feat(slack): autoFlagPreExistingSlackAccess helper
Best-effort lookup of an activating member's email in the Slack workspace. On a hit, flips slackInvited:true and stamps slackInvitedAt without sending a fresh invite. Races against a 3s timeout and swallows all errors so activation never blocks on Slack. - Promotes SlackService.findUserByEmail from private to public so the helper can call it without a wrapper. - New activity-log action: slack_access_auto_detected (actor = subject). - Idempotent: short-circuits when slackInvited is already true. Callers wired in next commit.
This commit is contained in:
parent
2f6a92ac61
commit
b1d8cb1966
4 changed files with 263 additions and 1 deletions
|
|
@ -104,7 +104,7 @@ export class SlackService {
|
|||
/**
|
||||
* Find user in workspace by email
|
||||
*/
|
||||
private async findUserByEmail(email: string): Promise<string | null> {
|
||||
async findUserByEmail(email: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await this.client.users.lookupByEmail({ email });
|
||||
return response.user?.id || null;
|
||||
|
|
|
|||
61
server/utils/slackAccess.js
Normal file
61
server/utils/slackAccess.js
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
67
tests/server/models/member-slack-fields.test.js
Normal file
67
tests/server/models/member-slack-fields.test.js
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
134
tests/server/utils/slackAccess.test.js
Normal file
134
tests/server/utils/slackAccess.test.js
Normal file
|
|
@ -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)')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue