diff --git a/server/api/onboarding/status.get.js b/server/api/onboarding/status.get.js new file mode 100644 index 0000000..e33fac9 --- /dev/null +++ b/server/api/onboarding/status.get.js @@ -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, + } +}) diff --git a/server/api/onboarding/track.post.js b/server/api/onboarding/track.post.js new file mode 100644 index 0000000..8ecb20a --- /dev/null +++ b/server/api/onboarding/track.post.js @@ -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 } +}) diff --git a/server/models/member.js b/server/models/member.js index 6868b31..34624d7 100644 --- a/server/models/member.js +++ b/server/models/member.js @@ -152,6 +152,13 @@ const memberSchema = new mongoose.Schema({ 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 }, lastLogin: Date, }); diff --git a/server/utils/schemas.js b/server/utils/schemas.js index a8b9c3d..7add495 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -364,6 +364,12 @@ export const inviteAcceptSchema = z.object({ token: z.string().min(1) }) +// --- Onboarding schemas --- + +export const onboardingTrackSchema = z.object({ + goal: z.enum(['eventPageVisited', 'ecologyPageVisited', 'wikiClicked']) +}) + // --- Tag schemas --- export const tagSuggestionSchema = z.object({ diff --git a/tests/server/api/onboarding-status.test.js b/tests/server/api/onboarding-status.test.js new file mode 100644 index 0000000..b671dca --- /dev/null +++ b/tests/server/api/onboarding-status.test.js @@ -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 }) + }) +}) diff --git a/tests/server/api/onboarding-track.test.js b/tests/server/api/onboarding-track.test.js new file mode 100644 index 0000000..754ff23 --- /dev/null +++ b/tests/server/api/onboarding-track.test.js @@ -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 }) + }) +})