feat(admin): add member onboarding alert detectors
This commit is contained in:
parent
d3a961f765
commit
824364d526
2 changed files with 192 additions and 2 deletions
|
|
@ -34,6 +34,86 @@ export function computeSignature(ids) {
|
|||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
// Alert functions land here in tasks 4–7.
|
||||
function memberItem(member, sublabel) {
|
||||
return {
|
||||
id: String(member._id),
|
||||
label: member.name,
|
||||
sublabel: sublabel ?? member.email,
|
||||
href: `/admin/members/${member._id}`
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectSlackInviteFailed() {
|
||||
await connectDB()
|
||||
const members = await Member
|
||||
.find({ slackInviteStatus: 'failed' })
|
||||
.select('name email')
|
||||
.lean()
|
||||
return {
|
||||
type: 'slack_invite_failed',
|
||||
severity: 'critical',
|
||||
title: 'Slack invites failed',
|
||||
items: members.map((m) => memberItem(m))
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectNoSlackHandleAfterWeek() {
|
||||
await connectDB()
|
||||
const cutoff = daysAgo(ALERT_THRESHOLDS.NO_SLACK_DAYS)
|
||||
const members = await Member
|
||||
.find({
|
||||
status: 'active',
|
||||
createdAt: { $lte: cutoff },
|
||||
$or: [
|
||||
{ slackUserId: { $exists: false } },
|
||||
{ slackUserId: null },
|
||||
{ slackUserId: '' }
|
||||
]
|
||||
})
|
||||
.select('name email createdAt')
|
||||
.lean()
|
||||
return {
|
||||
type: 'no_slack_handle_week',
|
||||
severity: 'attention',
|
||||
title: 'Active members without a Slack handle',
|
||||
items: members.map((m) =>
|
||||
memberItem(m, `${daysSince(m.createdAt)} days since joining`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectStuckPendingPayment() {
|
||||
await connectDB()
|
||||
const cutoff = daysAgo(ALERT_THRESHOLDS.STUCK_PAYMENT_DAYS)
|
||||
const members = await Member
|
||||
.find({
|
||||
status: 'pending_payment',
|
||||
createdAt: { $lte: cutoff }
|
||||
})
|
||||
.select('name email createdAt')
|
||||
.lean()
|
||||
return {
|
||||
type: 'stuck_pending_payment',
|
||||
severity: 'attention',
|
||||
title: 'Members stuck in pending payment',
|
||||
items: members.map((m) =>
|
||||
memberItem(m, `${daysSince(m.createdAt)} days stuck`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectSuspendedMembers() {
|
||||
await connectDB()
|
||||
const members = await Member
|
||||
.find({ status: 'suspended' })
|
||||
.select('name email')
|
||||
.lean()
|
||||
return {
|
||||
type: 'member_suspended',
|
||||
severity: 'attention',
|
||||
title: 'Suspended members',
|
||||
items: members.map((m) => memberItem(m))
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregator lands in task 8.
|
||||
|
|
|
|||
|
|
@ -46,7 +46,15 @@ vi.mock('../../../server/models/adminAlertDismissal.js', () => ({
|
|||
]
|
||||
}))
|
||||
|
||||
import { ALERT_THRESHOLDS, computeSignature } from '../../../server/utils/adminAlerts.js'
|
||||
import {
|
||||
ALERT_THRESHOLDS,
|
||||
computeSignature,
|
||||
detectSlackInviteFailed,
|
||||
detectNoSlackHandleAfterWeek,
|
||||
detectStuckPendingPayment,
|
||||
detectSuspendedMembers
|
||||
} from '../../../server/utils/adminAlerts.js'
|
||||
import Member from '../../../server/models/member.js'
|
||||
|
||||
describe('adminAlerts module shell', () => {
|
||||
describe('ALERT_THRESHOLDS', () => {
|
||||
|
|
@ -83,4 +91,106 @@ describe('adminAlerts module shell', () => {
|
|||
.toBe(computeSignature(['x', 'y']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('member alerts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function mockMemberFind(result) {
|
||||
Member.find.mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
lean: vi.fn().mockResolvedValue(result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('detectSlackInviteFailed', () => {
|
||||
it('returns matching members with critical severity', async () => {
|
||||
mockMemberFind([
|
||||
{ _id: 'm1', name: 'Alex', email: 'alex@example.com' },
|
||||
{ _id: 'm2', name: 'Bea', email: 'bea@example.com' }
|
||||
])
|
||||
|
||||
const alert = await detectSlackInviteFailed()
|
||||
|
||||
expect(alert.type).toBe('slack_invite_failed')
|
||||
expect(alert.severity).toBe('critical')
|
||||
expect(alert.items).toHaveLength(2)
|
||||
expect(alert.items[0]).toEqual({
|
||||
id: 'm1',
|
||||
label: 'Alex',
|
||||
sublabel: 'alex@example.com',
|
||||
href: '/admin/members/m1'
|
||||
})
|
||||
expect(Member.find).toHaveBeenCalledWith(
|
||||
{ slackInviteStatus: 'failed' }
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an empty list when nothing matches', async () => {
|
||||
mockMemberFind([])
|
||||
const alert = await detectSlackInviteFailed()
|
||||
expect(alert.items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectNoSlackHandleAfterWeek', () => {
|
||||
it('queries active members joined more than 7 days ago without a slackUserId', async () => {
|
||||
mockMemberFind([
|
||||
{ _id: 'm3', name: 'Cam', email: 'cam@example.com', createdAt: new Date(Date.now() - 9 * 86400000) }
|
||||
])
|
||||
|
||||
const alert = await detectNoSlackHandleAfterWeek()
|
||||
|
||||
expect(alert.type).toBe('no_slack_handle_week')
|
||||
expect(alert.severity).toBe('attention')
|
||||
expect(alert.items).toHaveLength(1)
|
||||
expect(alert.items[0].sublabel).toMatch(/9 days/)
|
||||
|
||||
const [query] = Member.find.mock.calls[0]
|
||||
expect(query.status).toBe('active')
|
||||
expect(query.createdAt).toEqual({ $lte: expect.any(Date) })
|
||||
// slackUserId condition: missing or empty
|
||||
expect(query.$or).toEqual([
|
||||
{ slackUserId: { $exists: false } },
|
||||
{ slackUserId: null },
|
||||
{ slackUserId: '' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectStuckPendingPayment', () => {
|
||||
it('returns members in pending_payment older than 7 days', async () => {
|
||||
mockMemberFind([
|
||||
{ _id: 'm4', name: 'Dee', email: 'dee@example.com', createdAt: new Date(Date.now() - 10 * 86400000) }
|
||||
])
|
||||
|
||||
const alert = await detectStuckPendingPayment()
|
||||
|
||||
expect(alert.type).toBe('stuck_pending_payment')
|
||||
expect(alert.severity).toBe('attention')
|
||||
expect(alert.items[0].sublabel).toMatch(/10 days/)
|
||||
|
||||
const [query] = Member.find.mock.calls[0]
|
||||
expect(query.status).toBe('pending_payment')
|
||||
expect(query.createdAt).toEqual({ $lte: expect.any(Date) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectSuspendedMembers', () => {
|
||||
it('returns suspended members', async () => {
|
||||
mockMemberFind([
|
||||
{ _id: 'm5', name: 'Eli', email: 'eli@example.com' }
|
||||
])
|
||||
|
||||
const alert = await detectSuspendedMembers()
|
||||
|
||||
expect(alert.type).toBe('member_suspended')
|
||||
expect(alert.severity).toBe('attention')
|
||||
expect(alert.items).toHaveLength(1)
|
||||
expect(Member.find).toHaveBeenCalledWith({ status: 'suspended' })
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue