From 55029e7eb777c6ebdc84458ef55606430af4f51c Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 29 Apr 2026 12:21:12 +0100 Subject: [PATCH] feat(activation): wire autoFlagPreExistingSlackAccess into self-serve paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-file inviteToSlack helpers with a single auto-flag call. Self-serve activation paths now check for pre-existing workspace membership (silent on miss) instead of attempting an admin-only invite. - helcim/subscription.post.js: removed local inviteToSlack; both free- and paid-tier activation branches now call the helper, then notifyNewMember with the canonical 'manual_invitation_required' arg. - members/create.post.js: same shape — helper + canonical notify arg. - invite/accept.post.js (free-tier branch): added the helper call after member creation. Free-tier had no prior Slack call (audit confirmed); paid-tier remains untouched and activates via the Helcim webhook. Admin-created and CSV-imported members intentionally do NOT call the helper — admins flip the flag manually after sending the invite. Test stub for autoFlagPreExistingSlackAccess added to server setup. --- server/api/helcim/subscription.post.js | 103 +++------ server/api/invite/accept.post.js | 1 + server/api/members/create.post.js | 92 ++------ tests/server/api/activation-auto-flag.test.js | 211 ++++++++++++++++++ tests/server/setup.js | 7 +- 5 files changed, 262 insertions(+), 152 deletions(-) create mode 100644 tests/server/api/activation-auto-flag.test.js diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js index c4eb977..577e264 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -8,77 +8,6 @@ import { createHelcimSubscription, generateIdempotencyKey, listHelcimCustomerTra import { sendWelcomeEmail } from '../../utils/resend.js' import { upsertPaymentFromHelcim } from '../../utils/payments.js' -// Function to invite member to Slack -async function inviteToSlack(member) { - try { - const slackService = getSlackService() - if (!slackService) { - console.warn('Slack service not configured, skipping invitation') - return - } - - console.log(`Processing Slack invitation for ${member.email}...`) - - const inviteResult = await slackService.inviteUserToSlack( - member.email, - member.name - ) - - if (inviteResult.success) { - const update = {} - if (inviteResult.status === 'existing_user_added_to_channel' || - inviteResult.status === 'user_already_in_channel' || - inviteResult.status === 'new_user_invited_to_workspace') { - update.slackInviteStatus = 'sent' - update.slackUserId = inviteResult.userId - update.slackInvited = true - } else { - update.slackInviteStatus = 'pending' - update.slackInvited = false - } - await Member.findByIdAndUpdate( - member._id, - { $set: update }, - { runValidators: false } - ) - - // Send notification to vetting channel - await slackService.notifyNewMember( - member.name, - member.email, - member.circle, - member.contributionAmount, - inviteResult.status - ) - - console.log(`Successfully processed Slack invitation for ${member.email}: ${inviteResult.status}`) - } else { - await Member.findByIdAndUpdate( - member._id, - { $set: { slackInviteStatus: 'failed' } }, - { runValidators: false } - ) - - console.error(`Failed to process Slack invitation for ${member.email}: ${inviteResult.error}`) - // Don't throw error - subscription creation should still succeed - } - } catch (error) { - console.error('Error during Slack invitation process:', error) - - try { - await Member.findByIdAndUpdate( - member._id, - { $set: { slackInviteStatus: 'failed' } }, - { runValidators: false } - ) - } catch (saveError) { - console.error('Failed to update member Slack status:', saveError) - } - - // Don't throw error - subscription creation should still succeed - } -} - export default defineEventHandler(async (event) => { try { // Membership signup completes subscription before email verify; allow the @@ -109,7 +38,21 @@ export default defineEventHandler(async (event) => { logActivity(member._id, 'subscription_created', { amount: body.contributionAmount }) - await inviteToSlack(member) + await autoFlagPreExistingSlackAccess(member) + try { + const slackService = getSlackService() + if (slackService) { + await slackService.notifyNewMember( + member.name, + member.email, + member.circle, + member.contributionAmount, + 'manual_invitation_required' + ) + } + } catch (err) { + console.error('[slack] notifyNewMember failed:', err) + } if (isFirstActivation) await sendWelcomeEmail(member) return { @@ -207,7 +150,21 @@ export default defineEventHandler(async (event) => { console.error('[payments] initial charge log failed, will be picked up by reconciliation:', err?.message || err) } - await inviteToSlack(member) + await autoFlagPreExistingSlackAccess(member) + try { + const slackService = getSlackService() + if (slackService) { + await slackService.notifyNewMember( + member.name, + member.email, + member.circle, + member.contributionAmount, + 'manual_invitation_required' + ) + } + } catch (err) { + console.error('[slack] notifyNewMember failed:', err) + } if (isFirstActivation) await sendWelcomeEmail(member) return { diff --git a/server/api/invite/accept.post.js b/server/api/invite/accept.post.js index 384ae8f..84d5db6 100644 --- a/server/api/invite/accept.post.js +++ b/server/api/invite/accept.post.js @@ -87,6 +87,7 @@ export default defineEventHandler(async (event) => { // For free tier, redirect to welcome if (body.contributionAmount === 0) { + await autoFlagPreExistingSlackAccess(member) return { success: true, requiresPayment: false, diff --git a/server/api/members/create.post.js b/server/api/members/create.post.js index 53827b0..9b78831 100644 --- a/server/api/members/create.post.js +++ b/server/api/members/create.post.js @@ -7,80 +7,6 @@ import { memberCreateSchema } from '../../utils/schemas.js' import { sendWelcomeEmail } from '../../utils/resend.js' import { assignMemberNumber } from '../../utils/memberNumber.js' -// Function to invite member to Slack -async function inviteToSlack(member) { - try { - const slackService = getSlackService() - if (!slackService) { - console.warn('Slack service not configured, skipping invitation') - return - } - - console.warn(`Processing Slack invitation for member`) - - const inviteResult = await slackService.inviteUserToSlack( - member.email, - member.name - ) - - if (inviteResult.success) { - // Update member record based on the actual result - if (inviteResult.status === 'existing_user_added_to_channel' || - inviteResult.status === 'user_already_in_channel' || - inviteResult.status === 'new_user_invited_to_workspace') { - await Member.findByIdAndUpdate( - member._id, - { $set: { slackInviteStatus: 'sent', slackUserId: inviteResult.userId, slackInvited: true } }, - { runValidators: false } - ) - } else { - // Manual invitation required - await Member.findByIdAndUpdate( - member._id, - { $set: { slackInviteStatus: 'pending', slackInvited: false } }, - { runValidators: false } - ) - } - - // Send notification to vetting channel - await slackService.notifyNewMember( - member.name, - member.email, - member.circle, - member.contributionAmount, - inviteResult.status - ) - - console.warn(`Slack invitation processed: ${inviteResult.status}`) - } else { - // Update member record to reflect failed invitation - await Member.findByIdAndUpdate( - member._id, - { $set: { slackInviteStatus: 'failed' } }, - { runValidators: false } - ) - - console.error(`Failed to process Slack invitation: ${inviteResult.error}`) - // Don't throw error - member creation should still succeed - } - } catch (error) { - console.error('Error during Slack invitation process:', error) - - // Update member record to reflect failed invitation - try { - await Member.findByIdAndUpdate( - member._id, - { $set: { slackInviteStatus: 'failed' } }, - { runValidators: false } - ) - } catch (saveError) { - console.error('Failed to update member Slack status:', saveError) - } - - // Don't throw error - member creation should still succeed - } -} - export default defineEventHandler(async (event) => { // Ensure database is connected await connectDB() @@ -107,8 +33,22 @@ export default defineEventHandler(async (event) => { circle: member.circle }, { timestamp: member.createdAt }) - // Send Slack invitation for new members - await inviteToSlack(member) + // Auto-flag pre-existing Slack workspace members; admin manually invites the rest. + await autoFlagPreExistingSlackAccess(member) + try { + const slackService = getSlackService() + if (slackService) { + await slackService.notifyNewMember( + member.name, + member.email, + member.circle, + member.contributionAmount, + 'manual_invitation_required' + ) + } + } catch (err) { + console.error('[slack] notifyNewMember failed:', err) + } // Send welcome email (non-blocking) try { diff --git a/tests/server/api/activation-auto-flag.test.js b/tests/server/api/activation-auto-flag.test.js new file mode 100644 index 0000000..bcf7493 --- /dev/null +++ b/tests/server/api/activation-auto-flag.test.js @@ -0,0 +1,211 @@ +// Spec: docs/specs/wave-based-slack-onboarding.md +// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §3 + §4 +// +// Verifies that the three self-serve activation paths invoke +// `autoFlagPreExistingSlackAccess`, while the two admin-create paths do not. +// +// `autoFlagPreExistingSlackAccess` is auto-imported by Nitro at runtime; in +// tests it's stubbed as a global in tests/server/setup.js. Tests grab the stub +// off `globalThis` and reset it per-case. + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import Member from '../../../server/models/member.js' +import { requireAuth } from '../../../server/utils/auth.js' +import { requiresPayment } from '../../../server/config/contributions.js' +import { sendWelcomeEmail } from '../../../server/utils/resend.js' +import { validateBody } from '../../../server/utils/validateBody.js' +// jwt and the auto-flag helper are mocked via globals/vi.mock +import PreRegistration from '../../../server/models/preRegistration.js' +import { createHelcimCustomer } from '../../../server/utils/helcim.js' +import subscriptionHandler from '../../../server/api/helcim/subscription.post.js' +import membersCreateHandler from '../../../server/api/members/create.post.js' +import inviteAcceptHandler from '../../../server/api/invite/accept.post.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +vi.mock('../../../server/models/member.js', () => { + const mockSave = vi.fn().mockResolvedValue(undefined) + function MockMember(data) { + Object.assign(this, data) + this._id = 'new-member-123' + this.status = data.status || 'pending_payment' + this.save = mockSave + } + MockMember.findOne = vi.fn() + MockMember.findOneAndUpdate = vi.fn() + MockMember.findById = vi.fn() + MockMember.findByIdAndUpdate = vi.fn() + MockMember.create = vi.fn() + MockMember._mockSave = mockSave + return { default: MockMember } +}) +vi.mock('../../../server/models/preRegistration.js', () => ({ + default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() } +})) +vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) +vi.mock('../../../server/utils/auth.js', () => ({ + requireAuth: vi.fn(), + getPaymentBridgeMember: vi.fn().mockResolvedValue(null), + setAuthCookie: vi.fn() +})) +vi.mock('../../../server/utils/slack.ts', () => ({ + getSlackService: vi.fn().mockReturnValue(null) +})) +vi.mock('../../../server/config/contributions.js', () => ({ + requiresPayment: vi.fn(), + getHelcimPlanId: vi.fn() +})) +vi.mock('../../../server/utils/resend.js', () => ({ + sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true }) +})) +vi.mock('../../../server/utils/helcim.js', () => ({ + createHelcimSubscription: vi.fn(), + generateIdempotencyKey: vi.fn().mockReturnValue('idem-key-1'), + listHelcimCustomerTransactions: vi.fn().mockResolvedValue([]), + createHelcimCustomer: vi.fn() +})) +vi.mock('../../../server/utils/payments.js', () => ({ + upsertPaymentFromHelcim: vi.fn().mockResolvedValue({ created: true, payment: { _id: 'p1' } }) +})) +vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() })) +vi.mock('../../../server/utils/schemas.js', () => ({ memberCreateSchema: {} })) +vi.mock('../../../server/utils/memberNumber.js', () => ({ + assignMemberNumber: vi.fn().mockResolvedValue(1) +})) +vi.mock('jsonwebtoken', () => { + const verify = vi.fn(() => ({ type: 'prereg-invite', preRegistrationId: 'prereg-1' })) + return { + default: { verify }, + verify + } +}) + +vi.stubGlobal('helcimSubscriptionSchema', {}) +vi.stubGlobal('inviteAcceptSchema', {}) + +const autoFlagStub = globalThis.autoFlagPreExistingSlackAccess + +// --------------------------------------------------------------------------- +// 3.1 — Helcim subscription success calls helper +// --------------------------------------------------------------------------- +describe('POST /api/helcim/subscription — auto-flag wiring (3.1)', () => { + beforeEach(() => { + vi.clearAllMocks() + autoFlagStub.mockClear() + requireAuth.mockResolvedValue(undefined) + requiresPayment.mockReturnValue(false) + }) + + it('calls autoFlagPreExistingSlackAccess after successful free-tier activation', async () => { + const mockMember = { + _id: 'member-1', + email: 'free@example.com', + name: 'Free Tier', + circle: 'community', + contributionAmount: 0, + status: 'active' + } + Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-1', status: 'pending_payment' }) + Member.findById.mockResolvedValue(mockMember) + + const event = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + body: { customerId: 'cust-1', contributionAmount: 0, customerCode: 'code-1' } + }) + + await subscriptionHandler(event) + + expect(autoFlagStub).toHaveBeenCalledTimes(1) + expect(autoFlagStub).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'member-1', email: 'free@example.com' }) + ) + }) +}) + +// --------------------------------------------------------------------------- +// 3.2 — Free-tier /api/invite/accept calls helper +// --------------------------------------------------------------------------- +describe('POST /api/invite/accept (free tier) — auto-flag wiring (3.2)', () => { + beforeEach(() => { + vi.clearAllMocks() + autoFlagStub.mockClear() + PreRegistration.findById.mockResolvedValue({ + _id: 'prereg-1', + email: 'invitee@example.com', + status: 'pending' + }) + PreRegistration.findByIdAndUpdate.mockResolvedValue(undefined) + Member.findOne.mockResolvedValue(null) + Member.create.mockImplementation(async (data) => ({ + _id: 'new-member-via-invite', + ...data + })) + // Handler uses Nitro auto-imported validateBody (global), not the explicit import + globalThis.validateBody.mockResolvedValue({ + token: 'tok', + preRegistrationId: 'prereg-1', + name: 'New Invitee', + circle: 'community', + contributionAmount: 0 + }) + }) + + it('calls helper after Member.create on the free-tier branch', async () => { + const event = createMockEvent({ method: 'POST', path: '/api/invite/accept' }) + + await inviteAcceptHandler(event) + + expect(autoFlagStub).toHaveBeenCalledTimes(1) + expect(autoFlagStub).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'new-member-via-invite', email: 'invitee@example.com' }) + ) + }) + + it('paid-tier branch does NOT call helper (3.3)', async () => { + globalThis.validateBody.mockResolvedValue({ + token: 'tok', + preRegistrationId: 'prereg-1', + name: 'Paid Invitee', + circle: 'community', + contributionAmount: 25 + }) + createHelcimCustomer.mockResolvedValue({ id: 'cust-2', customerCode: 'code-2' }) + + const event = createMockEvent({ method: 'POST', path: '/api/invite/accept' }) + + await inviteAcceptHandler(event) + + expect(autoFlagStub).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// 3.8 — members/create.post.js calls helper instead of legacy inviteToSlack +// --------------------------------------------------------------------------- +describe('POST /api/members/create — auto-flag wiring (3.8)', () => { + beforeEach(() => { + vi.clearAllMocks() + autoFlagStub.mockClear() + Member.findOne.mockResolvedValue(null) + Member._mockSave.mockResolvedValue(undefined) + sendWelcomeEmail.mockResolvedValue({ success: true }) + validateBody.mockResolvedValue({ + email: 'mc@example.com', + name: 'MC Member', + circle: 'community', + contributionAmount: 0 + }) + }) + + it('calls helper instead of legacy inviteToSlack', async () => { + const event = createMockEvent({ method: 'POST', path: '/api/members/create' }) + + await membersCreateHandler(event) + + expect(autoFlagStub).toHaveBeenCalledTimes(1) + expect(autoFlagStub).toHaveBeenCalledWith( + expect.objectContaining({ email: 'mc@example.com' }) + ) + }) +}) diff --git a/tests/server/setup.js b/tests/server/setup.js index f2fc815..ad47f25 100644 --- a/tests/server/setup.js +++ b/tests/server/setup.js @@ -15,6 +15,9 @@ import { sendRedirect } from 'h3' +// Real server/utils that are safe to use as-is in tests +import { escapeRegex } from '../../server/utils/escapeRegex.js' + // Register real h3 functions as globals so server code that relies on // Nitro auto-imports can find them in the test environment. vi.stubGlobal('getCookie', getCookie) @@ -42,7 +45,5 @@ vi.stubGlobal('requireAdmin', vi.fn()) vi.stubGlobal('validateBody', vi.fn(async (event) => readBody(event))) vi.stubGlobal('logActivity', vi.fn()) vi.stubGlobal('validateTagSlugs', vi.fn()) - -// Real server/utils that are safe to use as-is in tests -import { escapeRegex } from '../../server/utils/escapeRegex.js' +vi.stubGlobal('autoFlagPreExistingSlackAccess', vi.fn().mockResolvedValue(undefined)) vi.stubGlobal('escapeRegex', escapeRegex)