feat(admin): PATCH /api/admin/members/:id/slack-status
Endpoint that flips a member's slackInvited flag manually after the admin has actually sent the Slack invitation through Slack's UI. No Slack API call is made from this app. - Body validated via Zod literal-true schema (no undo path for the pilot — admins correct mistakes in the database if needed). - Idempotent: re-marking an already-invited member is a no-op, preserving the original slackInvitedAt and not duplicating the activity log entry. - Activity log: slack_invited_manually, actor = admin from requireAdmin, subject = the target member.
This commit is contained in:
parent
55029e7eb7
commit
0981596ea2
3 changed files with 227 additions and 0 deletions
35
server/api/admin/members/[id]/slack-status.patch.js
Normal file
35
server/api/admin/members/[id]/slack-status.patch.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import Member from '../../../../models/member.js'
|
||||||
|
import { connectDB } from '../../../../utils/mongoose.js'
|
||||||
|
import { validateBody } from '../../../../utils/validateBody.js'
|
||||||
|
import { adminSlackStatusSchema } from '../../../../utils/schemas.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const admin = await requireAdmin(event)
|
||||||
|
await validateBody(event, adminSlackStatusSchema)
|
||||||
|
await connectDB()
|
||||||
|
|
||||||
|
const memberId = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
const existing = await Member.findById(memberId)
|
||||||
|
if (!existing) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Member not found.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: if already invited, no-op (preserve original slackInvitedAt, no log).
|
||||||
|
if (existing.slackInvited === true) {
|
||||||
|
return { success: true, member: existing }
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await Member.findByIdAndUpdate(
|
||||||
|
memberId,
|
||||||
|
{ slackInvited: true, slackInvitedAt: new Date() },
|
||||||
|
{ new: true, runValidators: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
logActivity(memberId, 'slack_invited_manually', {}, { performedBy: admin._id })
|
||||||
|
|
||||||
|
return { success: true, member }
|
||||||
|
})
|
||||||
|
|
@ -314,6 +314,10 @@ export const adminRoleUpdateSchema = z.object({
|
||||||
role: z.enum(['admin', 'member'])
|
role: z.enum(['admin', 'member'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const adminSlackStatusSchema = z.object({
|
||||||
|
slackInvited: z.literal(true)
|
||||||
|
}).strict()
|
||||||
|
|
||||||
export const bulkMemberImportSchema = z.object({
|
export const bulkMemberImportSchema = z.object({
|
||||||
members: z.array(z.object({
|
members: z.array(z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
|
|
|
||||||
188
tests/server/api/admin-members-slack-status.test.js
Normal file
188
tests/server/api/admin-members-slack-status.test.js
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
// Spec: docs/specs/wave-based-slack-onboarding.md
|
||||||
|
// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §5
|
||||||
|
//
|
||||||
|
// SCAFFOLD: `describe.skip` until the route at
|
||||||
|
// server/api/admin/members/[id]/slack-status.patch.js
|
||||||
|
// exists. Unskip during implementation (TDD).
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
|
default: {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByIdAndUpdate: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/mongoose.js', () => ({
|
||||||
|
connectDB: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/validateBody.js', () => ({
|
||||||
|
validateBody: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/schemas.js', () => ({
|
||||||
|
adminSlackStatusSchema: {}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Slack service must NOT be invoked from this endpoint.
|
||||||
|
vi.mock('../../../server/utils/slack.ts', () => ({
|
||||||
|
getSlackService: vi.fn().mockReturnValue({
|
||||||
|
inviteUserToSlack: vi.fn(),
|
||||||
|
findUserByEmail: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('PATCH /api/admin/members/[id]/slack-status', () => {
|
||||||
|
let handler
|
||||||
|
let Member
|
||||||
|
let validateBody
|
||||||
|
let getSlackService
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
Member = (await import('../../../server/models/member.js')).default
|
||||||
|
validateBody = (await import('../../../server/utils/validateBody.js')).validateBody
|
||||||
|
getSlackService = (await import('../../../server/utils/slack.ts')).getSlackService
|
||||||
|
|
||||||
|
handler = (
|
||||||
|
await import('../../../server/api/admin/members/[id]/slack-status.patch.js')
|
||||||
|
).default
|
||||||
|
|
||||||
|
globalThis.requireAdmin.mockResolvedValue({
|
||||||
|
_id: { toString: () => 'admin-1' }
|
||||||
|
})
|
||||||
|
validateBody.mockResolvedValue({ slackInvited: true })
|
||||||
|
vi.stubGlobal('getRouterParam', vi.fn().mockReturnValue('member-1'))
|
||||||
|
globalThis.logActivity.mockClear?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
function makeEvent() {
|
||||||
|
return { node: { req: {}, res: {} } } // handler reads via auto-imports + stubs
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('auth (5.4 / 5.5)', () => {
|
||||||
|
it('rejects unauthenticated requests with 401', async () => {
|
||||||
|
globalThis.requireAdmin.mockRejectedValue(
|
||||||
|
createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||||
|
)
|
||||||
|
await expect(handler(makeEvent())).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-admin members with 403', async () => {
|
||||||
|
globalThis.requireAdmin.mockRejectedValue(
|
||||||
|
createError({ statusCode: 403, statusMessage: 'Forbidden' })
|
||||||
|
)
|
||||||
|
await expect(handler(makeEvent())).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validation (5.6)', () => {
|
||||||
|
it('rejects invalid body via validateBody (zod)', async () => {
|
||||||
|
validateBody.mockRejectedValue(
|
||||||
|
createError({ statusCode: 400, statusMessage: 'Invalid body' })
|
||||||
|
)
|
||||||
|
await expect(handler(makeEvent())).rejects.toMatchObject({ statusCode: 400 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('member lookup (5.7)', () => {
|
||||||
|
it('returns 404 when member does not exist', async () => {
|
||||||
|
Member.findById.mockResolvedValue(null)
|
||||||
|
await expect(handler(makeEvent())).rejects.toMatchObject({ statusCode: 404 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('happy path: false → true transition (5.1)', () => {
|
||||||
|
it('marks member invited, stamps date, logs activity', async () => {
|
||||||
|
Member.findById.mockResolvedValue({
|
||||||
|
_id: 'member-1',
|
||||||
|
slackInvited: false,
|
||||||
|
slackInvitedAt: undefined
|
||||||
|
})
|
||||||
|
const updated = {
|
||||||
|
_id: 'member-1',
|
||||||
|
slackInvited: true,
|
||||||
|
slackInvitedAt: new Date('2026-04-29T00:00:00Z')
|
||||||
|
}
|
||||||
|
Member.findByIdAndUpdate.mockResolvedValue(updated)
|
||||||
|
|
||||||
|
const result = await handler(makeEvent())
|
||||||
|
|
||||||
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||||
|
'member-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
slackInvited: true,
|
||||||
|
slackInvitedAt: expect.any(Date)
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ runValidators: false, new: true })
|
||||||
|
)
|
||||||
|
expect(globalThis.logActivity).toHaveBeenCalledWith(
|
||||||
|
'member-1',
|
||||||
|
'slack_invited_manually',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ performedBy: expect.anything() })
|
||||||
|
)
|
||||||
|
expect(result).toMatchObject({ success: true })
|
||||||
|
expect(result.member).toMatchObject({
|
||||||
|
slackInvited: true,
|
||||||
|
slackInvitedAt: expect.any(Date)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses findByIdAndUpdate with runValidators:false (5.9)', async () => {
|
||||||
|
Member.findById.mockResolvedValue({ _id: 'member-1', slackInvited: false })
|
||||||
|
Member.findByIdAndUpdate.mockResolvedValue({ _id: 'member-1', slackInvited: true })
|
||||||
|
|
||||||
|
await handler(makeEvent())
|
||||||
|
|
||||||
|
const opts = Member.findByIdAndUpdate.mock.calls.at(-1)?.[2]
|
||||||
|
expect(opts).toMatchObject({ runValidators: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('idempotency: true → true (5.2)', () => {
|
||||||
|
it('preserves original slackInvitedAt and writes no new activity-log entry', async () => {
|
||||||
|
const original = new Date('2026-01-15T00:00:00Z')
|
||||||
|
Member.findById.mockResolvedValue({
|
||||||
|
_id: 'member-1',
|
||||||
|
slackInvited: true,
|
||||||
|
slackInvitedAt: original
|
||||||
|
})
|
||||||
|
|
||||||
|
await handler(makeEvent())
|
||||||
|
|
||||||
|
// Either the endpoint short-circuits with no write, OR the update payload
|
||||||
|
// carefully omits slackInvitedAt. Both are acceptable; assert outcome:
|
||||||
|
const updateCall = Member.findByIdAndUpdate.mock.calls.at(-1)
|
||||||
|
if (updateCall) {
|
||||||
|
expect(updateCall[1]).not.toHaveProperty('slackInvitedAt')
|
||||||
|
}
|
||||||
|
expect(globalThis.logActivity).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('no Slack API call (5.3)', () => {
|
||||||
|
it('does not invoke any Slack write API on success', async () => {
|
||||||
|
Member.findById.mockResolvedValue({ _id: 'member-1', slackInvited: false })
|
||||||
|
Member.findByIdAndUpdate.mockResolvedValue({ _id: 'member-1', slackInvited: true })
|
||||||
|
|
||||||
|
await handler(makeEvent())
|
||||||
|
|
||||||
|
const slack = getSlackService()
|
||||||
|
expect(slack.inviteUserToSlack).not.toHaveBeenCalled()
|
||||||
|
expect(slack.findUserByEmail).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('source inspection (handler shape)', () => {
|
||||||
|
// Mirrors the pattern in admin-role-patch.test.js — verifies route
|
||||||
|
// construction order without booting the handler.
|
||||||
|
it.todo('calls requireAdmin before validateBody')
|
||||||
|
it.todo('does not import getSlackService')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.todo('PATCH { slackInvited: false } behavior — decided in spec gap #2 (5.11)')
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue