feat(onboarding): add onboarding status and track API routes with tests
This commit is contained in:
parent
3797ff7925
commit
56376d1995
6 changed files with 470 additions and 0 deletions
28
server/api/onboarding/status.get.js
Normal file
28
server/api/onboarding/status.get.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const member = await requireAuth(event)
|
||||||
|
|
||||||
|
const hasProfileTags =
|
||||||
|
member.craftTags.length > 0 &&
|
||||||
|
(member.communityEcology?.topics || []).length > 0
|
||||||
|
|
||||||
|
const hasVisitedEvent = !!member.onboarding?.eventPageVisited
|
||||||
|
|
||||||
|
const topics = member.communityEcology?.topics || []
|
||||||
|
const hasEngagedEcology =
|
||||||
|
!!member.onboarding?.ecologyPageVisited &&
|
||||||
|
topics.some((t) => ['help', 'interested', 'seeking'].includes(t.state))
|
||||||
|
|
||||||
|
const hasClickedWiki = !!member.onboarding?.wikiClicked
|
||||||
|
|
||||||
|
return {
|
||||||
|
goals: {
|
||||||
|
hasProfileTags,
|
||||||
|
hasVisitedEvent,
|
||||||
|
hasEngagedEcology,
|
||||||
|
hasClickedWiki,
|
||||||
|
},
|
||||||
|
completedAt: member.onboarding?.completedAt || null,
|
||||||
|
}
|
||||||
|
})
|
||||||
51
server/api/onboarding/track.post.js
Normal file
51
server/api/onboarding/track.post.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { requireAuth } from '../../utils/auth.js'
|
||||||
|
import { validateBody } from '../../utils/validateBody.js'
|
||||||
|
import { onboardingTrackSchema } from '../../utils/schemas.js'
|
||||||
|
import Member from '../../models/member.js'
|
||||||
|
import { logActivity } from '../../utils/activityLog.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const member = await requireAuth(event)
|
||||||
|
const { goal } = await validateBody(event, onboardingTrackSchema)
|
||||||
|
|
||||||
|
// Already graduated — no-op
|
||||||
|
if (member.onboarding?.completedAt) {
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent — already tracked
|
||||||
|
if (member.onboarding?.[goal]) {
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the boolean
|
||||||
|
await Member.findByIdAndUpdate(member._id, {
|
||||||
|
$set: { [`onboarding.${goal}`]: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log the individual goal completion
|
||||||
|
await logActivity(member._id, 'member_onboarding_goal_completed', { goal }, { visibility: 'admin' })
|
||||||
|
|
||||||
|
// Graduation check — atomic so concurrent requests can't double-graduate
|
||||||
|
const graduated = await Member.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
_id: member._id,
|
||||||
|
'onboarding.completedAt': null,
|
||||||
|
'onboarding.eventPageVisited': true,
|
||||||
|
'onboarding.ecologyPageVisited': true,
|
||||||
|
'onboarding.wikiClicked': true,
|
||||||
|
'craftTags.0': { $exists: true },
|
||||||
|
'communityEcology.topics': {
|
||||||
|
$elemMatch: { state: { $in: ['help', 'interested', 'seeking'] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $set: { 'onboarding.completedAt': new Date() } },
|
||||||
|
{ new: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (graduated) {
|
||||||
|
await logActivity(member._id, 'member_onboarding_completed', {}, { visibility: 'admin' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, graduated: !!graduated }
|
||||||
|
})
|
||||||
|
|
@ -152,6 +152,13 @@ const memberSchema = new mongoose.Schema({
|
||||||
|
|
||||||
memberNumber: { type: Number, unique: true, sparse: true },
|
memberNumber: { type: Number, unique: true, sparse: true },
|
||||||
|
|
||||||
|
onboarding: {
|
||||||
|
completedAt: { type: Date, default: null },
|
||||||
|
eventPageVisited: { type: Boolean, default: false },
|
||||||
|
ecologyPageVisited: { type: Boolean, default: false },
|
||||||
|
wikiClicked: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
|
||||||
createdAt: { type: Date, default: Date.now },
|
createdAt: { type: Date, default: Date.now },
|
||||||
lastLogin: Date,
|
lastLogin: Date,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,12 @@ export const inviteAcceptSchema = z.object({
|
||||||
token: z.string().min(1)
|
token: z.string().min(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Onboarding schemas ---
|
||||||
|
|
||||||
|
export const onboardingTrackSchema = z.object({
|
||||||
|
goal: z.enum(['eventPageVisited', 'ecologyPageVisited', 'wikiClicked'])
|
||||||
|
})
|
||||||
|
|
||||||
// --- Tag schemas ---
|
// --- Tag schemas ---
|
||||||
|
|
||||||
export const tagSuggestionSchema = z.object({
|
export const tagSuggestionSchema = z.object({
|
||||||
|
|
|
||||||
164
tests/server/api/onboarding-status.test.js
Normal file
164
tests/server/api/onboarding-status.test.js
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
|
requireAuth: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/mongoose.js', () => ({
|
||||||
|
connectDB: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { requireAuth } from '../../../server/utils/auth.js'
|
||||||
|
import handler from '../../../server/api/onboarding/status.get.js'
|
||||||
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
|
describe('GET /api/onboarding/status', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1.1: Default state for new member — all false, completedAt null
|
||||||
|
it('returns all goals false for a new member with no data', async () => {
|
||||||
|
requireAuth.mockResolvedValue({
|
||||||
|
_id: 'member-1',
|
||||||
|
craftTags: [],
|
||||||
|
communityEcology: { topics: [] },
|
||||||
|
onboarding: {
|
||||||
|
completedAt: null,
|
||||||
|
eventPageVisited: false,
|
||||||
|
ecologyPageVisited: false,
|
||||||
|
wikiClicked: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
|
||||||
|
const result = await handler(event)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
goals: {
|
||||||
|
hasProfileTags: false,
|
||||||
|
hasVisitedEvent: false,
|
||||||
|
hasEngagedEcology: false,
|
||||||
|
hasClickedWiki: false,
|
||||||
|
},
|
||||||
|
completedAt: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1.2: hasProfileTags true when both tag types present
|
||||||
|
it('hasProfileTags is true when member has both craft tags and ecology topics', async () => {
|
||||||
|
requireAuth.mockResolvedValue({
|
||||||
|
_id: 'member-1',
|
||||||
|
craftTags: ['game-design'],
|
||||||
|
communityEcology: {
|
||||||
|
topics: [{ tagSlug: 'governance', state: 'interested' }],
|
||||||
|
},
|
||||||
|
onboarding: {
|
||||||
|
completedAt: null,
|
||||||
|
eventPageVisited: false,
|
||||||
|
ecologyPageVisited: false,
|
||||||
|
wikiClicked: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
|
||||||
|
const result = await handler(event)
|
||||||
|
|
||||||
|
expect(result.goals.hasProfileTags).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1.3: hasProfileTags false when only craft tags
|
||||||
|
it('hasProfileTags is false when member has craft tags but no ecology topics', async () => {
|
||||||
|
requireAuth.mockResolvedValue({
|
||||||
|
_id: 'member-1',
|
||||||
|
craftTags: ['game-design'],
|
||||||
|
communityEcology: { topics: [] },
|
||||||
|
onboarding: {
|
||||||
|
completedAt: null,
|
||||||
|
eventPageVisited: false,
|
||||||
|
ecologyPageVisited: false,
|
||||||
|
wikiClicked: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
|
||||||
|
const result = await handler(event)
|
||||||
|
|
||||||
|
expect(result.goals.hasProfileTags).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1.5: hasEngagedEcology true when visited AND has tag with engagement state
|
||||||
|
it('hasEngagedEcology is true when page visited and has engaged topic', async () => {
|
||||||
|
requireAuth.mockResolvedValue({
|
||||||
|
_id: 'member-1',
|
||||||
|
craftTags: [],
|
||||||
|
communityEcology: {
|
||||||
|
topics: [{ tagSlug: 'governance', state: 'help' }],
|
||||||
|
},
|
||||||
|
onboarding: {
|
||||||
|
completedAt: null,
|
||||||
|
eventPageVisited: false,
|
||||||
|
ecologyPageVisited: true,
|
||||||
|
wikiClicked: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
|
||||||
|
const result = await handler(event)
|
||||||
|
|
||||||
|
expect(result.goals.hasEngagedEcology).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1.6: hasEngagedEcology false when visited but no engagement state
|
||||||
|
it('hasEngagedEcology is false when page visited but no topics have engagement state', async () => {
|
||||||
|
requireAuth.mockResolvedValue({
|
||||||
|
_id: 'member-1',
|
||||||
|
craftTags: [],
|
||||||
|
communityEcology: { topics: [] },
|
||||||
|
onboarding: {
|
||||||
|
completedAt: null,
|
||||||
|
eventPageVisited: false,
|
||||||
|
ecologyPageVisited: true,
|
||||||
|
wikiClicked: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
|
||||||
|
const result = await handler(event)
|
||||||
|
|
||||||
|
expect(result.goals.hasEngagedEcology).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1.9: Maps stored booleans directly
|
||||||
|
it('maps stored onboarding booleans directly for visit and wiki goals', async () => {
|
||||||
|
requireAuth.mockResolvedValue({
|
||||||
|
_id: 'member-1',
|
||||||
|
craftTags: [],
|
||||||
|
communityEcology: { topics: [] },
|
||||||
|
onboarding: {
|
||||||
|
completedAt: null,
|
||||||
|
eventPageVisited: true,
|
||||||
|
ecologyPageVisited: false,
|
||||||
|
wikiClicked: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
|
||||||
|
const result = await handler(event)
|
||||||
|
|
||||||
|
expect(result.goals.hasVisitedEvent).toBe(true)
|
||||||
|
expect(result.goals.hasEngagedEcology).toBe(false)
|
||||||
|
expect(result.goals.hasClickedWiki).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1.11: Requires auth (401)
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
requireAuth.mockRejectedValue(
|
||||||
|
createError({ statusCode: 401, statusMessage: 'Authentication required' })
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/onboarding/status' })
|
||||||
|
|
||||||
|
await expect(handler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
})
|
||||||
|
})
|
||||||
214
tests/server/api/onboarding-track.test.js
Normal file
214
tests/server/api/onboarding-track.test.js
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
|
requireAuth: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/mongoose.js', () => ({
|
||||||
|
connectDB: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
|
default: { findByIdAndUpdate: vi.fn(), findOneAndUpdate: vi.fn() }
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/validateBody.js', () => ({
|
||||||
|
validateBody: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/schemas.js', () => ({
|
||||||
|
onboardingTrackSchema: {}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/activityLog.js', () => ({
|
||||||
|
logActivity: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { requireAuth } from '../../../server/utils/auth.js'
|
||||||
|
import { validateBody } from '../../../server/utils/validateBody.js'
|
||||||
|
import Member from '../../../server/models/member.js'
|
||||||
|
import { logActivity } from '../../../server/utils/activityLog.js'
|
||||||
|
import handler from '../../../server/api/onboarding/track.post.js'
|
||||||
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
|
describe('POST /api/onboarding/track', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAuth.mockResolvedValue({
|
||||||
|
_id: 'member-1',
|
||||||
|
onboarding: {
|
||||||
|
completedAt: null,
|
||||||
|
eventPageVisited: false,
|
||||||
|
ecologyPageVisited: false,
|
||||||
|
wikiClicked: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Member.findByIdAndUpdate.mockResolvedValue({})
|
||||||
|
Member.findOneAndUpdate.mockResolvedValue(null) // no graduation by default
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2.1: Sets eventPageVisited to true
|
||||||
|
it('sets eventPageVisited to true and returns success', async () => {
|
||||||
|
validateBody.mockResolvedValue({ goal: 'eventPageVisited' })
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/onboarding/track',
|
||||||
|
body: { goal: 'eventPageVisited' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await handler(event)
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, graduated: false })
|
||||||
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith('member-1', {
|
||||||
|
$set: { 'onboarding.eventPageVisited': true },
|
||||||
|
})
|
||||||
|
expect(logActivity).toHaveBeenCalledWith(
|
||||||
|
'member-1',
|
||||||
|
'member_onboarding_goal_completed',
|
||||||
|
{ goal: 'eventPageVisited' },
|
||||||
|
{ visibility: 'admin' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2.4: Rejects invalid goal name (400)
|
||||||
|
it('rejects an invalid goal name with 400', async () => {
|
||||||
|
validateBody.mockRejectedValue(
|
||||||
|
createError({ statusCode: 400, statusMessage: 'Validation failed' })
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/onboarding/track',
|
||||||
|
body: { goal: 'invalidGoal' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(handler(event)).rejects.toMatchObject({ statusCode: 400 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2.6: Rejects computed goal names like 'hasProfileTags' (400)
|
||||||
|
it('rejects computed goal names like hasProfileTags with 400', async () => {
|
||||||
|
validateBody.mockRejectedValue(
|
||||||
|
createError({ statusCode: 400, statusMessage: 'Validation failed' })
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/onboarding/track',
|
||||||
|
body: { goal: 'hasProfileTags' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(handler(event)).rejects.toMatchObject({ statusCode: 400 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2.7: Idempotent — short-circuits when already true
|
||||||
|
it('short-circuits without DB write when goal is already true', async () => {
|
||||||
|
requireAuth.mockResolvedValue({
|
||||||
|
_id: 'member-1',
|
||||||
|
onboarding: {
|
||||||
|
completedAt: null,
|
||||||
|
eventPageVisited: true,
|
||||||
|
ecologyPageVisited: false,
|
||||||
|
wikiClicked: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
validateBody.mockResolvedValue({ goal: 'eventPageVisited' })
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/onboarding/track',
|
||||||
|
body: { goal: 'eventPageVisited' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await handler(event)
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
||||||
|
expect(Member.findOneAndUpdate).not.toHaveBeenCalled()
|
||||||
|
expect(logActivity).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2.10: Triggers graduation when all four goals met
|
||||||
|
it('triggers graduation when all goals are met', async () => {
|
||||||
|
validateBody.mockResolvedValue({ goal: 'wikiClicked' })
|
||||||
|
|
||||||
|
// findOneAndUpdate returns a doc when graduation conditions are met
|
||||||
|
const graduatedMember = {
|
||||||
|
_id: 'member-1',
|
||||||
|
onboarding: {
|
||||||
|
completedAt: new Date(),
|
||||||
|
eventPageVisited: true,
|
||||||
|
ecologyPageVisited: true,
|
||||||
|
wikiClicked: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Member.findOneAndUpdate.mockResolvedValue(graduatedMember)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/onboarding/track',
|
||||||
|
body: { goal: 'wikiClicked' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await handler(event)
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, graduated: true })
|
||||||
|
|
||||||
|
// Should have logged both goal completion and graduation
|
||||||
|
expect(logActivity).toHaveBeenCalledTimes(2)
|
||||||
|
expect(logActivity).toHaveBeenCalledWith(
|
||||||
|
'member-1',
|
||||||
|
'member_onboarding_goal_completed',
|
||||||
|
{ goal: 'wikiClicked' },
|
||||||
|
{ visibility: 'admin' }
|
||||||
|
)
|
||||||
|
expect(logActivity).toHaveBeenCalledWith(
|
||||||
|
'member-1',
|
||||||
|
'member_onboarding_completed',
|
||||||
|
{},
|
||||||
|
{ visibility: 'admin' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2.12: Graduation no-ops if tags were removed concurrently
|
||||||
|
it('does not graduate if tags were removed concurrently', async () => {
|
||||||
|
validateBody.mockResolvedValue({ goal: 'wikiClicked' })
|
||||||
|
|
||||||
|
// findOneAndUpdate returns null when conditions aren't met (tags removed)
|
||||||
|
Member.findOneAndUpdate.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/onboarding/track',
|
||||||
|
body: { goal: 'wikiClicked' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await handler(event)
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, graduated: false })
|
||||||
|
|
||||||
|
// Should have logged only goal completion, not graduation
|
||||||
|
expect(logActivity).toHaveBeenCalledTimes(1)
|
||||||
|
expect(logActivity).toHaveBeenCalledWith(
|
||||||
|
'member-1',
|
||||||
|
'member_onboarding_goal_completed',
|
||||||
|
{ goal: 'wikiClicked' },
|
||||||
|
{ visibility: 'admin' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2.15: Requires auth (401)
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
requireAuth.mockRejectedValue(
|
||||||
|
createError({ statusCode: 401, statusMessage: 'Authentication required' })
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/onboarding/track',
|
||||||
|
body: { goal: 'eventPageVisited' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(handler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue