refactor(helcim): wrapped PATCH body, first-activation welcome email guard
Moves updateHelcimSubscription to the live-verified wrapped shape
(PATCH /subscriptions { subscriptions: [{ id, ...payload }] }), adds a prior-
status check so sendWelcomeEmail only fires on pending_payment to active
transitions, short-circuits get-or-create-customer when a valid
helcimCustomerId is already on file, and replaces member.save() Slack-status
writes with findByIdAndUpdate({ runValidators: false }) to avoid save-time
validator pitfalls.
This commit is contained in:
parent
37a58cb0eb
commit
4f567e9586
4 changed files with 100 additions and 41 deletions
|
|
@ -1,17 +1,47 @@
|
||||||
// Get existing or create new Helcim customer (for upgrading members)
|
// Get existing or create new Helcim customer (for upgrading members)
|
||||||
|
import Member from '../../models/member.js'
|
||||||
import { requireAuth } from '../../utils/auth.js'
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
import { findHelcimCustomerByEmail, createHelcimCustomer } from '../../utils/helcim.js'
|
import {
|
||||||
|
getHelcimCustomer,
|
||||||
|
findHelcimCustomerByEmail,
|
||||||
|
createHelcimCustomer
|
||||||
|
} from '../../utils/helcim.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const member = await requireAuth(event)
|
const member = await requireAuth(event)
|
||||||
|
|
||||||
// First, try to find an existing customer
|
// 1. Short-circuit: member already has a Helcim customer ID on file.
|
||||||
|
// Verify it still resolves before falling through — otherwise we'd
|
||||||
|
// silently orphan the old customer and create a duplicate.
|
||||||
|
if (member.helcimCustomerId) {
|
||||||
|
try {
|
||||||
|
const customer = await getHelcimCustomer(member.helcimCustomerId)
|
||||||
|
if (customer?.id) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
customerId: customer.id,
|
||||||
|
customerCode: customer.customerCode,
|
||||||
|
existing: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Only fall through on 404 (customer was deleted in Helcim).
|
||||||
|
// Any other error must propagate — silently recreating was the
|
||||||
|
// original duplicate-customer bug.
|
||||||
|
if (err?.statusCode !== 404) throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall back to search by email (case-insensitive match).
|
||||||
let existingCustomer = null
|
let existingCustomer = null
|
||||||
try {
|
try {
|
||||||
const searchData = await findHelcimCustomerByEmail(member.email)
|
const searchData = await findHelcimCustomerByEmail(member.email)
|
||||||
if (searchData.customers && searchData.customers.length > 0) {
|
const memberEmail = member.email.toLowerCase()
|
||||||
existingCustomer = searchData.customers.find(c => c.email === member.email) || null
|
if (searchData.customers?.length) {
|
||||||
|
existingCustomer = searchData.customers.find(
|
||||||
|
c => c.email?.toLowerCase() === memberEmail
|
||||||
|
) || null
|
||||||
}
|
}
|
||||||
} catch (searchError) {
|
} catch (searchError) {
|
||||||
console.error('Error searching for customer:', searchError)
|
console.error('Error searching for customer:', searchError)
|
||||||
|
|
@ -20,8 +50,11 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
if (existingCustomer) {
|
if (existingCustomer) {
|
||||||
if (!member.helcimCustomerId) {
|
if (!member.helcimCustomerId) {
|
||||||
member.helcimCustomerId = existingCustomer.id
|
await Member.findByIdAndUpdate(
|
||||||
await member.save()
|
member._id,
|
||||||
|
{ $set: { helcimCustomerId: existingCustomer.id } },
|
||||||
|
{ runValidators: false }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -31,15 +64,18 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No existing customer found — create one
|
// 3. No match anywhere — create a fresh customer.
|
||||||
const customerData = await createHelcimCustomer({
|
const customerData = await createHelcimCustomer({
|
||||||
contactName: member.name,
|
contactName: member.name,
|
||||||
businessName: member.name,
|
businessName: member.name,
|
||||||
email: member.email
|
email: member.email
|
||||||
})
|
})
|
||||||
|
|
||||||
member.helcimCustomerId = customerData.id
|
await Member.findByIdAndUpdate(
|
||||||
await member.save()
|
member._id,
|
||||||
|
{ $set: { helcimCustomerId: customerData.id } },
|
||||||
|
{ runValidators: false }
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { connectDB } from '../../utils/mongoose.js'
|
||||||
import { getSlackService } from '../../utils/slack.ts'
|
import { getSlackService } from '../../utils/slack.ts'
|
||||||
import { requireAuth } from '../../utils/auth.js'
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
import { createHelcimSubscription, generateIdempotencyKey } from '../../utils/helcim.js'
|
import { createHelcimSubscription, generateIdempotencyKey } from '../../utils/helcim.js'
|
||||||
|
import { sendWelcomeEmail } from '../../utils/resend.js'
|
||||||
|
|
||||||
// Function to invite member to Slack
|
// Function to invite member to Slack
|
||||||
async function inviteToSlack(member) {
|
async function inviteToSlack(member) {
|
||||||
|
|
@ -23,20 +24,23 @@ async function inviteToSlack(member) {
|
||||||
)
|
)
|
||||||
|
|
||||||
if (inviteResult.success) {
|
if (inviteResult.success) {
|
||||||
// Update member record based on the actual result
|
const update = {}
|
||||||
if (inviteResult.status === 'existing_user_added_to_channel' ||
|
if (inviteResult.status === 'existing_user_added_to_channel' ||
|
||||||
inviteResult.status === 'user_already_in_channel' ||
|
inviteResult.status === 'user_already_in_channel' ||
|
||||||
inviteResult.status === 'new_user_invited_to_workspace') {
|
inviteResult.status === 'new_user_invited_to_workspace') {
|
||||||
member.slackInviteStatus = 'sent'
|
update.slackInviteStatus = 'sent'
|
||||||
member.slackUserId = inviteResult.userId
|
update.slackUserId = inviteResult.userId
|
||||||
member.slackInvited = true
|
update.slackInvited = true
|
||||||
} else {
|
} else {
|
||||||
// Manual invitation required
|
update.slackInviteStatus = 'pending'
|
||||||
member.slackInviteStatus = 'pending'
|
update.slackInvited = false
|
||||||
member.slackInvited = false
|
|
||||||
}
|
}
|
||||||
await member.save()
|
await Member.findByIdAndUpdate(
|
||||||
|
member._id,
|
||||||
|
{ $set: update },
|
||||||
|
{ runValidators: false }
|
||||||
|
)
|
||||||
|
|
||||||
// Send notification to vetting channel
|
// Send notification to vetting channel
|
||||||
await slackService.notifyNewMember(
|
await slackService.notifyNewMember(
|
||||||
member.name,
|
member.name,
|
||||||
|
|
@ -45,23 +49,27 @@ async function inviteToSlack(member) {
|
||||||
member.contributionTier,
|
member.contributionTier,
|
||||||
inviteResult.status
|
inviteResult.status
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(`Successfully processed Slack invitation for ${member.email}: ${inviteResult.status}`)
|
console.log(`Successfully processed Slack invitation for ${member.email}: ${inviteResult.status}`)
|
||||||
} else {
|
} else {
|
||||||
// Update member record to reflect failed invitation
|
await Member.findByIdAndUpdate(
|
||||||
member.slackInviteStatus = 'failed'
|
member._id,
|
||||||
await member.save()
|
{ $set: { slackInviteStatus: 'failed' } },
|
||||||
|
{ runValidators: false }
|
||||||
|
)
|
||||||
|
|
||||||
console.error(`Failed to process Slack invitation for ${member.email}: ${inviteResult.error}`)
|
console.error(`Failed to process Slack invitation for ${member.email}: ${inviteResult.error}`)
|
||||||
// Don't throw error - subscription creation should still succeed
|
// Don't throw error - subscription creation should still succeed
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during Slack invitation process:', error)
|
console.error('Error during Slack invitation process:', error)
|
||||||
|
|
||||||
// Update member record to reflect failed invitation
|
|
||||||
try {
|
try {
|
||||||
member.slackInviteStatus = 'failed'
|
await Member.findByIdAndUpdate(
|
||||||
await member.save()
|
member._id,
|
||||||
|
{ $set: { slackInviteStatus: 'failed' } },
|
||||||
|
{ runValidators: false }
|
||||||
|
)
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
console.error('Failed to update member Slack status:', saveError)
|
console.error('Failed to update member Slack status:', saveError)
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +84,14 @@ export default defineEventHandler(async (event) => {
|
||||||
await connectDB()
|
await connectDB()
|
||||||
const body = await validateBody(event, helcimSubscriptionSchema)
|
const body = await validateBody(event, helcimSubscriptionSchema)
|
||||||
|
|
||||||
|
// Only send welcome email when a member transitions from pending_payment
|
||||||
|
// to active for the first time — not on tier upgrades (active → active).
|
||||||
|
const priorMember = await Member.findOne(
|
||||||
|
{ helcimCustomerId: body.customerId },
|
||||||
|
{ status: 1 }
|
||||||
|
)
|
||||||
|
const isFirstActivation = priorMember?.status === 'pending_payment'
|
||||||
|
|
||||||
// Check if payment is required
|
// Check if payment is required
|
||||||
if (!requiresPayment(body.contributionTier)) {
|
if (!requiresPayment(body.contributionTier)) {
|
||||||
// For free tier, just update member status
|
// For free tier, just update member status
|
||||||
|
|
@ -91,8 +107,8 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
logActivity(member._id, 'subscription_created', { tier: body.contributionTier })
|
logActivity(member._id, 'subscription_created', { tier: body.contributionTier })
|
||||||
|
|
||||||
// Send Slack invitation for free tier members
|
|
||||||
await inviteToSlack(member)
|
await inviteToSlack(member)
|
||||||
|
if (isFirstActivation) await sendWelcomeEmail(member)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -134,8 +150,8 @@ export default defineEventHandler(async (event) => {
|
||||||
{ new: true }
|
{ new: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Send Slack invitation even when no plan is configured
|
|
||||||
await inviteToSlack(member)
|
await inviteToSlack(member)
|
||||||
|
if (isFirstActivation) await sendWelcomeEmail(member)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -194,8 +210,8 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
logActivity(member._id, 'subscription_created', { tier: body.contributionTier })
|
logActivity(member._id, 'subscription_created', { tier: body.contributionTier })
|
||||||
|
|
||||||
// Send Slack invitation for paid tier members
|
|
||||||
await inviteToSlack(member)
|
await inviteToSlack(member)
|
||||||
|
if (isFirstActivation) await sendWelcomeEmail(member)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -236,8 +252,8 @@ export default defineEventHandler(async (event) => {
|
||||||
{ new: true }
|
{ new: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Send Slack invitation even when subscription setup fails
|
|
||||||
await inviteToSlack(member)
|
await inviteToSlack(member)
|
||||||
|
if (isFirstActivation) await sendWelcomeEmail(member)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,11 @@ export const cancelHelcimSubscription = (id) =>
|
||||||
helcimFetch(`/subscriptions/${id}`, { method: 'DELETE', errorMessage: 'Subscription cancellation failed' })
|
helcimFetch(`/subscriptions/${id}`, { method: 'DELETE', errorMessage: 'Subscription cancellation failed' })
|
||||||
|
|
||||||
export const updateHelcimSubscription = (id, payload) =>
|
export const updateHelcimSubscription = (id, payload) =>
|
||||||
helcimFetch(`/subscriptions/${id}`, { method: 'PATCH', body: payload, errorMessage: 'Subscription update failed' })
|
helcimFetch('/subscriptions', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { subscriptions: [{ id: String(id), ...payload }] },
|
||||||
|
errorMessage: 'Subscription update failed'
|
||||||
|
})
|
||||||
|
|
||||||
// ---- Payment plans (admin) ----
|
// ---- Payment plans (admin) ----
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
import Member from '../../../server/models/member.js'
|
||||||
|
import { requireAuth } from '../../../server/utils/auth.js'
|
||||||
|
import { requiresPayment, getHelcimPlanId, getContributionTierByValue } from '../../../server/config/contributions.js'
|
||||||
|
import subscriptionHandler from '../../../server/api/helcim/subscription.post.js'
|
||||||
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
vi.mock('../../../server/models/member.js', () => ({
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
default: { findOneAndUpdate: vi.fn() }
|
default: { findOneAndUpdate: vi.fn(), findOne: vi.fn() }
|
||||||
}))
|
}))
|
||||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||||
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
|
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
|
||||||
|
|
@ -13,12 +19,9 @@ vi.mock('../../../server/config/contributions.js', () => ({
|
||||||
getHelcimPlanId: vi.fn(),
|
getHelcimPlanId: vi.fn(),
|
||||||
getContributionTierByValue: vi.fn()
|
getContributionTierByValue: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
vi.mock('../../../server/utils/resend.js', () => ({
|
||||||
import Member from '../../../server/models/member.js'
|
sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true })
|
||||||
import { requireAuth } from '../../../server/utils/auth.js'
|
}))
|
||||||
import { requiresPayment, getHelcimPlanId, getContributionTierByValue } from '../../../server/config/contributions.js'
|
|
||||||
import subscriptionHandler from '../../../server/api/helcim/subscription.post.js'
|
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
|
||||||
|
|
||||||
// helcimSubscriptionSchema is a Nitro auto-import used by validateBody
|
// helcimSubscriptionSchema is a Nitro auto-import used by validateBody
|
||||||
vi.stubGlobal('helcimSubscriptionSchema', {})
|
vi.stubGlobal('helcimSubscriptionSchema', {})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue