diff --git a/server/utils/adminAlerts.js b/server/utils/adminAlerts.js index 8b5cb17..adb1ffa 100644 --- a/server/utils/adminAlerts.js +++ b/server/utils/adminAlerts.js @@ -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. diff --git a/tests/server/utils/adminAlerts.test.js b/tests/server/utils/adminAlerts.test.js index bbcea8c..7f1fa27 100644 --- a/tests/server/utils/adminAlerts.test.js +++ b/tests/server/utils/adminAlerts.test.js @@ -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' }) + }) + }) + }) })