From 9fe8d9980854daf90ed65e4762d89262a34fe5ca Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:28:51 +0100 Subject: [PATCH 001/285] feat(onboarding): add Member onboarding subdocument, Event tags, and WikiArticle model --- server/models/event.js | 1 + server/models/member.js | 7 +++++++ server/models/wikiArticle.js | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 server/models/wikiArticle.js diff --git a/server/models/event.js b/server/models/event.js index 16245bc..1f723e2 100644 --- a/server/models/event.js +++ b/server/models/event.js @@ -182,6 +182,7 @@ const eventSchema = new mongoose.Schema({ refundAmount: Number, }, ], + tags: [{ type: String }], createdBy: { type: String, required: true }, createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now }, 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/models/wikiArticle.js b/server/models/wikiArticle.js new file mode 100644 index 0000000..16d4b51 --- /dev/null +++ b/server/models/wikiArticle.js @@ -0,0 +1,20 @@ +import mongoose from "mongoose"; + +const wikiArticleSchema = new mongoose.Schema( + { + outlineId: { type: String, unique: true, required: true }, + title: { type: String, required: true }, + collection: String, + url: { type: String, required: true }, + summary: String, + tags: [{ type: String }], + publishedAt: Date, + permission: String, + lastSyncedAt: Date, + outlineUpdatedAt: Date, + }, + { timestamps: true } +); + +export default mongoose.models.WikiArticle || + mongoose.model("WikiArticle", wikiArticleSchema); From 3144cbe213b99f4ece6fc850ba85b9f00e1144e6 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:28:57 +0100 Subject: [PATCH 002/285] feat(onboarding): redirect /welcome to /member/dashboard --- app/pages/welcome.vue | 279 +------------------------------ server/api/invite/accept.post.js | 2 +- 2 files changed, 2 insertions(+), 279 deletions(-) diff --git a/app/pages/welcome.vue b/app/pages/welcome.vue index 50d1a92..5022c3d 100644 --- a/app/pages/welcome.vue +++ b/app/pages/welcome.vue @@ -1,280 +1,3 @@ - - diff --git a/server/api/invite/accept.post.js b/server/api/invite/accept.post.js index 65541cd..278c1c3 100644 --- a/server/api/invite/accept.post.js +++ b/server/api/invite/accept.post.js @@ -83,7 +83,7 @@ export default defineEventHandler(async (event) => { return { success: true, requiresPayment: false, - redirectUrl: '/welcome', + redirectUrl: '/member/dashboard', member: { id: member._id, email: member.email, From 340b739bf2460db1974e5c64553add43cf361136 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:31:30 +0100 Subject: [PATCH 003/285] feat(onboarding): show onboarding progress on admin member detail --- app/pages/admin/members/[id].vue | 65 ++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/app/pages/admin/members/[id].vue b/app/pages/admin/members/[id].vue index ff3777b..111b949 100644 --- a/app/pages/admin/members/[id].vue +++ b/app/pages/admin/members/[id].vue @@ -127,6 +127,49 @@ + +
+ +
+
+
Profile Tags
+
+ {{ hasProfileTags ? '✓ Complete' : '— Incomplete' }} +
+
+
+
Event Page Visited
+
+ {{ member.onboarding?.eventPageVisited ? '✓ Complete' : '— Incomplete' }} +
+
+
+
Ecology Engaged
+
+ {{ hasEcologyEngaged ? '✓ Complete' : '— Incomplete' }} +
+
+
+
Wiki Clicked
+
+ {{ member.onboarding?.wikiClicked ? '✓ Complete' : '— Incomplete' }} +
+
+
+
Completed
+
+ {{ member.onboarding?.completedAt ? formatDate(member.onboarding.completedAt) : 'In progress' }} +
+
+
+
Slack status
+
+ {{ member.slackInviteStatus || 'none' }} +
+
+
+
+
@@ -304,6 +347,28 @@ function statusClass(status) { return "status-dim"; } +// Onboarding computed states +const hasProfileTags = computed(() => { + const m = member.value + if (!m) return false + return m.craftTags?.length > 0 && m.communityEcology?.topics?.length > 0 +}) + +const hasEcologyEngaged = computed(() => { + const m = member.value + if (!m) return false + return m.onboarding?.ecologyPageVisited && m.communityEcology?.topics?.some( + t => ['help', 'interested', 'seeking'].includes(t.state) + ) +}) + +const slackStatusClass = computed(() => { + const status = member.value?.slackInviteStatus + if (status === 'joined') return 'status-ok' + if (status === 'invited') return 'status-dim' + return 'status-dim' +}) + // Activity log const activityEntries = ref([]) const activityLoading = ref(false) From 56376d19953e26ee782ce0399ae560f09f411263 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:31:57 +0100 Subject: [PATCH 004/285] feat(onboarding): add onboarding status and track API routes with tests --- server/api/onboarding/status.get.js | 28 +++ server/api/onboarding/track.post.js | 51 +++++ server/models/member.js | 7 + server/utils/schemas.js | 6 + tests/server/api/onboarding-status.test.js | 164 ++++++++++++++++ tests/server/api/onboarding-track.test.js | 214 +++++++++++++++++++++ 6 files changed, 470 insertions(+) create mode 100644 server/api/onboarding/status.get.js create mode 100644 server/api/onboarding/track.post.js create mode 100644 tests/server/api/onboarding-status.test.js create mode 100644 tests/server/api/onboarding-track.test.js 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 }) + }) +}) From fcbad24f3eab14f87b09d9593b840c8fb7b614ef Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:32:11 +0100 Subject: [PATCH 005/285] feat(events): add tag-based event recommendations API --- server/api/events/recommended.get.js | 34 ++++ server/models/event.js | 1 + tests/server/api/events-recommended.test.js | 198 ++++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 server/api/events/recommended.get.js create mode 100644 tests/server/api/events-recommended.test.js diff --git a/server/api/events/recommended.get.js b/server/api/events/recommended.get.js new file mode 100644 index 0000000..9766582 --- /dev/null +++ b/server/api/events/recommended.get.js @@ -0,0 +1,34 @@ +import Event from '../../models/event.js' +import { connectDB } from '../../utils/mongoose.js' +import { requireAuth } from '../../utils/auth.js' + +export default defineEventHandler(async (event) => { + const member = await requireAuth(event) + + // Combine craft tags and cooperative ecology tags + const craftTags = member.craftTags || [] + const ecologyTags = (member.communityEcology?.topics || []).map(t => t.tagSlug) + const memberTags = [...new Set([...craftTags, ...ecologyTags].filter(Boolean))] + + if (!memberTags.length) { + return [] + } + + await connectDB() + + const query = getQuery(event) + const limit = Math.min(Math.max(parseInt(query.limit) || 10, 1), 25) + + const events = await Event.find({ + tags: { $in: memberTags }, + startDate: { $gt: new Date() }, + isCancelled: { $ne: true }, + isVisible: true + }) + .sort({ startDate: 1 }) + .limit(limit) + .select('title slug startDate endDate tagline tags eventType isOnline') + .lean() + + return events +}) diff --git a/server/models/event.js b/server/models/event.js index 16245bc..f4af533 100644 --- a/server/models/event.js +++ b/server/models/event.js @@ -182,6 +182,7 @@ const eventSchema = new mongoose.Schema({ refundAmount: Number, }, ], + tags: [String], createdBy: { type: String, required: true }, createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now }, diff --git a/tests/server/api/events-recommended.test.js b/tests/server/api/events-recommended.test.js new file mode 100644 index 0000000..631e501 --- /dev/null +++ b/tests/server/api/events-recommended.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockFind, mockSort, mockLimit, mockSelect, mockLean } = vi.hoisted(() => ({ + mockFind: vi.fn(), + mockSort: vi.fn(), + mockLimit: vi.fn(), + mockSelect: vi.fn(), + mockLean: vi.fn() +})) + +vi.mock('../../../server/models/event.js', () => ({ + default: { find: mockFind } +})) + +vi.mock('../../../server/utils/mongoose.js', () => ({ + connectDB: vi.fn() +})) + +vi.mock('../../../server/utils/auth.js', () => ({ + requireAuth: vi.fn() +})) + +import { requireAuth } from '../../../server/utils/auth.js' +import handler from '../../../server/api/events/recommended.get.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +// Wire up the chained query builder +function setupChain(result = []) { + mockLean.mockResolvedValue(result) + mockSelect.mockReturnValue({ lean: mockLean }) + mockLimit.mockReturnValue({ select: mockSelect }) + mockSort.mockReturnValue({ limit: mockLimit }) + mockFind.mockReturnValue({ sort: mockSort }) +} + +const futureDate = new Date(Date.now() + 86400000) + +function makeMember(overrides = {}) { + return { + _id: 'member-1', + craftTags: [], + communityEcology: { topics: [] }, + ...overrides + } +} + +function makeEvent(overrides = {}) { + return { + _id: 'event-1', + title: 'Test Event', + slug: '2026-05-01-test-event', + startDate: futureDate, + endDate: futureDate, + tagline: 'A test event', + tags: ['game-design'], + eventType: 'workshop', + isOnline: true, + ...overrides + } +} + +describe('GET /api/events/recommended', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns events matching member craft tags', async () => { + const member = makeMember({ craftTags: ['game-design', 'narrative'] }) + requireAuth.mockResolvedValue(member) + + const events = [makeEvent({ tags: ['game-design'] })] + setupChain(events) + + const event = createMockEvent({ method: 'GET', path: '/api/events/recommended' }) + const result = await handler(event) + + expect(result).toEqual(events) + expect(mockFind).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { $in: expect.arrayContaining(['game-design', 'narrative']) } + }) + ) + }) + + it('returns events matching cooperative tags from communityEcology.topics', async () => { + const member = makeMember({ + communityEcology: { + topics: [ + { tagSlug: 'revenue-sharing', state: 'interested' }, + { tagSlug: 'co-op-governance', state: 'help' } + ] + } + }) + requireAuth.mockResolvedValue(member) + + const events = [makeEvent({ tags: ['revenue-sharing'] })] + setupChain(events) + + const event = createMockEvent({ method: 'GET', path: '/api/events/recommended' }) + const result = await handler(event) + + expect(result).toEqual(events) + expect(mockFind).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { $in: expect.arrayContaining(['revenue-sharing', 'co-op-governance']) } + }) + ) + }) + + it('returns empty array when no tag overlap', async () => { + const member = makeMember({ craftTags: ['audio'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/events/recommended' }) + const result = await handler(event) + + expect(result).toEqual([]) + }) + + it('excludes past events via startDate $gt filter', async () => { + const member = makeMember({ craftTags: ['game-design'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/events/recommended' }) + await handler(event) + + const filter = mockFind.mock.calls[0][0] + expect(filter.startDate).toEqual({ $gt: expect.any(Date) }) + // The filter date should be approximately now (within 5 seconds) + const filterDate = filter.startDate.$gt + expect(filterDate.getTime()).toBeGreaterThan(Date.now() - 5000) + expect(filterDate.getTime()).toBeLessThanOrEqual(Date.now()) + }) + + it('uses default limit of 10', async () => { + const member = makeMember({ craftTags: ['game-design'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/events/recommended' }) + await handler(event) + + expect(mockLimit).toHaveBeenCalledWith(10) + }) + + it('accepts limit query param', async () => { + const member = makeMember({ craftTags: ['game-design'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/events/recommended?limit=5' }) + await handler(event) + + expect(mockLimit).toHaveBeenCalledWith(5) + }) + + it('caps limit at 25', async () => { + const member = makeMember({ craftTags: ['game-design'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/events/recommended?limit=100' }) + await handler(event) + + expect(mockLimit).toHaveBeenCalledWith(25) + }) + + it('returns empty array when member has no tags', async () => { + const member = makeMember() + requireAuth.mockResolvedValue(member) + + const event = createMockEvent({ method: 'GET', path: '/api/events/recommended' }) + const result = await handler(event) + + expect(result).toEqual([]) + // Should not query the database at all + expect(mockFind).not.toHaveBeenCalled() + }) + + it('requires auth (401)', async () => { + requireAuth.mockRejectedValue( + createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + ) + + const event = createMockEvent({ method: 'GET', path: '/api/events/recommended' }) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 401 + }) + }) +}) From 2166ee32ca5acf1d87349ec49488840fa79acce3 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:32:32 +0100 Subject: [PATCH 006/285] feat(events): add tag validation to admin event create/edit routes --- server/api/admin/events.post.js | 14 +++ server/api/admin/events/[id].put.js | 14 +++ tests/server/api/event-tags.test.js | 157 ++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 tests/server/api/event-tags.test.js diff --git a/server/api/admin/events.post.js b/server/api/admin/events.post.js index 875499b..08a3543 100644 --- a/server/api/admin/events.post.js +++ b/server/api/admin/events.post.js @@ -1,4 +1,5 @@ import Event from "../../models/event.js"; +import Tag from "../../models/tag.js"; import { connectDB } from "../../utils/mongoose.js"; import { requireAdmin } from "../../utils/auth.js"; import { validateBody } from "../../utils/validateBody.js"; @@ -12,6 +13,19 @@ export default defineEventHandler(async (event) => { await connectDB(); + // Validate tag slugs against Tag collection + if (body.tags && body.tags.length > 0) { + const foundTags = await Tag.find({ slug: { $in: body.tags } }); + const foundSlugs = new Set(foundTags.map((t) => t.slug)); + const invalid = body.tags.filter((s) => !foundSlugs.has(s)); + if (invalid.length > 0) { + throw createError({ + statusCode: 400, + statusMessage: `Unknown tag slugs: ${invalid.join(", ")}`, + }); + } + } + const eventData = { ...body, createdBy: admin.email, diff --git a/server/api/admin/events/[id].put.js b/server/api/admin/events/[id].put.js index 3928344..60809a7 100644 --- a/server/api/admin/events/[id].put.js +++ b/server/api/admin/events/[id].put.js @@ -1,4 +1,5 @@ import Event from '../../../models/event.js' +import Tag from '../../../models/tag.js' import { connectDB } from '../../../utils/mongoose.js' import { requireAdmin } from '../../../utils/auth.js' @@ -11,6 +12,19 @@ export default defineEventHandler(async (event) => { await connectDB() + // Validate tag slugs against Tag collection + if (body.tags && body.tags.length > 0) { + const foundTags = await Tag.find({ slug: { $in: body.tags } }) + const foundSlugs = new Set(foundTags.map(t => t.slug)) + const invalid = body.tags.filter(s => !foundSlugs.has(s)) + if (invalid.length > 0) { + throw createError({ + statusCode: 400, + statusMessage: `Unknown tag slugs: ${invalid.join(', ')}` + }) + } + } + const updateData = { ...body, startDate: new Date(body.startDate), diff --git a/tests/server/api/event-tags.test.js b/tests/server/api/event-tags.test.js new file mode 100644 index 0000000..37a0eb9 --- /dev/null +++ b/tests/server/api/event-tags.test.js @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// --- Mocks (must be before imports) --- + +const mockEventSave = vi.fn() +function MockEvent(data) { + Object.assign(this, data) + this._id = 'evt-123' + this.slug = '2026-04-01-test-event' + this.save = mockEventSave.mockResolvedValue(this) +} +MockEvent.findByIdAndUpdate = vi.fn() + +vi.mock('../../../server/models/event.js', () => ({ default: MockEvent })) + +vi.mock('../../../server/models/tag.js', () => ({ + default: { find: vi.fn() } +})) + +vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) +vi.mock('../../../server/utils/auth.js', () => ({ + requireAdmin: vi.fn().mockResolvedValue({ email: 'admin@example.com', role: 'admin' }) +})) +vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() })) +vi.mock('../../../server/utils/schemas.js', () => ({ + adminEventCreateSchema: {}, + adminEventUpdateSchema: {} +})) + +import Tag from '../../../server/models/tag.js' +import { validateBody } from '../../../server/utils/validateBody.js' +import createHandler from '../../../server/api/admin/events.post.js' +import updateHandler from '../../../server/api/admin/events/[id].put.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +// Stub the global adminEventUpdateSchema used by the update route (auto-imported by Nitro) +vi.stubGlobal('adminEventUpdateSchema', {}) + +// --- Helpers --- + +const validEventBody = { + title: 'Test Event', + description: 'A test event', + startDate: '2026-04-01T10:00:00Z', + endDate: '2026-04-01T12:00:00Z', + location: 'https://meet.example.com' +} + +// --- Tests --- + +describe('Event tag validation', () => { + beforeEach(() => { + vi.clearAllMocks() + // Override the global validateBody stub (from setup.js) to use our mock + globalThis.validateBody = validateBody + }) + + describe('create route (events.post)', () => { + it('accepts valid tags', async () => { + validateBody.mockResolvedValue({ + ...validEventBody, + tags: ['game-design', 'cooperatives'] + }) + Tag.find.mockResolvedValue([ + { slug: 'game-design' }, + { slug: 'cooperatives' } + ]) + + const event = createMockEvent({ method: 'POST', path: '/api/admin/events' }) + const result = await createHandler(event) + + expect(Tag.find).toHaveBeenCalledWith({ slug: { $in: ['game-design', 'cooperatives'] } }) + expect(result.tags).toEqual(['game-design', 'cooperatives']) + }) + + it('rejects unknown tag slugs with 400', async () => { + validateBody.mockResolvedValue({ + ...validEventBody, + tags: ['game-design', 'nonexistent-tag'] + }) + Tag.find.mockResolvedValue([ + { slug: 'game-design' } + ]) + + const event = createMockEvent({ method: 'POST', path: '/api/admin/events' }) + + await expect(createHandler(event)).rejects.toMatchObject({ + statusCode: 400, + statusMessage: 'Unknown tag slugs: nonexistent-tag' + }) + }) + + it('skips validation when tags are omitted', async () => { + validateBody.mockResolvedValue({ ...validEventBody }) + + const event = createMockEvent({ method: 'POST', path: '/api/admin/events' }) + await createHandler(event) + + expect(Tag.find).not.toHaveBeenCalled() + }) + + it('skips validation when tags array is empty', async () => { + validateBody.mockResolvedValue({ ...validEventBody, tags: [] }) + + const event = createMockEvent({ method: 'POST', path: '/api/admin/events' }) + await createHandler(event) + + expect(Tag.find).not.toHaveBeenCalled() + }) + }) + + describe('update route (events/[id].put)', () => { + beforeEach(() => { + MockEvent.findByIdAndUpdate.mockResolvedValue({ + _id: 'evt-123', + ...validEventBody, + tags: ['game-design'] + }) + }) + + it('accepts valid tags', async () => { + validateBody.mockResolvedValue({ + ...validEventBody, + tags: ['game-design'] + }) + Tag.find.mockResolvedValue([ + { slug: 'game-design' } + ]) + + const event = createMockEvent({ method: 'PUT', path: '/api/admin/events/evt-123' }) + event.context = { ...event.context, params: { id: 'evt-123' } } + + const result = await updateHandler(event) + + expect(Tag.find).toHaveBeenCalledWith({ slug: { $in: ['game-design'] } }) + expect(result.tags).toEqual(['game-design']) + }) + + it('rejects unknown tag slugs with 400', async () => { + validateBody.mockResolvedValue({ + ...validEventBody, + tags: ['valid-tag', 'bogus-tag', 'also-bogus'] + }) + Tag.find.mockResolvedValue([ + { slug: 'valid-tag' } + ]) + + const event = createMockEvent({ method: 'PUT', path: '/api/admin/events/evt-123' }) + event.context = { ...event.context, params: { id: 'evt-123' } } + + await expect(updateHandler(event)).rejects.toMatchObject({ + statusCode: 400, + statusMessage: 'Unknown tag slugs: bogus-tag, also-bogus' + }) + }) + }) +}) From 327f504df91189d1f8e71d8d5284c1e149f9e695 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:32:48 +0100 Subject: [PATCH 007/285] feat(slack): add background job to detect Slack workspace joins --- server/plugins/check-slack-joins.js | 29 ++++ server/utils/checkSlackJoins.js | 59 ++++++++ tests/server/tasks/check-slack-joins.test.js | 151 +++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 server/plugins/check-slack-joins.js create mode 100644 server/utils/checkSlackJoins.js create mode 100644 tests/server/tasks/check-slack-joins.test.js diff --git a/server/plugins/check-slack-joins.js b/server/plugins/check-slack-joins.js new file mode 100644 index 0000000..9f53ff9 --- /dev/null +++ b/server/plugins/check-slack-joins.js @@ -0,0 +1,29 @@ +// server/plugins/check-slack-joins.js +import { checkSlackJoins } from '../utils/checkSlackJoins.js' + +const INTERVAL_MS = 3600000 // 1 hour + +export default defineNitroPlugin(() => { + // Don't run in test environment + if (process.env.NODE_ENV === 'test') return + + const config = useRuntimeConfig() + const token = config.slackBotToken + + if (!token) { + console.warn('[check-slack-joins] No Slack bot token configured, skipping background job') + return + } + + async function run() { + try { + await checkSlackJoins(token) + } catch (err) { + console.error('[check-slack-joins] Unhandled error:', err.message || err) + } + } + + // Run immediately on server start, then every hour + run() + setInterval(run, INTERVAL_MS) +}) diff --git a/server/utils/checkSlackJoins.js b/server/utils/checkSlackJoins.js new file mode 100644 index 0000000..64d4604 --- /dev/null +++ b/server/utils/checkSlackJoins.js @@ -0,0 +1,59 @@ +// server/utils/checkSlackJoins.js +import Member from '../models/member.js' +import { connectDB } from './mongoose.js' +import { WebClient } from '@slack/web-api' + +const BATCH_SIZE = 15 +const BATCH_DELAY_MS = 1000 + +/** + * Check members with pending Slack invites to see if they've joined the workspace. + * Processes in batches of 15 with 1-second delays (Slack Tier 2 rate limit). + */ +export async function checkSlackJoins(slackBotToken) { + await connectDB() + + const client = new WebClient(slackBotToken) + + const members = await Member.find({ + slackInviteStatus: { $in: ['sent', 'accepted'] } + }).select('_id email slackInviteStatus') + + if (members.length === 0) return { checked: 0, joined: 0 } + + let joined = 0 + + for (let i = 0; i < members.length; i += BATCH_SIZE) { + const batch = members.slice(i, i + BATCH_SIZE) + + for (const member of batch) { + try { + const response = await client.users.lookupByEmail({ email: member.email }) + const userId = response.user?.id + + if (userId) { + await Member.findByIdAndUpdate(member._id, { + slackInviteStatus: 'joined', + slackUserId: userId + }) + joined++ + console.log(`[check-slack-joins] ${member.email} joined Slack (${userId})`) + } + } catch (err) { + // users_not_found is expected for members who haven't joined yet + if (err.data?.error === 'users_not_found') continue + + console.error(`[check-slack-joins] Error checking ${member.email}:`, err.message || err) + // Continue processing remaining members + } + } + + // Delay between batches (skip after last batch) + if (i + BATCH_SIZE < members.length) { + await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS)) + } + } + + console.log(`[check-slack-joins] Done: ${joined}/${members.length} members joined`) + return { checked: members.length, joined } +} diff --git a/tests/server/tasks/check-slack-joins.test.js b/tests/server/tasks/check-slack-joins.test.js new file mode 100644 index 0000000..1e12814 --- /dev/null +++ b/tests/server/tasks/check-slack-joins.test.js @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Hoist mock functions so vi.mock factories can reference them +const { mockFind, mockFindByIdAndUpdate, mockLookupByEmail } = vi.hoisted(() => ({ + mockFind: vi.fn(), + mockFindByIdAndUpdate: vi.fn(), + mockLookupByEmail: vi.fn() +})) + +// Mock mongoose connection +vi.mock('../../../server/utils/mongoose.js', () => ({ + connectDB: vi.fn() +})) + +// Mock Member model +vi.mock('../../../server/models/member.js', () => ({ + default: { + find: mockFind, + findByIdAndUpdate: mockFindByIdAndUpdate + } +})) + +// Mock @slack/web-api — use function expression so `new WebClient()` works +vi.mock('@slack/web-api', () => ({ + WebClient: vi.fn().mockImplementation(function () { + this.users = { lookupByEmail: mockLookupByEmail } + }) +})) + +import { checkSlackJoins } from '../../../server/utils/checkSlackJoins.js' + +describe('checkSlackJoins', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: find returns empty with select chain + mockFind.mockReturnValue({ select: vi.fn().mockResolvedValue([]) }) + }) + + it('8.1: detects joined member — updates status and stores slackUserId', async () => { + mockFind.mockReturnValue({ + select: vi.fn().mockResolvedValue([ + { _id: 'm1', email: 'alice@example.com', slackInviteStatus: 'sent' } + ]) + }) + mockLookupByEmail.mockResolvedValue({ user: { id: 'U123ABC' } }) + mockFindByIdAndUpdate.mockResolvedValue({}) + + const result = await checkSlackJoins('xoxb-test-token') + + expect(mockLookupByEmail).toHaveBeenCalledWith({ email: 'alice@example.com' }) + expect(mockFindByIdAndUpdate).toHaveBeenCalledWith('m1', { + slackInviteStatus: 'joined', + slackUserId: 'U123ABC' + }) + expect(result).toEqual({ checked: 1, joined: 1 }) + }) + + it('8.2: no change when member not found in Slack', async () => { + mockFind.mockReturnValue({ + select: vi.fn().mockResolvedValue([ + { _id: 'm2', email: 'bob@example.com', slackInviteStatus: 'sent' } + ]) + }) + // Slack throws users_not_found for unknown emails + mockLookupByEmail.mockRejectedValue({ data: { error: 'users_not_found' } }) + + const result = await checkSlackJoins('xoxb-test-token') + + expect(mockFindByIdAndUpdate).not.toHaveBeenCalled() + expect(result).toEqual({ checked: 1, joined: 0 }) + }) + + it('8.3: skips already-joined members (not included in query)', async () => { + mockFind.mockReturnValue({ + select: vi.fn().mockResolvedValue([]) + }) + + const result = await checkSlackJoins('xoxb-test-token') + + // Verify the query only looks for 'sent' and 'accepted' + expect(mockFind).toHaveBeenCalledWith({ + slackInviteStatus: { $in: ['sent', 'accepted'] } + }) + expect(result).toEqual({ checked: 0, joined: 0 }) + expect(mockLookupByEmail).not.toHaveBeenCalled() + }) + + it('8.4: skips members with null slackInviteStatus (not included in query)', async () => { + mockFind.mockReturnValue({ + select: vi.fn().mockResolvedValue([]) + }) + + await checkSlackJoins('xoxb-test-token') + + // Query uses $in with only 'sent' and 'accepted' — null is excluded + const queryArg = mockFind.mock.calls[0][0] + expect(queryArg.slackInviteStatus.$in).toEqual(['sent', 'accepted']) + expect(queryArg.slackInviteStatus.$in).not.toContain(null) + }) + + it('8.6: partial failure does not abort batch — remaining members still processed', async () => { + mockFind.mockReturnValue({ + select: vi.fn().mockResolvedValue([ + { _id: 'm1', email: 'alice@example.com', slackInviteStatus: 'sent' }, + { _id: 'm2', email: 'bob@example.com', slackInviteStatus: 'sent' }, + { _id: 'm3', email: 'carol@example.com', slackInviteStatus: 'sent' } + ]) + }) + + // First call: unexpected API error + mockLookupByEmail + .mockRejectedValueOnce(new Error('Slack API timeout')) + // Second call: not found + .mockRejectedValueOnce({ data: { error: 'users_not_found' } }) + // Third call: found + .mockResolvedValueOnce({ user: { id: 'U999' } }) + + mockFindByIdAndUpdate.mockResolvedValue({}) + + const result = await checkSlackJoins('xoxb-test-token') + + // All three were checked + expect(mockLookupByEmail).toHaveBeenCalledTimes(3) + // Only the third member joined + expect(mockFindByIdAndUpdate).toHaveBeenCalledTimes(1) + expect(mockFindByIdAndUpdate).toHaveBeenCalledWith('m3', { + slackInviteStatus: 'joined', + slackUserId: 'U999' + }) + expect(result).toEqual({ checked: 3, joined: 1 }) + }) + + it('8.7: handles accepted status members too', async () => { + mockFind.mockReturnValue({ + select: vi.fn().mockResolvedValue([ + { _id: 'm4', email: 'dave@example.com', slackInviteStatus: 'accepted' } + ]) + }) + mockLookupByEmail.mockResolvedValue({ user: { id: 'UABC' } }) + mockFindByIdAndUpdate.mockResolvedValue({}) + + const result = await checkSlackJoins('xoxb-test-token') + + expect(mockLookupByEmail).toHaveBeenCalledWith({ email: 'dave@example.com' }) + expect(mockFindByIdAndUpdate).toHaveBeenCalledWith('m4', { + slackInviteStatus: 'joined', + slackUserId: 'UABC' + }) + expect(result).toEqual({ checked: 1, joined: 1 }) + }) +}) From 905b5155e278d84df85ef371e8965ca522999653 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:33:06 +0100 Subject: [PATCH 008/285] feat(wiki): add Outline utility and wiki sync API --- .env.example | 5 +- nuxt.config.ts | 1 + server/api/admin/wiki/sync.post.js | 84 ++++++ server/models/wikiArticle.js | 20 ++ server/utils/outline.js | 67 +++++ tests/server/api/admin-auth-guards.test.js | 8 +- tests/server/api/wiki-sync.test.js | 283 +++++++++++++++++++++ 7 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 server/api/admin/wiki/sync.post.js create mode 100644 server/models/wikiArticle.js create mode 100644 server/utils/outline.js create mode 100644 tests/server/api/wiki-sync.test.js diff --git a/.env.example b/.env.example index d59ecce..0754ce5 100644 --- a/.env.example +++ b/.env.example @@ -27,4 +27,7 @@ BASE_URL=http://localhost:3000 # OIDC Provider (for Outline Wiki SSO) OIDC_CLIENT_ID=outline-wiki OIDC_CLIENT_SECRET= -OIDC_COOKIE_SECRET= \ No newline at end of file +OIDC_COOKIE_SECRET= + +# Outline Wiki Integration +OUTLINE_API_KEY= \ No newline at end of file diff --git a/nuxt.config.ts b/nuxt.config.ts index 8d07012..c3843cf 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -87,6 +87,7 @@ export default defineNuxtConfig({ oidcClientId: process.env.OIDC_CLIENT_ID || "outline-wiki", oidcClientSecret: process.env.OIDC_CLIENT_SECRET || "", oidcCookieSecret: process.env.OIDC_COOKIE_SECRET || "", + outlineApiKey: process.env.OUTLINE_API_KEY || "", // Public keys (available on client-side) public: { diff --git a/server/api/admin/wiki/sync.post.js b/server/api/admin/wiki/sync.post.js new file mode 100644 index 0000000..c0b9dd2 --- /dev/null +++ b/server/api/admin/wiki/sync.post.js @@ -0,0 +1,84 @@ +import WikiArticle from '../../../models/wikiArticle.js' +import { connectDB } from '../../../utils/mongoose.js' +import { requireAdmin } from '../../../utils/auth.js' +import { fetchAllDocuments, extractSummary } from '../../../utils/outline.js' + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + + // Fetch ALL documents first — if this fails, no DB changes happen + let documents + try { + documents = await fetchAllDocuments() + } catch (err) { + console.error('[wiki-sync] Outline fetch failed:', err) + throw createError({ + statusCode: err.statusCode || 502, + statusMessage: err.statusMessage || 'Failed to fetch documents from Outline' + }) + } + + await connectDB() + + const fetchedOutlineIds = new Set(documents.map((doc) => doc.id)) + + // Get all existing articles for comparison + const existing = await WikiArticle.find({}, 'outlineId publishedAt') + const existingByOutlineId = new Map( + existing.map((a) => [a.outlineId, a]) + ) + + let created = 0 + let updated = 0 + let deleted = 0 + let errors = 0 + + // Upsert each fetched document + for (const doc of documents) { + try { + const articleData = { + title: doc.title, + collection: doc.collection?.name || null, + url: doc.url, + summary: extractSummary(doc.text), + publishedAt: doc.publishedAt ? new Date(doc.publishedAt) : new Date(doc.createdAt), + permission: doc.permission || null, + lastSyncedAt: new Date(), + outlineUpdatedAt: doc.updatedAt ? new Date(doc.updatedAt) : null + } + + const result = await WikiArticle.findOneAndUpdate( + { outlineId: doc.id }, + { $set: articleData }, + { upsert: true, new: true, rawResult: true } + ) + + if (result.lastErrorObject?.updatedExisting) { + updated++ + } else { + created++ + } + } catch (err) { + console.error(`[wiki-sync] Error upserting doc ${doc.id}:`, err) + errors++ + } + } + + // Soft-delete articles no longer in Outline + for (const [outlineId, article] of existingByOutlineId) { + if (!fetchedOutlineIds.has(outlineId) && article.publishedAt !== null) { + try { + await WikiArticle.findOneAndUpdate( + { outlineId }, + { $set: { publishedAt: null, lastSyncedAt: new Date() } } + ) + deleted++ + } catch (err) { + console.error(`[wiki-sync] Error soft-deleting ${outlineId}:`, err) + errors++ + } + } + } + + return { created, updated, deleted, errors } +}) diff --git a/server/models/wikiArticle.js b/server/models/wikiArticle.js new file mode 100644 index 0000000..275ba7d --- /dev/null +++ b/server/models/wikiArticle.js @@ -0,0 +1,20 @@ +import mongoose from 'mongoose' + +const wikiArticleSchema = new mongoose.Schema( + { + outlineId: { type: String, unique: true, required: true }, + title: { type: String, required: true }, + collection: String, + url: { type: String, required: true }, + summary: String, + tags: [{ type: String }], + publishedAt: Date, + permission: String, + lastSyncedAt: Date, + outlineUpdatedAt: Date + }, + { timestamps: true } +) + +export default mongoose.models.WikiArticle || + mongoose.model('WikiArticle', wikiArticleSchema) diff --git a/server/utils/outline.js b/server/utils/outline.js new file mode 100644 index 0000000..fb85f34 --- /dev/null +++ b/server/utils/outline.js @@ -0,0 +1,67 @@ +const OUTLINE_API_BASE = 'https://wiki.ghostguild.org/api' + +/** + * Strip HTML tags and truncate to 200 characters at a word boundary. + * If the stripped text is <= 200 chars, returns it as-is. + */ +export function extractSummary(text) { + if (!text) return '' + + const stripped = text.replace(/<[^>]*>/g, '').trim() + + if (stripped.length <= 200) return stripped + + const truncated = stripped.slice(0, 200) + const lastSpace = truncated.lastIndexOf(' ') + + // If no space found at all, return the full 200 chars (single long word) + if (lastSpace <= 0) return truncated + + return truncated.slice(0, lastSpace) +} + +/** + * Fetch all documents from Outline wiki, paginating through all pages. + * Throws on any page failure — caller is responsible for abort logic. + */ +export async function fetchAllDocuments() { + const config = useRuntimeConfig() + const apiKey = config.outlineApiKey + + if (!apiKey) { + throw createError({ + statusCode: 500, + statusMessage: 'Outline API key not configured' + }) + } + + const documents = [] + let path = '/documents.list' + + while (path) { + const response = await fetch(`${OUTLINE_API_BASE}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}` + }, + body: JSON.stringify({ limit: 25 }) + }) + + if (!response.ok) { + const errorText = await response.text() + console.error(`[outline] POST ${path} ${response.status} ${errorText}`) + throw createError({ + statusCode: response.status, + statusMessage: 'Outline API error' + }) + } + + const data = await response.json() + documents.push(...(data.data || [])) + + path = data.pagination?.nextPath || null + } + + return documents +} diff --git a/tests/server/api/admin-auth-guards.test.js b/tests/server/api/admin-auth-guards.test.js index 0bcb6e7..1770dec 100644 --- a/tests/server/api/admin-auth-guards.test.js +++ b/tests/server/api/admin-auth-guards.test.js @@ -45,6 +45,9 @@ const adminRoutes = { 'alerts/dismiss.post.js', 'alerts/dismissed.get.js', 'alerts/restore.post.js' + ], + 'admin/wiki/': [ + 'wiki/sync.post.js' ] } @@ -72,7 +75,10 @@ const businessLogicPatterns = [ 'computeAllAlerts(', 'AdminAlertDismissal.findOneAndUpdate', 'AdminAlertDismissal.find', - 'AdminAlertDismissal.deleteMany' + 'AdminAlertDismissal.deleteMany', + 'WikiArticle.find', + 'WikiArticle.findOneAndUpdate', + 'fetchAllDocuments(' ] describe('Admin endpoint auth guards', () => { diff --git a/tests/server/api/wiki-sync.test.js b/tests/server/api/wiki-sync.test.js new file mode 100644 index 0000000..ce19882 --- /dev/null +++ b/tests/server/api/wiki-sync.test.js @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock dependencies before imports +vi.mock('../../../server/models/wikiArticle.js', () => ({ + default: { + find: vi.fn(), + findOneAndUpdate: vi.fn() + } +})) +vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) +vi.mock('../../../server/utils/auth.js', () => ({ + requireAdmin: vi.fn() +})) +vi.mock('../../../server/utils/outline.js', () => ({ + fetchAllDocuments: vi.fn(), + extractSummary: vi.fn((text) => text || '') +})) + +import WikiArticle from '../../../server/models/wikiArticle.js' +import { requireAdmin } from '../../../server/utils/auth.js' +import { fetchAllDocuments, extractSummary } from '../../../server/utils/outline.js' +import syncHandler from '../../../server/api/admin/wiki/sync.post.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +// Also test extractSummary directly (unmocked) +import { extractSummary as realExtractSummary } from '../../../server/utils/outline.js' + +function makeOutlineDoc(overrides = {}) { + return { + id: 'doc-1', + title: 'Test Article', + url: '/doc/test-article', + text: 'Some article content', + publishedAt: '2026-01-15T00:00:00Z', + createdAt: '2026-01-10T00:00:00Z', + updatedAt: '2026-01-15T00:00:00Z', + permission: 'read', + collection: { name: 'General' }, + ...overrides + } +} + +describe('wiki sync endpoint', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' }) + WikiArticle.find.mockResolvedValue([]) + }) + + it('requires admin auth (403)', async () => { + requireAdmin.mockRejectedValue( + createError({ statusCode: 403, statusMessage: 'Admin access required' }) + ) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + await expect(syncHandler(event)).rejects.toMatchObject({ + statusCode: 403, + statusMessage: 'Admin access required' + }) + + expect(requireAdmin).toHaveBeenCalledWith(event) + expect(fetchAllDocuments).not.toHaveBeenCalled() + }) + + it('upserts new articles from Outline', async () => { + const docs = [ + makeOutlineDoc({ id: 'doc-1', title: 'First Article' }), + makeOutlineDoc({ id: 'doc-2', title: 'Second Article' }) + ] + fetchAllDocuments.mockResolvedValue(docs) + WikiArticle.find.mockResolvedValue([]) + WikiArticle.findOneAndUpdate.mockResolvedValue({ + lastErrorObject: { updatedExisting: false } + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + const result = await syncHandler(event) + + expect(result.created).toBe(2) + expect(result.updated).toBe(0) + expect(result.deleted).toBe(0) + expect(result.errors).toBe(0) + expect(WikiArticle.findOneAndUpdate).toHaveBeenCalledTimes(2) + expect(WikiArticle.findOneAndUpdate).toHaveBeenCalledWith( + { outlineId: 'doc-1' }, + expect.objectContaining({ + $set: expect.objectContaining({ title: 'First Article' }) + }), + { upsert: true, new: true, rawResult: true } + ) + }) + + it('updates changed titles and URLs', async () => { + const docs = [ + makeOutlineDoc({ id: 'doc-1', title: 'Updated Title', url: '/doc/new-url' }) + ] + fetchAllDocuments.mockResolvedValue(docs) + WikiArticle.find.mockResolvedValue([ + { outlineId: 'doc-1', publishedAt: new Date('2026-01-15') } + ]) + WikiArticle.findOneAndUpdate.mockResolvedValue({ + lastErrorObject: { updatedExisting: true } + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + const result = await syncHandler(event) + + expect(result.updated).toBe(1) + expect(result.created).toBe(0) + expect(WikiArticle.findOneAndUpdate).toHaveBeenCalledWith( + { outlineId: 'doc-1' }, + expect.objectContaining({ + $set: expect.objectContaining({ + title: 'Updated Title', + url: '/doc/new-url' + }) + }), + { upsert: true, new: true, rawResult: true } + ) + }) + + it('soft-deletes removed articles (publishedAt set to null)', async () => { + fetchAllDocuments.mockResolvedValue([ + makeOutlineDoc({ id: 'doc-1' }) + ]) + WikiArticle.find.mockResolvedValue([ + { outlineId: 'doc-1', publishedAt: new Date('2026-01-15') }, + { outlineId: 'doc-2', publishedAt: new Date('2026-01-10') } + ]) + WikiArticle.findOneAndUpdate.mockResolvedValue({ + lastErrorObject: { updatedExisting: true } + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + const result = await syncHandler(event) + + expect(result.deleted).toBe(1) + // The second call should be the soft-delete for doc-2 + const softDeleteCall = WikiArticle.findOneAndUpdate.mock.calls.find( + (call) => call[0].outlineId === 'doc-2' + ) + expect(softDeleteCall).toBeTruthy() + expect(softDeleteCall[1].$set.publishedAt).toBeNull() + }) + + it('restores re-published articles', async () => { + const docs = [ + makeOutlineDoc({ + id: 'doc-1', + publishedAt: '2026-02-01T00:00:00Z' + }) + ] + fetchAllDocuments.mockResolvedValue(docs) + // doc-1 was previously soft-deleted (publishedAt: null) + WikiArticle.find.mockResolvedValue([ + { outlineId: 'doc-1', publishedAt: null } + ]) + WikiArticle.findOneAndUpdate.mockResolvedValue({ + lastErrorObject: { updatedExisting: true } + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + const result = await syncHandler(event) + + expect(result.updated).toBe(1) + expect(result.deleted).toBe(0) + // The upsert should set publishedAt from the Outline document + const upsertCall = WikiArticle.findOneAndUpdate.mock.calls.find( + (call) => call[0].outlineId === 'doc-1' + ) + expect(upsertCall[1].$set.publishedAt).toEqual(new Date('2026-02-01T00:00:00Z')) + }) + + it('aborts on mid-pagination API error with no partial writes', async () => { + fetchAllDocuments.mockRejectedValue( + createError({ statusCode: 502, statusMessage: 'Outline API error' }) + ) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + await expect(syncHandler(event)).rejects.toMatchObject({ + statusCode: 502 + }) + + // No DB writes should have occurred + expect(WikiArticle.findOneAndUpdate).not.toHaveBeenCalled() + expect(WikiArticle.find).not.toHaveBeenCalled() + }) + + it('returns counts in response', async () => { + const docs = [ + makeOutlineDoc({ id: 'doc-1' }), + makeOutlineDoc({ id: 'doc-2' }), + makeOutlineDoc({ id: 'doc-3' }) + ] + fetchAllDocuments.mockResolvedValue(docs) + WikiArticle.find.mockResolvedValue([ + { outlineId: 'doc-1', publishedAt: new Date() }, + { outlineId: 'doc-4', publishedAt: new Date() } + ]) + // doc-1 exists (update), doc-2 and doc-3 are new (create), doc-4 is gone (delete) + WikiArticle.findOneAndUpdate + .mockResolvedValueOnce({ lastErrorObject: { updatedExisting: true } }) // doc-1 update + .mockResolvedValueOnce({ lastErrorObject: { updatedExisting: false } }) // doc-2 create + .mockResolvedValueOnce({ lastErrorObject: { updatedExisting: false } }) // doc-3 create + .mockResolvedValue({}) // doc-4 soft-delete + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/sync' + }) + + const result = await syncHandler(event) + + expect(result).toEqual({ + created: 2, + updated: 1, + deleted: 1, + errors: 0 + }) + }) +}) + +// Test extractSummary directly — reimport unmocked +describe('extractSummary', () => { + // We need the real implementation, so we dynamically import it + // to bypass the module mock above + let realExtract + + beforeEach(async () => { + const mod = await vi.importActual('../../../server/utils/outline.js') + realExtract = mod.extractSummary + }) + + it('returns empty string for falsy input', () => { + expect(realExtract('')).toBe('') + expect(realExtract(null)).toBe('') + expect(realExtract(undefined)).toBe('') + }) + + it('strips HTML tags', () => { + expect(realExtract('

Hello world

')).toBe('Hello world') + }) + + it('returns text as-is when <= 200 chars after stripping', () => { + const short = 'A short summary.' + expect(realExtract(short)).toBe(short) + }) + + it('truncates at word boundary without mid-word cuts', () => { + // Build a string that is longer than 200 chars + const words = 'word '.repeat(50).trim() // 249 chars: "word word word..." + const result = realExtract(words) + expect(result.length).toBeLessThanOrEqual(200) + // Should not end mid-word + expect(result).not.toMatch(/\S$\S/) + // Should end with a complete word + expect(result.endsWith('word')).toBe(true) + }) +}) From d3a5c1a3a70dc564b9336de37d29a503ae1990b1 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:36:19 +0100 Subject: [PATCH 009/285] feat(wiki): add tag-based wiki recommendations API --- server/api/wiki/recommended.get.js | 32 +++++ server/models/wikiArticle.js | 20 +++ tests/server/api/wiki-recommended.test.js | 165 ++++++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 server/api/wiki/recommended.get.js create mode 100644 server/models/wikiArticle.js create mode 100644 tests/server/api/wiki-recommended.test.js diff --git a/server/api/wiki/recommended.get.js b/server/api/wiki/recommended.get.js new file mode 100644 index 0000000..104960a --- /dev/null +++ b/server/api/wiki/recommended.get.js @@ -0,0 +1,32 @@ +import WikiArticle from '../../models/wikiArticle.js' +import { connectDB } from '../../utils/mongoose.js' +import { requireAuth } from '../../utils/auth.js' + +export default defineEventHandler(async (event) => { + const member = await requireAuth(event) + + // Combine craft tags and cooperative ecology tags + const craftTags = member.craftTags || [] + const ecologyTags = (member.communityEcology?.topics || []).map(t => t.tagSlug) + const memberTags = [...new Set([...craftTags, ...ecologyTags].filter(Boolean))] + + if (!memberTags.length) { + return [] + } + + await connectDB() + + const query = getQuery(event) + const limit = Math.min(Math.max(parseInt(query.limit) || 10, 1), 25) + + const articles = await WikiArticle.find({ + tags: { $in: memberTags }, + publishedAt: { $ne: null } + }) + .sort({ publishedAt: -1 }) + .limit(limit) + .select('title url summary tags collection publishedAt') + .lean() + + return articles +}) diff --git a/server/models/wikiArticle.js b/server/models/wikiArticle.js new file mode 100644 index 0000000..275ba7d --- /dev/null +++ b/server/models/wikiArticle.js @@ -0,0 +1,20 @@ +import mongoose from 'mongoose' + +const wikiArticleSchema = new mongoose.Schema( + { + outlineId: { type: String, unique: true, required: true }, + title: { type: String, required: true }, + collection: String, + url: { type: String, required: true }, + summary: String, + tags: [{ type: String }], + publishedAt: Date, + permission: String, + lastSyncedAt: Date, + outlineUpdatedAt: Date + }, + { timestamps: true } +) + +export default mongoose.models.WikiArticle || + mongoose.model('WikiArticle', wikiArticleSchema) diff --git a/tests/server/api/wiki-recommended.test.js b/tests/server/api/wiki-recommended.test.js new file mode 100644 index 0000000..c6ae5f0 --- /dev/null +++ b/tests/server/api/wiki-recommended.test.js @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockFind, mockSort, mockLimit, mockSelect, mockLean } = vi.hoisted(() => ({ + mockFind: vi.fn(), + mockSort: vi.fn(), + mockLimit: vi.fn(), + mockSelect: vi.fn(), + mockLean: vi.fn() +})) + +vi.mock('../../../server/models/wikiArticle.js', () => ({ + default: { find: mockFind } +})) + +vi.mock('../../../server/utils/mongoose.js', () => ({ + connectDB: vi.fn() +})) + +vi.mock('../../../server/utils/auth.js', () => ({ + requireAuth: vi.fn() +})) + +import { requireAuth } from '../../../server/utils/auth.js' +import handler from '../../../server/api/wiki/recommended.get.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +// Wire up the chained query builder +function setupChain(result = []) { + mockLean.mockResolvedValue(result) + mockSelect.mockReturnValue({ lean: mockLean }) + mockLimit.mockReturnValue({ select: mockSelect }) + mockSort.mockReturnValue({ limit: mockLimit }) + mockFind.mockReturnValue({ sort: mockSort }) +} + +function makeMember(overrides = {}) { + return { + _id: 'member-1', + craftTags: [], + communityEcology: { topics: [] }, + ...overrides + } +} + +function makeArticle(overrides = {}) { + return { + _id: 'article-1', + title: 'Test Article', + url: 'https://wiki.example.com/test-article', + summary: 'A test article', + tags: ['game-design'], + collection: 'Guides', + publishedAt: new Date('2026-04-01'), + ...overrides + } +} + +describe('GET /api/wiki/recommended', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns articles matching member tags', async () => { + const member = makeMember({ craftTags: ['game-design', 'narrative'] }) + requireAuth.mockResolvedValue(member) + + const articles = [makeArticle({ tags: ['game-design'] })] + setupChain(articles) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended' }) + const result = await handler(event) + + expect(result).toEqual(articles) + expect(mockFind).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { $in: expect.arrayContaining(['game-design', 'narrative']) } + }) + ) + }) + + it('returns empty array when no tag overlap', async () => { + const member = makeMember({ craftTags: ['audio'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended' }) + const result = await handler(event) + + expect(result).toEqual([]) + }) + + it('excludes unpublished articles (publishedAt: null)', async () => { + const member = makeMember({ craftTags: ['game-design'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended' }) + await handler(event) + + const filter = mockFind.mock.calls[0][0] + expect(filter.publishedAt).toEqual({ $ne: null }) + }) + + it('uses default limit of 10', async () => { + const member = makeMember({ craftTags: ['game-design'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended' }) + await handler(event) + + expect(mockLimit).toHaveBeenCalledWith(10) + }) + + it('accepts limit query param', async () => { + const member = makeMember({ craftTags: ['game-design'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended?limit=5' }) + await handler(event) + + expect(mockLimit).toHaveBeenCalledWith(5) + }) + + it('caps limit at 25', async () => { + const member = makeMember({ craftTags: ['game-design'] }) + requireAuth.mockResolvedValue(member) + + setupChain([]) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended?limit=100' }) + await handler(event) + + expect(mockLimit).toHaveBeenCalledWith(25) + }) + + it('returns empty array when member has no tags', async () => { + const member = makeMember() + requireAuth.mockResolvedValue(member) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended' }) + const result = await handler(event) + + expect(result).toEqual([]) + // Should not query the database at all + expect(mockFind).not.toHaveBeenCalled() + }) + + it('requires auth (401)', async () => { + requireAuth.mockRejectedValue( + createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + ) + + const event = createMockEvent({ method: 'GET', path: '/api/wiki/recommended' }) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 401 + }) + }) +}) From e4f2efd6d0b42e1f6edb42f49931b5f01718a221 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:36:44 +0100 Subject: [PATCH 010/285] feat(wiki): add admin wiki management API routes --- server/api/admin/wiki/[id].patch.js | 28 +++ server/api/admin/wiki/batch-tag.post.js | 55 ++++++ server/api/admin/wiki/index.get.js | 24 +++ server/models/wikiArticle.js | 20 ++ tests/server/api/admin-wiki.test.js | 248 ++++++++++++++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 server/api/admin/wiki/[id].patch.js create mode 100644 server/api/admin/wiki/batch-tag.post.js create mode 100644 server/api/admin/wiki/index.get.js create mode 100644 server/models/wikiArticle.js create mode 100644 tests/server/api/admin-wiki.test.js diff --git a/server/api/admin/wiki/[id].patch.js b/server/api/admin/wiki/[id].patch.js new file mode 100644 index 0000000..1ad9a08 --- /dev/null +++ b/server/api/admin/wiki/[id].patch.js @@ -0,0 +1,28 @@ +import * as z from 'zod' +import WikiArticle from '../../../models/wikiArticle.js' +import { connectDB } from '../../../utils/mongoose.js' + +const wikiTagsSchema = z.object({ + tags: z.array(z.string()) +}) + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + + const body = await validateBody(event, wikiTagsSchema) + const id = getRouterParam(event, 'id') + + await connectDB() + + const article = await WikiArticle.findByIdAndUpdate( + id, + { tags: body.tags }, + { new: true } + ) + + if (!article) { + throw createError({ statusCode: 404, statusMessage: 'Article not found' }) + } + + return article +}) diff --git a/server/api/admin/wiki/batch-tag.post.js b/server/api/admin/wiki/batch-tag.post.js new file mode 100644 index 0000000..be0ebef --- /dev/null +++ b/server/api/admin/wiki/batch-tag.post.js @@ -0,0 +1,55 @@ +import * as z from 'zod' +import WikiArticle from '../../../models/wikiArticle.js' +import { connectDB } from '../../../utils/mongoose.js' + +const batchTagSchema = z.object({ + articleIds: z.array(z.string()).optional(), + collection: z.string().optional(), + addTags: z.array(z.string()).optional(), + removeTags: z.array(z.string()).optional() +}) + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + + const body = await validateBody(event, batchTagSchema) + + if (!body.articleIds && !body.collection) { + throw createError({ + statusCode: 400, + statusMessage: 'Must provide either articleIds or collection' + }) + } + + if (!body.addTags?.length && !body.removeTags?.length) { + throw createError({ + statusCode: 400, + statusMessage: 'Must provide at least one of addTags or removeTags' + }) + } + + await connectDB() + + const filter = body.articleIds + ? { _id: { $in: body.articleIds } } + : { collection: body.collection } + + let modified = 0 + + if (body.addTags?.length) { + const result = await WikiArticle.updateMany(filter, { + $addToSet: { tags: { $each: body.addTags } } + }) + modified = result.modifiedCount || 0 + } + + if (body.removeTags?.length) { + const result = await WikiArticle.updateMany(filter, { + $pull: { tags: { $in: body.removeTags } } + }) + // Use the higher count if both operations ran + modified = Math.max(modified, result.modifiedCount || 0) + } + + return { modified } +}) diff --git a/server/api/admin/wiki/index.get.js b/server/api/admin/wiki/index.get.js new file mode 100644 index 0000000..b4baf5a --- /dev/null +++ b/server/api/admin/wiki/index.get.js @@ -0,0 +1,24 @@ +import WikiArticle from '../../../models/wikiArticle.js' +import { connectDB } from '../../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + await requireAdmin(event) + await connectDB() + + const { collection, search } = getQuery(event) + + const filter = {} + if (collection) { + filter.collection = collection + } + if (search) { + filter.title = { $regex: search, $options: 'i' } + } + + const articles = await WikiArticle.find(filter) + .select('collection title tags url outlineId publishedAt') + .sort({ collection: 1, title: 1 }) + .lean() + + return articles +}) diff --git a/server/models/wikiArticle.js b/server/models/wikiArticle.js new file mode 100644 index 0000000..275ba7d --- /dev/null +++ b/server/models/wikiArticle.js @@ -0,0 +1,20 @@ +import mongoose from 'mongoose' + +const wikiArticleSchema = new mongoose.Schema( + { + outlineId: { type: String, unique: true, required: true }, + title: { type: String, required: true }, + collection: String, + url: { type: String, required: true }, + summary: String, + tags: [{ type: String }], + publishedAt: Date, + permission: String, + lastSyncedAt: Date, + outlineUpdatedAt: Date + }, + { timestamps: true } +) + +export default mongoose.models.WikiArticle || + mongoose.model('WikiArticle', wikiArticleSchema) diff --git a/tests/server/api/admin-wiki.test.js b/tests/server/api/admin-wiki.test.js new file mode 100644 index 0000000..bd70678 --- /dev/null +++ b/tests/server/api/admin-wiki.test.js @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../../../server/models/wikiArticle.js', () => ({ + default: { + find: vi.fn(), + findByIdAndUpdate: vi.fn(), + updateMany: vi.fn() + } +})) + +vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) + +import WikiArticle from '../../../server/models/wikiArticle.js' +import indexHandler from '../../../server/api/admin/wiki/index.get.js' +import patchHandler from '../../../server/api/admin/wiki/[id].patch.js' +import batchTagHandler from '../../../server/api/admin/wiki/batch-tag.post.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +// --- GET /api/admin/wiki --- + +describe('GET /api/admin/wiki', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' }) + }) + + it('returns all articles with collection, title, tags', async () => { + const articles = [ + { _id: 'a1', collection: 'General', title: 'Alpha', tags: ['co-ops'], url: '/doc/alpha', outlineId: 'o1', publishedAt: new Date() }, + { _id: 'a2', collection: 'General', title: 'Beta', tags: [], url: '/doc/beta', outlineId: 'o2', publishedAt: new Date() } + ] + WikiArticle.find.mockReturnValue({ + select: () => ({ + sort: () => ({ + lean: () => Promise.resolve(articles) + }) + }) + }) + + const event = createMockEvent({ method: 'GET', path: '/api/admin/wiki' }) + const result = await indexHandler(event) + + expect(result).toEqual(articles) + expect(WikiArticle.find).toHaveBeenCalledWith({}) + }) + + it('filters by collection', async () => { + WikiArticle.find.mockReturnValue({ + select: () => ({ + sort: () => ({ + lean: () => Promise.resolve([]) + }) + }) + }) + + const event = createMockEvent({ + method: 'GET', + path: '/api/admin/wiki?collection=Guides' + }) + await indexHandler(event) + + expect(WikiArticle.find).toHaveBeenCalledWith({ collection: 'Guides' }) + }) + + it('searches by title (case-insensitive)', async () => { + WikiArticle.find.mockReturnValue({ + select: () => ({ + sort: () => ({ + lean: () => Promise.resolve([]) + }) + }) + }) + + const event = createMockEvent({ + method: 'GET', + path: '/api/admin/wiki?search=cooperative' + }) + await indexHandler(event) + + expect(WikiArticle.find).toHaveBeenCalledWith({ + title: { $regex: 'cooperative', $options: 'i' } + }) + }) + + it('requires admin auth (403)', async () => { + requireAdmin.mockRejectedValue( + createError({ statusCode: 403, statusMessage: 'Admin access required' }) + ) + + const event = createMockEvent({ method: 'GET', path: '/api/admin/wiki' }) + await expect(indexHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) +}) + +// --- PATCH /api/admin/wiki/:id --- + +describe('PATCH /api/admin/wiki/:id', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' }) + validateBody.mockImplementation(async (event) => readBody(event)) + }) + + it('sets tags on an article', async () => { + const updated = { + _id: 'a1', + title: 'Alpha', + tags: ['co-ops', 'governance'], + collection: 'General' + } + WikiArticle.findByIdAndUpdate.mockResolvedValue(updated) + + const event = createMockEvent({ + method: 'PATCH', + path: '/api/admin/wiki/a1', + body: { tags: ['co-ops', 'governance'] } + }) + // Simulate getRouterParam returning the id + event.context.params = { id: 'a1' } + + const result = await patchHandler(event) + + expect(result).toEqual(updated) + expect(WikiArticle.findByIdAndUpdate).toHaveBeenCalledWith( + 'a1', + { tags: ['co-ops', 'governance'] }, + { new: true } + ) + }) + + it('returns 404 for invalid article ID', async () => { + WikiArticle.findByIdAndUpdate.mockResolvedValue(null) + + const event = createMockEvent({ + method: 'PATCH', + path: '/api/admin/wiki/nonexistent', + body: { tags: ['test'] } + }) + event.context.params = { id: 'nonexistent' } + + await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('requires admin auth (403)', async () => { + requireAdmin.mockRejectedValue( + createError({ statusCode: 403, statusMessage: 'Admin access required' }) + ) + + const event = createMockEvent({ + method: 'PATCH', + path: '/api/admin/wiki/a1', + body: { tags: ['test'] } + }) + event.context.params = { id: 'a1' } + + await expect(patchHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) +}) + +// --- POST /api/admin/wiki/batch-tag --- + +describe('POST /api/admin/wiki/batch-tag', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: 'admin-1', role: 'admin' }) + validateBody.mockImplementation(async (event) => readBody(event)) + }) + + it('adds tags to multiple articles (add/merge semantics)', async () => { + WikiArticle.updateMany.mockResolvedValue({ modifiedCount: 3 }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/batch-tag', + body: { + articleIds: ['a1', 'a2', 'a3'], + addTags: ['governance', 'finance'] + } + }) + + const result = await batchTagHandler(event) + + expect(result).toEqual({ modified: 3 }) + expect(WikiArticle.updateMany).toHaveBeenCalledWith( + { _id: { $in: ['a1', 'a2', 'a3'] } }, + { $addToSet: { tags: { $each: ['governance', 'finance'] } } } + ) + }) + + it('removes tags from multiple articles', async () => { + WikiArticle.updateMany.mockResolvedValue({ modifiedCount: 2 }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/batch-tag', + body: { + collection: 'Guides', + removeTags: ['outdated'] + } + }) + + const result = await batchTagHandler(event) + + expect(result).toEqual({ modified: 2 }) + expect(WikiArticle.updateMany).toHaveBeenCalledWith( + { collection: 'Guides' }, + { $pull: { tags: { $in: ['outdated'] } } } + ) + }) + + it('does not duplicate existing tags on add', async () => { + // $addToSet guarantees no duplicates — verify the operator is used + WikiArticle.updateMany.mockResolvedValue({ modifiedCount: 1 }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/batch-tag', + body: { + articleIds: ['a1'], + addTags: ['co-ops'] + } + }) + + await batchTagHandler(event) + + // Confirm $addToSet is used, not $push + const call = WikiArticle.updateMany.mock.calls[0] + expect(call[1]).toHaveProperty('$addToSet') + expect(call[1]).not.toHaveProperty('$push') + }) + + it('requires admin auth (403)', async () => { + requireAdmin.mockRejectedValue( + createError({ statusCode: 403, statusMessage: 'Admin access required' }) + ) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/wiki/batch-tag', + body: { + articleIds: ['a1'], + addTags: ['test'] + } + }) + + await expect(batchTagHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) +}) From 337664790f5be233d1d0c71f661664d18e6cad20 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:38:20 +0100 Subject: [PATCH 011/285] feat(events): add tag selector to admin event form --- app/pages/admin/events/create.vue | 42 ++++++++++++++++++++++++++----- server/models/event.js | 1 + 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/app/pages/admin/events/create.vue b/app/pages/admin/events/create.vue index a3d56e3..07267e5 100644 --- a/app/pages/admin/events/create.vue +++ b/app/pages/admin/events/create.vue @@ -232,6 +232,26 @@ + +
+

Tags

+ +
+ + +

+ Tag this event to help with discovery and recommendations +

+
+
+

Ticketing

@@ -584,6 +604,11 @@ const formErrors = ref([]); const fieldErrors = ref({}); const selectedSeriesId = ref(null); const availableSeries = ref([]); +const availableTags = ref([]); + +const tagOptions = computed(() => + availableTags.value.map((t) => ({ label: t.label, value: t.slug })) +); const eventForm = reactive({ title: "", @@ -599,6 +624,7 @@ const eventForm = reactive({ isCancelled: false, cancellationMessage: "", targetCircles: [], + tags: [], maxAttendees: "", registrationRequired: false, registrationDeadline: "", @@ -632,15 +658,17 @@ const removeAgendaItem = (index) => { eventForm.agenda.splice(index, 1); }; -// Load available series +// Load available series and tags onMounted(async () => { try { - const response = await $fetch("/api/admin/series"); - console.log("Loaded series:", response); - availableSeries.value = response; - console.log("availableSeries.value:", availableSeries.value); + const [seriesResponse, tagsResponse] = await Promise.all([ + $fetch("/api/admin/series"), + $fetch("/api/tags"), + ]); + availableSeries.value = seriesResponse; + availableTags.value = tagsResponse.tags || []; } catch (error) { - console.error("Failed to load series:", error); + console.error("Failed to load form data:", error); } }); @@ -692,6 +720,7 @@ if (route.query.edit) { isCancelled: event.isCancelled || false, cancellationMessage: event.cancellationMessage || "", targetCircles: event.targetCircles || [], + tags: event.tags || [], maxAttendees: event.maxAttendees || "", registrationRequired: event.registrationRequired, registrationDeadline: event.registrationDeadline @@ -912,6 +941,7 @@ const saveAndCreateAnother = async () => { isCancelled: false, cancellationMessage: "", targetCircles: [], + tags: [], maxAttendees: "", registrationRequired: false, registrationDeadline: "", diff --git a/server/models/event.js b/server/models/event.js index 16245bc..ff2e2b0 100644 --- a/server/models/event.js +++ b/server/models/event.js @@ -142,6 +142,7 @@ const eventSchema = new mongoose.Schema({ maxAttendees: Number, registrationRequired: { type: Boolean, default: false }, registrationDeadline: Date, + tags: [String], // Tag slugs from Tag collection agenda: [String], speakers: [ { From 5d3b04af48e992369f2bb836d2c3c90ffb80aa84 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:41:30 +0100 Subject: [PATCH 012/285] feat(onboarding): add useOnboarding composable --- app/composables/useOnboarding.js | 189 +++++++++ .../client/composables/useOnboarding.test.js | 390 ++++++++++++++++++ 2 files changed, 579 insertions(+) create mode 100644 app/composables/useOnboarding.js create mode 100644 tests/client/composables/useOnboarding.test.js diff --git a/app/composables/useOnboarding.js b/app/composables/useOnboarding.js new file mode 100644 index 0000000..6dfcbf7 --- /dev/null +++ b/app/composables/useOnboarding.js @@ -0,0 +1,189 @@ +/** + * Onboarding Composable + * Tracks new member onboarding goals and provides post-graduation suggestions. + */ +export function useOnboarding(options = {}) { + const goals = useState('onboarding.goals', () => ({ + hasProfileTags: false, + hasVisitedEvent: false, + hasEngagedEcology: false, + hasClickedWiki: false, + })) + + const completedAt = useState('onboarding.completedAt', () => null) + const loading = useState('onboarding.loading', () => false) + const recommendations = useState('onboarding.recommendations', () => ({ + events: [], + ecology: [], + wiki: [], + })) + + // Track whether we've already fetched status this session + const _fetched = useState('onboarding._fetched', () => false) + + const isComplete = computed(() => + goals.value.hasProfileTags && + goals.value.hasVisitedEvent && + goals.value.hasEngagedEcology && + goals.value.hasClickedWiki + ) + + const pickCategory = options.pickCategory || ((categories) => { + return categories[Math.floor(Math.random() * categories.length)] + }) + + const currentSuggestion = computed(() => { + // Not graduated — return highest-priority incomplete goal + if (!isComplete.value) { + if (!goals.value.hasProfileTags) { + return { + key: 'profileTags', + text: 'Complete your profile by adding your craft and community tags', + action: '/member/profile', + actionText: 'Set up tags', + } + } + if (!goals.value.hasVisitedEvent) { + return { + key: 'visitEvent', + text: 'Check out upcoming events', + action: '/events', + actionText: 'Browse events', + } + } + if (!goals.value.hasEngagedEcology) { + return { + key: 'ecology', + text: 'Explore the community ecology to find collaborators', + action: '/ecology', + actionText: 'Explore ecology', + } + } + if (!goals.value.hasClickedWiki) { + return { + key: 'wiki', + text: 'Browse the wiki for resources and guides', + action: null, + actionText: 'Browse wiki', + isExternal: true, + } + } + } + + // Graduated — suggestion mode + const cats = ['events', 'ecology', 'wiki'].filter( + (c) => recommendations.value[c]?.length > 0 + ) + + if (cats.length === 0) { + return { key: 'empty', text: 'No suggestions right now' } + } + + const selected = pickCategory(cats) + const items = recommendations.value[selected] + + if (items?.length > 0) { + return buildRecommendation(selected, items[0]) + } + + // Fallback to first non-empty category (shouldn't happen since we filtered) + for (const cat of cats) { + if (recommendations.value[cat]?.length > 0) { + return buildRecommendation(cat, recommendations.value[cat][0]) + } + } + + return { key: 'empty', text: 'No suggestions right now' } + }) + + function buildRecommendation(category, item) { + if (category === 'events') { + return { + key: 'event', + text: `Upcoming event: ${item.title}`, + action: `/events/${item._id}`, + actionText: 'View event', + } + } + if (category === 'ecology') { + return { + key: 'ecology', + text: `Connect with ${item.name || 'a member'} in the ecology`, + action: '/ecology', + actionText: 'Explore ecology', + } + } + if (category === 'wiki') { + return { + key: 'wiki', + text: `Recommended: ${item.title}`, + action: item.url || null, + actionText: 'Read article', + isExternal: true, + } + } + return { key: 'empty', text: 'No suggestions right now' } + } + + async function fetchStatus() { + if (_fetched.value) return + loading.value = true + try { + const data = await $fetch('/api/onboarding/status') + if (data?.goals) { + goals.value = { ...goals.value, ...data.goals } + } + if (data?.completedAt) { + completedAt.value = data.completedAt + } + _fetched.value = true + + // If graduated, fetch recommendations + if (isComplete.value) { + await fetchRecommendations() + } + } catch { + // Silently fail — goals stay at defaults + } finally { + loading.value = false + } + } + + async function fetchRecommendations() { + const [events, ecology, wiki] = await Promise.allSettled([ + $fetch('/api/events/recommended'), + $fetch('/api/ecology/suggestions'), + $fetch('/api/wiki/recommended'), + ]) + recommendations.value = { + events: events.status === 'fulfilled' ? (events.value || []) : [], + ecology: ecology.status === 'fulfilled' ? (ecology.value?.suggestions || []) : [], + wiki: wiki.status === 'fulfilled' ? (wiki.value || []) : [], + } + } + + async function trackGoal(goalName) { + if (isComplete.value) return + try { + await $fetch('/api/onboarding/track', { + method: 'POST', + body: { goal: goalName }, + }) + } catch { + // Fire-and-forget + } + } + + // Initialize on first use + fetchStatus() + + return { + goals: readonly(goals), + isComplete: readonly(isComplete), + completedAt: readonly(completedAt), + currentSuggestion, + trackGoal, + recommendations: readonly(recommendations), + loading: readonly(loading), + } +} diff --git a/tests/client/composables/useOnboarding.test.js b/tests/client/composables/useOnboarding.test.js new file mode 100644 index 0000000..322b2c1 --- /dev/null +++ b/tests/client/composables/useOnboarding.test.js @@ -0,0 +1,390 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref, computed, readonly } from 'vue' + +// --- Nuxt auto-import stubs --- + +vi.stubGlobal('computed', computed) +vi.stubGlobal('readonly', readonly) + +// useState: return a fresh ref per key, reset between tests +const stateStore = {} +vi.stubGlobal('useState', (key, init) => { + if (!stateStore[key]) { + stateStore[key] = ref(init ? init() : null) + } + return stateStore[key] +}) + +function resetState() { + for (const key of Object.keys(stateStore)) { + delete stateStore[key] + } +} + +// $fetch mock +const fetchMock = vi.fn() +vi.stubGlobal('$fetch', fetchMock) + +// --- Tests --- + +describe('useOnboarding', () => { + let useOnboarding + + beforeEach(async () => { + resetState() + fetchMock.mockReset() + // Default: status endpoint returns all-false goals + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: false, + hasVisitedEvent: false, + hasEngagedEcology: false, + hasClickedWiki: false, + }, + completedAt: null, + }) + } + return Promise.resolve(null) + }) + + // Re-import to get clean module state + vi.resetModules() + const mod = await import('../../../app/composables/useOnboarding.js') + useOnboarding = mod.useOnboarding + }) + + // 9.1: Initializes goals from API response + it('9.1: initializes goals from API response', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: false, + hasEngagedEcology: true, + hasClickedWiki: false, + }, + completedAt: null, + }) + } + return Promise.resolve(null) + }) + + const { goals, loading } = useOnboarding() + + // Wait for fetchStatus to complete + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(goals.value.hasProfileTags).toBe(true) + expect(goals.value.hasVisitedEvent).toBe(false) + expect(goals.value.hasEngagedEcology).toBe(true) + expect(goals.value.hasClickedWiki).toBe(false) + }) + + // 9.2: isComplete true when all goals true + it('9.2: isComplete is true when all goals are true', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: true, + hasClickedWiki: true, + }, + completedAt: '2026-04-01T00:00:00Z', + }) + } + if (url === '/api/events/recommended') return Promise.resolve([]) + if (url === '/api/ecology/suggestions') return Promise.resolve({ suggestions: [] }) + if (url === '/api/wiki/recommended') return Promise.resolve([]) + return Promise.resolve(null) + }) + + const { isComplete, completedAt, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(isComplete.value).toBe(true) + expect(completedAt.value).toBe('2026-04-01T00:00:00Z') + }) + + // 9.3: isComplete false with partial goals + it('9.3: isComplete is false with partial goals', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: false, + hasClickedWiki: false, + }, + completedAt: null, + }) + } + return Promise.resolve(null) + }) + + const { isComplete, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(isComplete.value).toBe(false) + }) + + // 9.4: currentSuggestion returns profile tags when no tags set (priority 1) + it('9.4: currentSuggestion returns profile tags when no tags set', async () => { + const { currentSuggestion, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(currentSuggestion.value.key).toBe('profileTags') + expect(currentSuggestion.value.action).toBe('/member/profile') + expect(currentSuggestion.value.actionText).toBe('Set up tags') + }) + + // 9.5: currentSuggestion returns event when tags set but no event visit (priority 2) + it('9.5: currentSuggestion returns event when tags set but no event visit', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: false, + hasEngagedEcology: false, + hasClickedWiki: false, + }, + completedAt: null, + }) + } + return Promise.resolve(null) + }) + + const { currentSuggestion, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(currentSuggestion.value.key).toBe('visitEvent') + expect(currentSuggestion.value.action).toBe('/events') + expect(currentSuggestion.value.actionText).toBe('Browse events') + }) + + // 9.6: currentSuggestion returns ecology when tags + event done (priority 3) + it('9.6: currentSuggestion returns ecology when tags + event done', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: false, + hasClickedWiki: false, + }, + completedAt: null, + }) + } + return Promise.resolve(null) + }) + + const { currentSuggestion, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(currentSuggestion.value.key).toBe('ecology') + expect(currentSuggestion.value.action).toBe('/ecology') + }) + + // 9.7: currentSuggestion returns wiki when only wiki remaining (priority 4) + it('9.7: currentSuggestion returns wiki when only wiki remaining', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: true, + hasClickedWiki: false, + }, + completedAt: null, + }) + } + return Promise.resolve(null) + }) + + const { currentSuggestion, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(currentSuggestion.value.key).toBe('wiki') + expect(currentSuggestion.value.action).toBeNull() + expect(currentSuggestion.value.isExternal).toBe(true) + }) + + // 9.8: trackGoal fires POST and is skipped when complete + it('9.8: trackGoal fires POST and is skipped when complete', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: true, + hasClickedWiki: true, + }, + completedAt: '2026-04-01T00:00:00Z', + }) + } + if (url === '/api/events/recommended') return Promise.resolve([]) + if (url === '/api/ecology/suggestions') return Promise.resolve({ suggestions: [] }) + if (url === '/api/wiki/recommended') return Promise.resolve([]) + return Promise.resolve(null) + }) + + const { trackGoal, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + await trackGoal('eventPageVisited') + + // Should NOT have called the track endpoint since isComplete is true + const trackCalls = fetchMock.mock.calls.filter( + (c) => c[0] === '/api/onboarding/track' + ) + expect(trackCalls).toHaveLength(0) + }) + + // 9.9: Suggestion mode uses injectable pickCategory + it('9.9: suggestion mode uses injectable pickCategory', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: true, + hasClickedWiki: true, + }, + completedAt: '2026-04-01T00:00:00Z', + }) + } + if (url === '/api/events/recommended') { + return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }]) + } + if (url === '/api/ecology/suggestions') { + return Promise.resolve({ suggestions: [{ name: 'Alex' }] }) + } + if (url === '/api/wiki/recommended') { + return Promise.resolve([{ title: 'Co-op Guide', url: 'https://wiki.example.com/coop' }]) + } + return Promise.resolve(null) + }) + + // Always pick 'wiki' + const { currentSuggestion, loading } = useOnboarding({ + pickCategory: () => 'wiki', + }) + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(currentSuggestion.value.key).toBe('wiki') + expect(currentSuggestion.value.text).toContain('Co-op Guide') + expect(currentSuggestion.value.isExternal).toBe(true) + }) + + // 9.10: Suggestion mode falls back when selected category empty + it('9.10: suggestion mode falls back when selected category empty', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: true, + hasClickedWiki: true, + }, + completedAt: '2026-04-01T00:00:00Z', + }) + } + if (url === '/api/events/recommended') { + return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }]) + } + if (url === '/api/ecology/suggestions') { + return Promise.resolve({ suggestions: [] }) + } + if (url === '/api/wiki/recommended') { + return Promise.resolve([]) + } + return Promise.resolve(null) + }) + + // pickCategory only gets non-empty categories, so it should only see 'events' + const pickedCategories = [] + const { currentSuggestion, loading } = useOnboarding({ + pickCategory: (cats) => { + pickedCategories.push([...cats]) + return cats[0] + }, + }) + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + // Access computed first to trigger pickCategory + expect(currentSuggestion.value.key).toBe('event') + expect(currentSuggestion.value.text).toContain('Game Jam') + // Only events had results, so pickCategory should only have received ['events'] + expect(pickedCategories[0]).toEqual(['events']) + }) + + // 9.11: Suggestion mode shows fallback when all categories empty + it('9.11: suggestion mode shows fallback when all categories empty', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: true, + hasClickedWiki: true, + }, + completedAt: '2026-04-01T00:00:00Z', + }) + } + if (url === '/api/events/recommended') return Promise.resolve([]) + if (url === '/api/ecology/suggestions') return Promise.resolve({ suggestions: [] }) + if (url === '/api/wiki/recommended') return Promise.resolve([]) + return Promise.resolve(null) + }) + + const { currentSuggestion, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(currentSuggestion.value.key).toBe('empty') + expect(currentSuggestion.value.text).toBe('No suggestions right now') + }) +}) From 795b856d560036a87b43f6404ae1fa0b4959ff37 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:42:43 +0100 Subject: [PATCH 013/285] feat(wiki): add admin wiki management page --- app/layouts/admin.vue | 17 + app/pages/admin/wiki.vue | 886 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 903 insertions(+) create mode 100644 app/pages/admin/wiki.vue diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue index 7f7d290..0802815 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -50,6 +50,14 @@ Series +
  • + + Wiki + +
  • @@ -136,6 +144,15 @@ Series +
  • + + Wiki + +
  • diff --git a/app/pages/admin/wiki.vue b/app/pages/admin/wiki.vue new file mode 100644 index 0000000..3edeff9 --- /dev/null +++ b/app/pages/admin/wiki.vue @@ -0,0 +1,886 @@ + + + + + From 3ff7cd4e0bfaeb08cd191a848fb4bcb0c89a33df Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:44:41 +0100 Subject: [PATCH 014/285] feat(onboarding): add OnboardingWidget component --- app/components/OnboardingWidget.vue | 215 ++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 app/components/OnboardingWidget.vue diff --git a/app/components/OnboardingWidget.vue b/app/components/OnboardingWidget.vue new file mode 100644 index 0000000..5d6c710 --- /dev/null +++ b/app/components/OnboardingWidget.vue @@ -0,0 +1,215 @@ + + + + + From 7c3a10232d8d8d54ee5fed9bfe37d7970de40a33 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:46:41 +0100 Subject: [PATCH 015/285] feat(onboarding): add tracking calls to event, ecology, and wiki pages --- app/pages/ecology.vue | 4 ++++ app/pages/events/[slug].vue | 4 ++++ app/pages/member/dashboard.vue | 8 ++++++++ 3 files changed, 16 insertions(+) diff --git a/app/pages/ecology.vue b/app/pages/ecology.vue index eed1daa..22fa81c 100644 --- a/app/pages/ecology.vue +++ b/app/pages/ecology.vue @@ -171,6 +171,7 @@ const loadSuggestions = async () => { } const { memberData } = useAuth() +const { trackGoal, isComplete } = useOnboarding() const loadTags = async () => { try { @@ -220,6 +221,9 @@ onMounted(async () => { } finally { loading.value = false } + if (!isComplete.value) { + trackGoal('ecologyPageVisited') + } }) useHead({ diff --git a/app/pages/events/[slug].vue b/app/pages/events/[slug].vue index 994fa0e..2570f52 100644 --- a/app/pages/events/[slug].vue +++ b/app/pages/events/[slug].vue @@ -331,10 +331,14 @@ const { getRSVPMessage, } = useMemberStatus(); const { completePayment, isProcessingPayment } = useMemberPayment(); +const { trackGoal, isComplete } = useOnboarding(); onMounted(async () => { await checkMemberStatus(); if (memberData.value) { + if (!isComplete.value) { + trackGoal('eventPageVisited'); + } registrationForm.value.name = memberData.value.name; registrationForm.value.email = memberData.value.email; registrationForm.value.membershipLevel = diff --git a/app/pages/member/dashboard.vue b/app/pages/member/dashboard.vue index b56798f..e1cfd16 100644 --- a/app/pages/member/dashboard.vue +++ b/app/pages/member/dashboard.vue @@ -135,6 +135,7 @@ href="https://wiki.ghostguild.org" target="_blank" class="quick-action" + @click="handleWikiClick" > Browse the wiki @@ -218,6 +219,13 @@ const { memberData, checkMemberStatus } = useAuth(); const { isActive, statusConfig, isPendingPayment, canPeerSupport } = useMemberStatus(); const { completePayment, isProcessingPayment } = useMemberPayment(); +const { trackGoal, isComplete: onboardingComplete } = useOnboarding(); + +const handleWikiClick = () => { + if (!onboardingComplete.value) { + trackGoal('wikiClicked'); + } +}; const registeredEvents = ref([]); const loadingEvents = ref(false); From a516f172fbb0638dcf729bce902296d8cdb76e31 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 23:51:56 +0100 Subject: [PATCH 016/285] refactor: extract escapeRegex and validateTagSlugs server utils Deduplicate tag validation and regex escaping into shared auto-imported utils. Add tag validation to wiki patch/batch-tag routes. Remove duplicate tags field from event schema. --- server/api/admin/events.post.js | 14 +------------- server/api/admin/events/[id].put.js | 14 +------------- server/api/admin/wiki/[id].patch.js | 2 ++ server/api/admin/wiki/batch-tag.post.js | 2 ++ server/api/admin/wiki/index.get.js | 2 +- server/api/members/directory.get.js | 3 +-- server/models/event.js | 1 - server/utils/escapeRegex.js | 3 +++ server/utils/validateTagSlugs.js | 14 ++++++++++++++ tests/server/api/event-tags.test.js | 4 +++- tests/server/setup.js | 5 +++++ 11 files changed, 33 insertions(+), 31 deletions(-) create mode 100644 server/utils/escapeRegex.js create mode 100644 server/utils/validateTagSlugs.js diff --git a/server/api/admin/events.post.js b/server/api/admin/events.post.js index 08a3543..c64332f 100644 --- a/server/api/admin/events.post.js +++ b/server/api/admin/events.post.js @@ -1,5 +1,4 @@ import Event from "../../models/event.js"; -import Tag from "../../models/tag.js"; import { connectDB } from "../../utils/mongoose.js"; import { requireAdmin } from "../../utils/auth.js"; import { validateBody } from "../../utils/validateBody.js"; @@ -13,18 +12,7 @@ export default defineEventHandler(async (event) => { await connectDB(); - // Validate tag slugs against Tag collection - if (body.tags && body.tags.length > 0) { - const foundTags = await Tag.find({ slug: { $in: body.tags } }); - const foundSlugs = new Set(foundTags.map((t) => t.slug)); - const invalid = body.tags.filter((s) => !foundSlugs.has(s)); - if (invalid.length > 0) { - throw createError({ - statusCode: 400, - statusMessage: `Unknown tag slugs: ${invalid.join(", ")}`, - }); - } - } + await validateTagSlugs(body.tags); const eventData = { ...body, diff --git a/server/api/admin/events/[id].put.js b/server/api/admin/events/[id].put.js index 60809a7..92c0945 100644 --- a/server/api/admin/events/[id].put.js +++ b/server/api/admin/events/[id].put.js @@ -1,5 +1,4 @@ import Event from '../../../models/event.js' -import Tag from '../../../models/tag.js' import { connectDB } from '../../../utils/mongoose.js' import { requireAdmin } from '../../../utils/auth.js' @@ -12,18 +11,7 @@ export default defineEventHandler(async (event) => { await connectDB() - // Validate tag slugs against Tag collection - if (body.tags && body.tags.length > 0) { - const foundTags = await Tag.find({ slug: { $in: body.tags } }) - const foundSlugs = new Set(foundTags.map(t => t.slug)) - const invalid = body.tags.filter(s => !foundSlugs.has(s)) - if (invalid.length > 0) { - throw createError({ - statusCode: 400, - statusMessage: `Unknown tag slugs: ${invalid.join(', ')}` - }) - } - } + await validateTagSlugs(body.tags) const updateData = { ...body, diff --git a/server/api/admin/wiki/[id].patch.js b/server/api/admin/wiki/[id].patch.js index 1ad9a08..0d9d88a 100644 --- a/server/api/admin/wiki/[id].patch.js +++ b/server/api/admin/wiki/[id].patch.js @@ -14,6 +14,8 @@ export default defineEventHandler(async (event) => { await connectDB() + await validateTagSlugs(body.tags) + const article = await WikiArticle.findByIdAndUpdate( id, { tags: body.tags }, diff --git a/server/api/admin/wiki/batch-tag.post.js b/server/api/admin/wiki/batch-tag.post.js index be0ebef..951a175 100644 --- a/server/api/admin/wiki/batch-tag.post.js +++ b/server/api/admin/wiki/batch-tag.post.js @@ -30,6 +30,8 @@ export default defineEventHandler(async (event) => { await connectDB() + await validateTagSlugs([...(body.addTags || []), ...(body.removeTags || [])]) + const filter = body.articleIds ? { _id: { $in: body.articleIds } } : { collection: body.collection } diff --git a/server/api/admin/wiki/index.get.js b/server/api/admin/wiki/index.get.js index b4baf5a..7b2a49b 100644 --- a/server/api/admin/wiki/index.get.js +++ b/server/api/admin/wiki/index.get.js @@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => { filter.collection = collection } if (search) { - filter.title = { $regex: search, $options: 'i' } + filter.title = { $regex: escapeRegex(search), $options: 'i' } } const articles = await WikiArticle.find(filter) diff --git a/server/api/members/directory.get.js b/server/api/members/directory.get.js index 8df00e6..7390679 100644 --- a/server/api/members/directory.get.js +++ b/server/api/members/directory.get.js @@ -42,8 +42,7 @@ export default defineEventHandler(async (event) => { } if (search) { - // Escape regex metacharacters to prevent ReDoS - const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const escaped = escapeRegex(search); andConditions.push({ $or: [ { name: { $regex: escaped, $options: "i" } }, diff --git a/server/models/event.js b/server/models/event.js index 2fd5d6d..ff2e2b0 100644 --- a/server/models/event.js +++ b/server/models/event.js @@ -183,7 +183,6 @@ const eventSchema = new mongoose.Schema({ refundAmount: Number, }, ], - tags: [{ type: String }], createdBy: { type: String, required: true }, createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now }, diff --git a/server/utils/escapeRegex.js b/server/utils/escapeRegex.js new file mode 100644 index 0000000..4013c44 --- /dev/null +++ b/server/utils/escapeRegex.js @@ -0,0 +1,3 @@ +export function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/server/utils/validateTagSlugs.js b/server/utils/validateTagSlugs.js new file mode 100644 index 0000000..5ada09d --- /dev/null +++ b/server/utils/validateTagSlugs.js @@ -0,0 +1,14 @@ +import Tag from '../models/tag.js' + +export async function validateTagSlugs(slugs) { + if (!slugs?.length) return + const foundTags = await Tag.find({ slug: { $in: slugs } }) + const foundSlugs = new Set(foundTags.map(t => t.slug)) + const invalid = slugs.filter(s => !foundSlugs.has(s)) + if (invalid.length > 0) { + throw createError({ + statusCode: 400, + statusMessage: `Unknown tag slugs: ${invalid.join(', ')}` + }) + } +} diff --git a/tests/server/api/event-tags.test.js b/tests/server/api/event-tags.test.js index 37a0eb9..6ef3bb9 100644 --- a/tests/server/api/event-tags.test.js +++ b/tests/server/api/event-tags.test.js @@ -29,6 +29,7 @@ vi.mock('../../../server/utils/schemas.js', () => ({ import Tag from '../../../server/models/tag.js' import { validateBody } from '../../../server/utils/validateBody.js' +import { validateTagSlugs } from '../../../server/utils/validateTagSlugs.js' import createHandler from '../../../server/api/admin/events.post.js' import updateHandler from '../../../server/api/admin/events/[id].put.js' import { createMockEvent } from '../helpers/createMockEvent.js' @@ -51,8 +52,9 @@ const validEventBody = { describe('Event tag validation', () => { beforeEach(() => { vi.clearAllMocks() - // Override the global validateBody stub (from setup.js) to use our mock + // Override global stubs (from setup.js) to use the real/mocked implementations globalThis.validateBody = validateBody + globalThis.validateTagSlugs = validateTagSlugs }) describe('create route (events.post)', () => { diff --git a/tests/server/setup.js b/tests/server/setup.js index 50fc8ef..f2fc815 100644 --- a/tests/server/setup.js +++ b/tests/server/setup.js @@ -41,3 +41,8 @@ vi.stubGlobal('requireAuth', vi.fn()) vi.stubGlobal('requireAdmin', vi.fn()) vi.stubGlobal('validateBody', vi.fn(async (event) => readBody(event))) vi.stubGlobal('logActivity', vi.fn()) +vi.stubGlobal('validateTagSlugs', vi.fn()) + +// Real server/utils that are safe to use as-is in tests +import { escapeRegex } from '../../server/utils/escapeRegex.js' +vi.stubGlobal('escapeRegex', escapeRegex) From 50a358b294807ee8d03d1ce089e18a0bbe61dcaa Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 23:52:00 +0100 Subject: [PATCH 017/285] feat(wiki): add batch tag remove mode to admin wiki page Add add/remove toggle to batch tag picker. Clean up unused requireAdmin import from wiki sync route. --- app/pages/admin/wiki.vue | 37 ++++++++++++++++++++---------- server/api/admin/wiki/sync.post.js | 1 - tests/server/api/wiki-sync.test.js | 4 ---- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/app/pages/admin/wiki.vue b/app/pages/admin/wiki.vue index 3edeff9..a8c3358 100644 --- a/app/pages/admin/wiki.vue +++ b/app/pages/admin/wiki.vue @@ -58,13 +58,17 @@ Select all in "{{ collectionFilter }}"
    - + + + + - Stay signed in -
    - `, "form#op\\.logoutForm { display: none; }"); + // oidc-provider's form HTML is a stable format (see node_modules/ + // oidc-provider/lib/actions/end_session.js:90): + //
    + // We extract just the xsrf token and hand off to a Nuxt page at + // /auth/logout-confirm that renders a styled form posting back to + // /oidc/session/end/confirm with that xsrf value. The token rides + // in a short-lived httpOnly cookie so it never hits the URL. + const match = form.match(/name="xsrf"\s+value="([^"]+)"/); + if (!match) { + // Defensive: if oidc-provider ever changes its form format, fall + // back to the raw form so logout still works. + ctx.type = "html"; + ctx.status = 200; + ctx.body = `${form}`; + return; + } + ctx.cookies.set("oidc_logout_xsrf", match[1], { + httpOnly: true, + sameSite: "lax", + maxAge: 120_000, // 2 minutes + path: "/", + overwrite: true, + signed: false, + }); + ctx.redirect("/auth/logout-confirm"); }, postLogoutSuccessSource: async (ctx: any) => { - ctx.body = guildPageShell("Signed Out", ` -

    Signed Out

    -

    You have been successfully signed out.

    - - `); + ctx.redirect("/auth/logout-success"); }, }, }, @@ -252,17 +149,15 @@ export async function getOidcProvider() { }, renderError: async (ctx: any, out: Record, _error: Error) => { - const details = Object.entries(out) - .map(([key, value]) => `${key}: ${value}`) - .join("
    "); - ctx.body = guildPageShell("Something Went Wrong", ` -

    Something Went Wrong

    -

    An error occurred during authentication. Please try again.

    -
    ${details}
    - - `); + // Allow-list only the standard OIDC error response fields. Prevents + // leaking internal error messages / stack traces, keeps the query + // string short, and the Nuxt page escapes them on render via Vue's + // default interpolation (fixes the prior XSS via unescaped HTML + // interpolation in the old guildPageShell implementation). + const params = new URLSearchParams(); + if (out.error) params.set("error", out.error); + if (out.error_description) params.set("error_description", out.error_description); + ctx.redirect(`/auth/oidc-error?${params.toString()}`); }, // Allow Outline to use PKCE but don't require it @@ -282,5 +177,12 @@ export async function getOidcProvider() { }, }); + // oidc-provider extends Koa but calls super() with no args, so app.proxy + // defaults to false — which makes ctx.protocol ignore X-Forwarded-Proto and + // emit http:// URLs for form actions, discovery metadata, authorization + // redirects, etc. Setting proxy = true here makes Koa trust Traefik's + // X-Forwarded-Proto header and build https:// URLs in production. + (_provider as any).proxy = true; + return _provider; } From c6b970a6218fb0c352a32526c2ca708a387cabd3 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 11 Apr 2026 23:24:38 +0100 Subject: [PATCH 021/285] Design token updates. --- app/assets/css/main.css | 9 +- app/components/OnboardingWidget.vue | 257 ++++++++++------------------ app/components/ParchmentInset.vue | 4 +- app/composables/useOnboarding.js | 7 - app/pages/ecology.vue | 21 ++- app/pages/events/index.vue | 32 ++-- app/pages/join.vue | 4 +- app/pages/member/dashboard.vue | 9 +- app/pages/member/profile.vue | 8 +- app/pages/members/[id].vue | 3 +- scripts/seed-welcome-tester.cjs | 52 ++++++ server/api/auth/member.get.js | 1 + server/models/wikiArticle.js | 2 +- 13 files changed, 198 insertions(+), 211 deletions(-) create mode 100644 scripts/seed-welcome-tester.cjs diff --git a/app/assets/css/main.css b/app/assets/css/main.css index ff66d93..4f1ab74 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -32,6 +32,8 @@ --parch-hover: #3a3025; --parch-text: #ede4d0; --parch-text-dim: #b8ae98; + --parch-accent: #c4a448; + --parch-border: #b8a880; --c-community: #7a4838; --c-founder: #8a4420; --c-practitioner: #2a4650; @@ -58,10 +60,9 @@ --text-bright: #d0c8b0; --text-dim: #958774; --text-faint: #8b7b62; - --parch: #ede4d0; - --parch-hover: #d4c8a8; - --parch-text: #2a2015; - --parch-text-dim: #5a5040; + /* Parch family intentionally stays pinned to light-mode values — + inverted blocks are a consistent zine/terminal inset in both themes. + See: --parch-accent and --parch-border for on-parch accents/borders. */ --c-community: #a06850; --c-founder: #c06030; --c-practitioner: #4a7080; diff --git a/app/components/OnboardingWidget.vue b/app/components/OnboardingWidget.vue index 4d73b61..2daf60a 100644 --- a/app/components/OnboardingWidget.vue +++ b/app/components/OnboardingWidget.vue @@ -1,92 +1,61 @@ From 2ae27d6dda7d776e617abd153aa8172422860cb1 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:10:50 +0100 Subject: [PATCH 078/285] feat(helcim): add cadence-keyed plan id runtime config --- .env.example | 2 ++ nuxt.config.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.env.example b/.env.example index 2fefd06..8a6cadf 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ MONGODB_URI=mongodb://localhost:27017/ghostguild # HELCIM_API_TOKEN=your-live-helcim-api-token HELCIM_API_TOKEN=your-test-helcim-api-token NUXT_PUBLIC_HELCIM_ACCOUNT_ID=your-helcim-account-id +NUXT_HELCIM_MONTHLY_PLAN_ID= +NUXT_HELCIM_ANNUAL_PLAN_ID= # Email Configuration (Resend) RESEND_API_KEY=your-resend-api-key diff --git a/nuxt.config.ts b/nuxt.config.ts index b3190cf..d04c3f7 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -104,6 +104,8 @@ export default defineNuxtConfig({ oidcClientSecret: process.env.OIDC_CLIENT_SECRET || "", oidcCookieSecret: process.env.OIDC_COOKIE_SECRET || "", outlineApiKey: process.env.OUTLINE_API_KEY || "", + helcimMonthlyPlanId: process.env.NUXT_HELCIM_MONTHLY_PLAN_ID || "", + helcimAnnualPlanId: process.env.NUXT_HELCIM_ANNUAL_PLAN_ID || "", // Public keys (available on client-side) public: { From de4bfdcc166ed4d03d4f6d2a7de43f0e5e19ab99 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:12:45 +0100 Subject: [PATCH 079/285] feat(member): add billingCadence field to schema --- server/models/member.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/models/member.js b/server/models/member.js index f34abd6..4ba353e 100644 --- a/server/models/member.js +++ b/server/models/member.js @@ -40,6 +40,11 @@ const memberSchema = new mongoose.Schema({ }, helcimCustomerId: String, helcimSubscriptionId: String, + billingCadence: { + type: String, + enum: ['monthly', 'annual'], + default: 'monthly', + }, paymentMethod: { type: String, enum: ["card", "bank", "none"], From 47f2d666dd32040323f9d881eafc0941e4766238 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:14:24 +0100 Subject: [PATCH 080/285] fix(helcim): use Number(id) in wrapped PATCH /subscriptions body --- server/utils/helcim.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/helcim.js b/server/utils/helcim.js index 6cb068f..a0a28c8 100644 --- a/server/utils/helcim.js +++ b/server/utils/helcim.js @@ -99,7 +99,7 @@ export const cancelHelcimSubscription = (id) => export const updateHelcimSubscription = (id, payload) => helcimFetch('/subscriptions', { method: 'PATCH', - body: { subscriptions: [{ id: String(id), ...payload }] }, + body: { subscriptions: [{ id: Number(id), ...payload }] }, errorMessage: 'Subscription update failed' }) From 35197c465b72f4c4b325961082ac494a2ee975bd Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:16:09 +0100 Subject: [PATCH 081/285] feat(schemas): accept cadence field on subscription + contribution updates --- server/utils/schemas.js | 6 +- tests/server/api/validation-phase3.test.js | 65 ++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 6e1abc4..dd5de22 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -75,7 +75,8 @@ export const helcimSubscriptionSchema = z.object({ customerId: z.union([z.string().min(1), z.number()]), contributionTier: z.enum(['0', '5', '15', '30', '50']), customerCode: z.union([z.string().min(1).max(200), z.number()]).transform(String), - cardToken: z.string().max(500).optional().nullable() + cardToken: z.string().max(500).optional().nullable(), + cadence: z.enum(['monthly', 'annual']).default('monthly') }) export const helcimUpdateBillingSchema = z.object({ @@ -135,7 +136,8 @@ export const eventPaymentSchema = z.object({ // --- Member schemas --- export const updateContributionSchema = z.object({ - contributionTier: z.enum(['0', '5', '15', '30', '50']) + contributionTier: z.enum(['0', '5', '15', '30', '50']), + cadence: z.enum(['monthly', 'annual']).default('monthly') }) export const updateCircleSchema = z.object({ diff --git a/tests/server/api/validation-phase3.test.js b/tests/server/api/validation-phase3.test.js index fd63e78..ed12cd4 100644 --- a/tests/server/api/validation-phase3.test.js +++ b/tests/server/api/validation-phase3.test.js @@ -160,6 +160,48 @@ describe('helcimSubscriptionSchema', () => { }) expect(result.success).toBe(false) }) + + it('accepts cadence: monthly', () => { + const result = helcimSubscriptionSchema.safeParse({ + customerId: '12345', + contributionTier: '15', + customerCode: 'CST123', + cadence: 'monthly' + }) + expect(result.success).toBe(true) + expect(result.data.cadence).toBe('monthly') + }) + + it('accepts cadence: annual', () => { + const result = helcimSubscriptionSchema.safeParse({ + customerId: '12345', + contributionTier: '15', + customerCode: 'CST123', + cadence: 'annual' + }) + expect(result.success).toBe(true) + expect(result.data.cadence).toBe('annual') + }) + + it('rejects cadence: weekly', () => { + const result = helcimSubscriptionSchema.safeParse({ + customerId: '12345', + contributionTier: '15', + customerCode: 'CST123', + cadence: 'weekly' + }) + expect(result.success).toBe(false) + }) + + it('defaults cadence to monthly when omitted', () => { + const result = helcimSubscriptionSchema.safeParse({ + customerId: '12345', + contributionTier: '15', + customerCode: 'CST123' + }) + expect(result.success).toBe(true) + expect(result.data.cadence).toBe('monthly') + }) }) describe('helcimUpdateBillingSchema', () => { @@ -310,6 +352,29 @@ describe('updateContributionSchema', () => { expect(result.success).toBe(true) expect(result.data).not.toHaveProperty('role') }) + + it('accepts cadence: monthly', () => { + const result = updateContributionSchema.safeParse({ contributionTier: '15', cadence: 'monthly' }) + expect(result.success).toBe(true) + expect(result.data.cadence).toBe('monthly') + }) + + it('accepts cadence: annual', () => { + const result = updateContributionSchema.safeParse({ contributionTier: '15', cadence: 'annual' }) + expect(result.success).toBe(true) + expect(result.data.cadence).toBe('annual') + }) + + it('rejects cadence: weekly', () => { + const result = updateContributionSchema.safeParse({ contributionTier: '15', cadence: 'weekly' }) + expect(result.success).toBe(false) + }) + + it('defaults cadence to monthly when omitted', () => { + const result = updateContributionSchema.safeParse({ contributionTier: '15' }) + expect(result.success).toBe(true) + expect(result.data.cadence).toBe('monthly') + }) }) // --- Series schemas --- From be0e6e769993678d7bd9a60e35c832f73c26a723 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:19:05 +0100 Subject: [PATCH 082/285] refactor(config): cadence-keyed plan id, add getTierAmount, drop per-tier helcimPlanId --- app/config/contributions.js | 98 ++++---------------- server/config/contributions.js | 105 ++++++---------------- tests/server/config/contributions.test.js | 20 +++++ 3 files changed, 64 insertions(+), 159 deletions(-) create mode 100644 tests/server/config/contributions.test.js diff --git a/app/config/contributions.js b/app/config/contributions.js index ceee0a8..985de53 100644 --- a/app/config/contributions.js +++ b/app/config/contributions.js @@ -1,82 +1,22 @@ -// Central configuration for Ghost Guild Contribution Levels and Helcim Plans +// Central configuration for Ghost Guild Contribution Levels export const CONTRIBUTION_TIERS = { - FREE: { - value: "0", - amount: 0, - label: "$0 - I need support right now", - tier: "free", - helcimPlanId: null, // No Helcim plan needed for free tier - }, - SUPPORTER: { - value: "5", - amount: 5, - label: "$5 - I can contribute", - tier: "supporter", - helcimPlanId: "supporter-monthly-5", - }, - MEMBER: { - value: "15", - amount: 15, - label: "$15 - I can sustain the community", - tier: "member", - helcimPlanId: "member-monthly-15", - }, - ADVOCATE: { - value: "30", - amount: 30, - label: "$30 - I can support others too", - tier: "advocate", - helcimPlanId: "advocate-monthly-30", - }, - CHAMPION: { - value: "50", - amount: 50, - label: "$50 - I want to sponsor multiple members", - tier: "champion", - helcimPlanId: "champion-monthly-50", - }, -}; + FREE: { value: "0", amount: 0, label: "$0 - I need support right now", tier: "free" }, + SUPPORTER: { value: "5", amount: 5, label: "$5 - I can contribute", tier: "supporter" }, + MEMBER: { value: "15", amount: 15, label: "$15 - I can sustain the community", tier: "member" }, + ADVOCATE: { value: "30", amount: 30, label: "$30 - I can support others too", tier: "advocate" }, + CHAMPION: { value: "50", amount: 50, label: "$50 - I want to sponsor multiple members", tier: "champion" }, +} -// Get all contribution options as an array (useful for forms) -export const getContributionOptions = () => { - return Object.values(CONTRIBUTION_TIERS); -}; +export const getContributionOptions = () => Object.values(CONTRIBUTION_TIERS) +export const getValidContributionValues = () => Object.values(CONTRIBUTION_TIERS).map(t => t.value) +export const getContributionTierByValue = (value) => + Object.values(CONTRIBUTION_TIERS).find(t => t.value === value) +export const requiresPayment = (value) => (getContributionTierByValue(value)?.amount ?? 0) > 0 +export const isValidContributionValue = (value) => getValidContributionValues().includes(value) +export const getPaidContributionTiers = () => + Object.values(CONTRIBUTION_TIERS).filter(t => t.amount > 0) -// Get valid contribution values for validation -export const getValidContributionValues = () => { - return Object.values(CONTRIBUTION_TIERS).map((tier) => tier.value); -}; - -// Get contribution tier by value -export const getContributionTierByValue = (value) => { - return Object.values(CONTRIBUTION_TIERS).find((tier) => tier.value === value); -}; - -// Get Helcim plan ID for a contribution tier -export const getHelcimPlanId = (contributionValue) => { - const tier = getContributionTierByValue(contributionValue); - return tier?.helcimPlanId || null; -}; - -// Check if a contribution tier requires payment -export const requiresPayment = (contributionValue) => { - const tier = getContributionTierByValue(contributionValue); - return tier?.amount > 0; -}; - -// Check if a contribution value is valid -export const isValidContributionValue = (value) => { - return getValidContributionValues().includes(value); -}; - -// Get contribution tier by Helcim plan ID -export const getContributionTierByHelcimPlan = (helcimPlanId) => { - return Object.values(CONTRIBUTION_TIERS).find( - (tier) => tier.helcimPlanId === helcimPlanId, - ); -}; - -// Get paid tiers only (excluding free tier) -export const getPaidContributionTiers = () => { - return Object.values(CONTRIBUTION_TIERS).filter((tier) => tier.amount > 0); -}; +export function getTierAmount(tier, cadence = 'monthly') { + const base = parseFloat(tier.amount) + return cadence === 'annual' ? base * 10 : base +} diff --git a/server/config/contributions.js b/server/config/contributions.js index 0802bf0..d871bd5 100644 --- a/server/config/contributions.js +++ b/server/config/contributions.js @@ -1,85 +1,30 @@ // Server-side contribution config -// Copy of the client-side config for server use - -// Central configuration for Ghost Guild Contribution Levels and Helcim Plans export const CONTRIBUTION_TIERS = { - FREE: { - value: "0", - amount: 0, - label: "$0 - I need support right now", - tier: "free", - helcimPlanId: null, // No Helcim plan needed for free tier - }, - SUPPORTER: { - value: "5", - amount: 5, - label: "$5 - I can contribute", - tier: "supporter", - helcimPlanId: 20162, - }, - MEMBER: { - value: "15", - amount: 15, - label: "$15 - I can sustain the community", - tier: "member", - helcimPlanId: 21596, - }, - ADVOCATE: { - value: "30", - amount: 30, - label: "$30 - I can support others too", - tier: "advocate", - helcimPlanId: 21597, - }, - CHAMPION: { - value: "50", - amount: 50, - label: "$50 - I want to sponsor multiple members", - tier: "champion", - helcimPlanId: 21598, - }, -}; + FREE: { value: "0", amount: 0, label: "$0 - I need support right now", tier: "free" }, + SUPPORTER: { value: "5", amount: 5, label: "$5 - I can contribute", tier: "supporter" }, + MEMBER: { value: "15", amount: 15, label: "$15 - I can sustain the community", tier: "member" }, + ADVOCATE: { value: "30", amount: 30, label: "$30 - I can support others too", tier: "advocate" }, + CHAMPION: { value: "50", amount: 50, label: "$50 - I want to sponsor multiple members", tier: "champion" }, +} -// Get all contribution options as an array (useful for forms) -export const getContributionOptions = () => { - return Object.values(CONTRIBUTION_TIERS); -}; +export const getContributionOptions = () => Object.values(CONTRIBUTION_TIERS) +export const getValidContributionValues = () => Object.values(CONTRIBUTION_TIERS).map(t => t.value) +export const getContributionTierByValue = (value) => + Object.values(CONTRIBUTION_TIERS).find(t => t.value === value) +export const requiresPayment = (value) => (getContributionTierByValue(value)?.amount ?? 0) > 0 +export const isValidContributionValue = (value) => getValidContributionValues().includes(value) +export const getPaidContributionTiers = () => + Object.values(CONTRIBUTION_TIERS).filter(t => t.amount > 0) -// Get valid contribution values for validation -export const getValidContributionValues = () => { - return Object.values(CONTRIBUTION_TIERS).map((tier) => tier.value); -}; +export function getTierAmount(tier, cadence = 'monthly') { + const base = parseFloat(tier.amount) + return cadence === 'annual' ? base * 10 : base +} -// Get contribution tier by value -export const getContributionTierByValue = (value) => { - return Object.values(CONTRIBUTION_TIERS).find((tier) => tier.value === value); -}; - -// Get Helcim plan ID for a contribution tier -export const getHelcimPlanId = (contributionValue) => { - const tier = getContributionTierByValue(contributionValue); - return tier?.helcimPlanId || null; -}; - -// Check if a contribution tier requires payment -export const requiresPayment = (contributionValue) => { - const tier = getContributionTierByValue(contributionValue); - return tier?.amount > 0; -}; - -// Check if a contribution value is valid -export const isValidContributionValue = (value) => { - return getValidContributionValues().includes(value); -}; - -// Get contribution tier by Helcim plan ID -export const getContributionTierByHelcimPlan = (helcimPlanId) => { - return Object.values(CONTRIBUTION_TIERS).find( - (tier) => tier.helcimPlanId === helcimPlanId, - ); -}; - -// Get paid tiers only (excluding free tier) -export const getPaidContributionTiers = () => { - return Object.values(CONTRIBUTION_TIERS).filter((tier) => tier.amount > 0); -}; +// Cadence-keyed plan-id lookup. Throws via createError at call site if env missing; +// this helper just returns the raw runtime-config value. +export function getHelcimPlanId(cadence) { + const config = useRuntimeConfig() + if (cadence === 'annual') return config.helcimAnnualPlanId || null + return config.helcimMonthlyPlanId || null +} diff --git a/tests/server/config/contributions.test.js b/tests/server/config/contributions.test.js new file mode 100644 index 0000000..b0e916b --- /dev/null +++ b/tests/server/config/contributions.test.js @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest' +import { getTierAmount } from '../../../server/config/contributions.js' + +describe('getTierAmount', () => { + it('returns monthly amount as-is', () => { + expect(getTierAmount({ amount: 5 }, 'monthly')).toBe(5) + }) + + it('returns annual amount as base * 10', () => { + expect(getTierAmount({ amount: 5 }, 'annual')).toBe(50) + }) + + it('returns annual amount for $50 tier', () => { + expect(getTierAmount({ amount: 50 }, 'annual')).toBe(500) + }) + + it('defaults cadence to monthly', () => { + expect(getTierAmount({ amount: 15 })).toBe(15) + }) +}) From 8d43804c7f04a824fb4282525afe9edd43bab44a Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:25:14 +0100 Subject: [PATCH 083/285] feat(helcim): create subscription by cadence with recurringAmount Replace tier-based plan lookup with cadence-keyed lookup, compute recurringAmount via getTierAmount, persist billingCadence on member. Delete both manual-fallback blocks; Helcim failure now surfaces as 500. --- server/api/helcim/subscription.post.js | 173 +++++----------- tests/server/api/helcim-subscription.test.js | 198 ++++++++++++++++--- 2 files changed, 218 insertions(+), 153 deletions(-) diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js index 76f9faf..5de619d 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -1,5 +1,5 @@ // Create a Helcim subscription -import { getHelcimPlanId, requiresPayment, getContributionTierByValue } from '../../config/contributions.js' +import { getHelcimPlanId, requiresPayment, getContributionTierByValue, getTierAmount } from '../../config/contributions.js' import Member from '../../models/member.js' import { connectDB } from '../../utils/mongoose.js' import { getSlackService } from '../../utils/slack.ts' @@ -124,9 +124,6 @@ export default defineEventHandler(async (event) => { } } - // Get the Helcim plan ID - const planId = getHelcimPlanId(body.contributionTier) - // Validate card token is provided if (!body.cardToken) { throw createError({ @@ -135,142 +132,70 @@ export default defineEventHandler(async (event) => { }) } - // Check if we have a configured plan for this tier - if (!planId) { - const member = await Member.findOneAndUpdate( - { helcimCustomerId: body.customerId }, - { - status: 'active', - contributionTier: body.contributionTier, - subscriptionStartDate: new Date(), - paymentMethod: 'card', - cardToken: body.cardToken, - notes: `Payment successful but no Helcim plan configured for tier ${body.contributionTier}` - }, - { new: true } - ) + const tierInfo = getContributionTierByValue(body.contributionTier) + const cadence = body.cadence + const paymentPlanId = getHelcimPlanId(cadence) - await inviteToSlack(member) - if (isFirstActivation) await sendWelcomeEmail(member) - - return { - success: true, - subscription: { - subscriptionId: 'manual-' + Date.now(), - status: 'needs_plan_setup', - nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) - }, - member: { - id: member._id, - email: member.email, - name: member.name, - circle: member.circle, - contributionTier: member.contributionTier, - status: member.status - }, - warning: `Payment successful but recurring plan needs to be set up in Helcim for the ${body.contributionTier} tier` - } + if (!paymentPlanId) { + throw createError({ + statusCode: 500, + statusMessage: cadence === 'annual' + ? 'Annual plan id not configured' + : 'Monthly plan id not configured', + }) } - // Try to create subscription in Helcim const idempotencyKey = generateIdempotencyKey() - // Get contribution tier details to set recurring amount - const tierInfo = getContributionTierByValue(body.contributionTier) - const subscriptionPayload = { - dateActivated: new Date().toISOString().split('T')[0], // Today in YYYY-MM-DD format - paymentPlanId: parseInt(planId), + dateActivated: new Date().toISOString().split('T')[0], + paymentPlanId: parseInt(paymentPlanId), customerCode: body.customerCode, - recurringAmount: parseFloat(tierInfo.amount), - paymentMethod: 'card' + recurringAmount: getTierAmount(tierInfo, cadence), + paymentMethod: 'card', } - try { - const subscriptionData = await createHelcimSubscription(subscriptionPayload, idempotencyKey) + const subscriptionData = await createHelcimSubscription(subscriptionPayload, idempotencyKey) - // Extract the first subscription from the response array - const subscription = subscriptionData.data?.[0] - if (!subscription) { - throw new Error('No subscription returned in response') - } + // Extract the first subscription from the response array + const subscription = subscriptionData.data?.[0] + if (!subscription) { + throw createError({ statusCode: 500, statusMessage: 'Subscription creation failed' }) + } - // Update member in database - const member = await Member.findOneAndUpdate( - { helcimCustomerId: body.customerId }, - { - status: 'active', + // Update member in database + const member = await Member.findOneAndUpdate( + { helcimCustomerId: body.customerId }, + { $set: { contributionTier: body.contributionTier, helcimSubscriptionId: subscription.id, - subscriptionStartDate: new Date(), - paymentMethod: 'card' - }, - { new: true } - ) - - logActivity(member._id, 'subscription_created', { tier: body.contributionTier }) - - await inviteToSlack(member) - if (isFirstActivation) await sendWelcomeEmail(member) - - return { - success: true, - subscription: { - subscriptionId: subscription.id, - status: subscription.status, - nextBillingDate: subscription.nextBillingDate - }, - member: { - id: member._id, - email: member.email, - name: member.name, - circle: member.circle, - contributionTier: member.contributionTier, - status: member.status - } - } - } catch (helcimError) { - // The helper throws createError on non-OK responses (statusCode = upstream HTTP status) - // and lets network errors propagate. We treat 400/404 from upstream AND any network - // error as the "manual setup needed" fallback. Re-throw other upstream errors (e.g. 5xx). - if (helcimError.statusCode && helcimError.statusCode !== 400 && helcimError.statusCode !== 404) { - throw helcimError - } - console.error('Error during subscription creation:', helcimError) - - // Still mark member as active since payment was successful - const member = await Member.findOneAndUpdate( - { helcimCustomerId: body.customerId }, - { - status: 'active', - contributionTier: body.contributionTier, - subscriptionStartDate: new Date(), + helcimCustomerId: body.customerId, paymentMethod: 'card', - cardToken: body.cardToken, - notes: `Payment successful but subscription creation failed: ${helcimError.message || 'unknown error'}` - }, - { new: true } - ) + billingCadence: cadence, + status: 'active', + } }, + { new: true, runValidators: false } + ) - await inviteToSlack(member) - if (isFirstActivation) await sendWelcomeEmail(member) + logActivity(member._id, 'subscription_created', { tier: body.contributionTier }) - return { - success: true, - subscription: { - subscriptionId: 'manual-' + Date.now(), - status: 'needs_setup', - nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) - }, - member: { - id: member._id, - email: member.email, - name: member.name, - circle: member.circle, - contributionTier: member.contributionTier, - status: member.status - }, - warning: 'Payment successful but recurring subscription needs manual setup' + await inviteToSlack(member) + if (isFirstActivation) await sendWelcomeEmail(member) + + return { + success: true, + subscription: { + subscriptionId: subscription.id, + status: subscription.status, + nextBillingDate: subscription.nextBillingDate + }, + member: { + id: member._id, + email: member.email, + name: member.name, + circle: member.circle, + contributionTier: member.contributionTier, + status: member.status } } } catch (error) { diff --git a/tests/server/api/helcim-subscription.test.js b/tests/server/api/helcim-subscription.test.js index ee4577c..c41b881 100644 --- a/tests/server/api/helcim-subscription.test.js +++ b/tests/server/api/helcim-subscription.test.js @@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import Member from '../../../server/models/member.js' import { requireAuth } from '../../../server/utils/auth.js' -import { requiresPayment, getHelcimPlanId, getContributionTierByValue } from '../../../server/config/contributions.js' +import { requiresPayment, getHelcimPlanId, getContributionTierByValue, getTierAmount } from '../../../server/config/contributions.js' +import { createHelcimSubscription } from '../../../server/utils/helcim.js' import subscriptionHandler from '../../../server/api/helcim/subscription.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' @@ -17,25 +18,25 @@ vi.mock('../../../server/utils/slack.ts', () => ({ vi.mock('../../../server/config/contributions.js', () => ({ requiresPayment: vi.fn(), getHelcimPlanId: vi.fn(), - getContributionTierByValue: vi.fn() + getContributionTierByValue: vi.fn(), + getTierAmount: vi.fn(), })) vi.mock('../../../server/utils/resend.js', () => ({ sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true }) })) +vi.mock('../../../server/utils/helcim.js', () => ({ + createHelcimSubscription: vi.fn(), + generateIdempotencyKey: vi.fn().mockReturnValue('idem-key-123'), +})) // helcimSubscriptionSchema is a Nitro auto-import used by validateBody vi.stubGlobal('helcimSubscriptionSchema', {}) describe('helcim subscription endpoint', () => { - const savedFetch = globalThis.fetch - beforeEach(() => { vi.clearAllMocks() - }) - - afterEach(() => { - // Restore fetch in case a test stubbed it - globalThis.fetch = savedFetch + // Default: first activation from pending_payment + Member.findOne.mockResolvedValue({ status: 'pending_payment' }) }) it('requires auth', async () => { @@ -95,17 +96,18 @@ describe('helcim subscription endpoint', () => { expect.objectContaining({ status: 'active', contributionTier: '0' }), { new: true } ) + expect(createHelcimSubscription).not.toHaveBeenCalled() }) it('paid tier without cardToken returns 400', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) - getHelcimPlanId.mockReturnValue('plan-123') + getHelcimPlanId.mockReturnValue('99999') const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', - body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1' } + body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1', cadence: 'monthly' } }) await expect(subscriptionHandler(event)).rejects.toMatchObject({ @@ -114,11 +116,12 @@ describe('helcim subscription endpoint', () => { }) }) - it('Helcim API failure still activates member', async () => { + it('monthly $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) - getHelcimPlanId.mockReturnValue('plan-123') + getHelcimPlanId.mockReturnValue('99999') getContributionTierByValue.mockReturnValue({ amount: '15' }) + getTierAmount.mockReturnValue(15) const mockMember = { _id: 'member-2', @@ -127,11 +130,156 @@ describe('helcim subscription endpoint', () => { circle: 'founder', contributionTier: '15', status: 'active', - save: vi.fn() } Member.findOneAndUpdate.mockResolvedValue(mockMember) + createHelcimSubscription.mockResolvedValue({ + data: [{ id: 'sub-monthly-1', status: 'active', nextBillingDate: '2026-05-18' }] + }) - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))) + const event = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly' } + }) + + const result = await subscriptionHandler(event) + + expect(result.success).toBe(true) + expect(createHelcimSubscription).toHaveBeenCalledWith( + expect.objectContaining({ paymentPlanId: 99999, recurringAmount: 15 }), + 'idem-key-123' + ) + expect(Member.findOneAndUpdate).toHaveBeenCalledWith( + { helcimCustomerId: 'cust-1' }, + { $set: expect.objectContaining({ billingCadence: 'monthly', contributionTier: '15', status: 'active' }) }, + { new: true, runValidators: false } + ) + }) + + it('annual $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => { + requireAuth.mockResolvedValue(undefined) + requiresPayment.mockReturnValue(true) + getHelcimPlanId.mockReturnValue('88888') + getContributionTierByValue.mockReturnValue({ amount: '15' }) + getTierAmount.mockReturnValue(150) + + const mockMember = { + _id: 'member-3', + email: 'annual@example.com', + name: 'Annual User', + circle: 'founder', + contributionTier: '15', + status: 'active', + } + Member.findOneAndUpdate.mockResolvedValue(mockMember) + createHelcimSubscription.mockResolvedValue({ + data: [{ id: 'sub-annual-1', status: 'active', nextBillingDate: '2027-04-18' }] + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1', cardToken: 'tok-123', cadence: 'annual' } + }) + + const result = await subscriptionHandler(event) + + expect(result.success).toBe(true) + expect(createHelcimSubscription).toHaveBeenCalledWith( + expect.objectContaining({ paymentPlanId: 88888, recurringAmount: 150 }), + 'idem-key-123' + ) + expect(Member.findOneAndUpdate).toHaveBeenCalledWith( + { helcimCustomerId: 'cust-1' }, + { $set: expect.objectContaining({ billingCadence: 'annual', contributionTier: '15', status: 'active' }) }, + { new: true, runValidators: false } + ) + }) + + it('annual $50 tier recurringAmount is 500', async () => { + requireAuth.mockResolvedValue(undefined) + requiresPayment.mockReturnValue(true) + getHelcimPlanId.mockReturnValue('88888') + getContributionTierByValue.mockReturnValue({ amount: '50' }) + getTierAmount.mockReturnValue(500) + + const mockMember = { + _id: 'member-4', + email: 'top@example.com', + name: 'Top Tier', + circle: 'practitioner', + contributionTier: '50', + status: 'active', + } + Member.findOneAndUpdate.mockResolvedValue(mockMember) + createHelcimSubscription.mockResolvedValue({ + data: [{ id: 'sub-annual-50', status: 'active', nextBillingDate: '2027-04-18' }] + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + body: { customerId: 'cust-2', contributionTier: '50', customerCode: 'code-2', cardToken: 'tok-456', cadence: 'annual' } + }) + + await subscriptionHandler(event) + + expect(createHelcimSubscription).toHaveBeenCalledWith( + expect.objectContaining({ paymentPlanId: 88888, recurringAmount: 500 }), + 'idem-key-123' + ) + }) + + it('missing monthly plan id returns 500 with message, no Helcim call, no Mongo write', async () => { + requireAuth.mockResolvedValue(undefined) + requiresPayment.mockReturnValue(true) + getHelcimPlanId.mockReturnValue(null) + getContributionTierByValue.mockReturnValue({ amount: '15' }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly' } + }) + + await expect(subscriptionHandler(event)).rejects.toMatchObject({ + statusCode: 500, + statusMessage: 'Monthly plan id not configured', + }) + + expect(createHelcimSubscription).not.toHaveBeenCalled() + expect(Member.findOneAndUpdate).not.toHaveBeenCalled() + }) + + it('missing annual plan id returns 500 with message, no Helcim call, no Mongo write', async () => { + requireAuth.mockResolvedValue(undefined) + requiresPayment.mockReturnValue(true) + getHelcimPlanId.mockReturnValue(null) + getContributionTierByValue.mockReturnValue({ amount: '15' }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1', cardToken: 'tok-123', cadence: 'annual' } + }) + + await expect(subscriptionHandler(event)).rejects.toMatchObject({ + statusCode: 500, + statusMessage: 'Annual plan id not configured', + }) + + expect(createHelcimSubscription).not.toHaveBeenCalled() + expect(Member.findOneAndUpdate).not.toHaveBeenCalled() + }) + + it('Helcim API failure returns 500 and does NOT activate member', async () => { + requireAuth.mockResolvedValue(undefined) + requiresPayment.mockReturnValue(true) + getHelcimPlanId.mockReturnValue('99999') + getContributionTierByValue.mockReturnValue({ amount: '15' }) + getTierAmount.mockReturnValue(15) + + createHelcimSubscription.mockRejectedValue(new Error('Network error')) const event = createMockEvent({ method: 'POST', @@ -140,23 +288,15 @@ describe('helcim subscription endpoint', () => { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1', - cardToken: 'tok-123' + cardToken: 'tok-123', + cadence: 'monthly', } }) - const result = await subscriptionHandler(event) + await expect(subscriptionHandler(event)).rejects.toMatchObject({ + statusCode: 500, + }) - expect(result.success).toBe(true) - expect(result.warning).toBeTruthy() - expect(result.member.status).toBe('active') - expect(Member.findOneAndUpdate).toHaveBeenCalledWith( - { helcimCustomerId: 'cust-1' }, - expect.objectContaining({ status: 'active', contributionTier: '15' }), - { new: true } - ) - - vi.unstubAllGlobals() - // Re-stub the schema global after unstubAllGlobals - vi.stubGlobal('helcimSubscriptionSchema', {}) + expect(Member.findOneAndUpdate).not.toHaveBeenCalled() }) }) From 4b5ea9bbd83b3013172b2f0acc88dfe41d75fcc7 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:28:05 +0100 Subject: [PATCH 084/285] fix(helcim): restore subscriptionStartDate on paid-tier activation --- server/api/helcim/subscription.post.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js index 5de619d..7d9c015 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -172,6 +172,7 @@ export default defineEventHandler(async (event) => { helcimCustomerId: body.customerId, paymentMethod: 'card', billingCadence: cadence, + subscriptionStartDate: new Date(), status: 'active', } }, { new: true, runValidators: false } From e8c81cf062b42a95347e286661321a5131e3d2fe Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:32:22 +0100 Subject: [PATCH 085/285] feat(contribution): paid-to-paid tier swap via recurringAmount PATCH --- .../api/members/update-contribution.post.js | 35 ++- tests/server/api/update-contribution.test.js | 207 ++++++++++++++++++ 2 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 tests/server/api/update-contribution.test.js diff --git a/server/api/members/update-contribution.post.js b/server/api/members/update-contribution.post.js index fdd0bec..57f7fdc 100644 --- a/server/api/members/update-contribution.post.js +++ b/server/api/members/update-contribution.post.js @@ -3,6 +3,7 @@ import { getHelcimPlanId, requiresPayment, getContributionTierByValue, + getTierAmount, } from "../../config/contributions.js"; import { connectDB } from "../../utils/mongoose.js"; import Member from "../../models/member.js"; @@ -158,51 +159,49 @@ export default defineEventHandler(async (event) => { // Case 3: Moving between paid tiers if (oldRequiresPayment && newRequiresPayment) { - const newPlanId = getHelcimPlanId(newTier); - - if (!newPlanId) { + if (!member.helcimSubscriptionId) { throw createError({ statusCode: 400, - statusMessage: `Plan not configured for tier ${newTier}`, + statusMessage: "Payment information required. You'll be redirected to complete payment setup.", + data: { requiresPaymentSetup: true }, }); } - if (!member.helcimSubscriptionId) { - // No subscription exists - they need to go through payment flow + const memberCadence = member.billingCadence || 'monthly'; + if (body.cadence && body.cadence !== memberCadence) { throw createError({ statusCode: 400, - statusMessage: - "Payment information required. You'll be redirected to complete payment setup.", - data: { requiresPaymentSetup: true }, + statusMessage: 'Cadence switch not supported on existing subscription', }); } + const newTierInfo = getContributionTierByValue(newTier); + if (!newTierInfo) { + throw createError({ statusCode: 400, statusMessage: 'Invalid tier' }); + } + try { const subscriptionData = await updateHelcimSubscription( member.helcimSubscriptionId, - { paymentPlanId: parseInt(newPlanId) }, + { recurringAmount: getTierAmount(newTierInfo, memberCadence) } ); - // Update member record await Member.findByIdAndUpdate( member._id, { $set: { contributionTier: newTier } }, { runValidators: false } ); - logContributionChange() + logContributionChange(); return { success: true, - message: "Successfully updated contribution level", + message: 'Successfully updated contribution level', subscription: subscriptionData, }; } catch (updateError) { - console.error("Error updating Helcim subscription:", updateError); - throw createError({ - statusCode: 500, - statusMessage: "Subscription update failed", - }); + console.error('Error updating Helcim subscription:', updateError); + throw createError({ statusCode: 500, statusMessage: 'Subscription update failed' }); } } diff --git a/tests/server/api/update-contribution.test.js b/tests/server/api/update-contribution.test.js new file mode 100644 index 0000000..f1a4a45 --- /dev/null +++ b/tests/server/api/update-contribution.test.js @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import Member from '../../../server/models/member.js' +import { + requiresPayment, + getContributionTierByValue, + getTierAmount, +} from '../../../server/config/contributions.js' +import { updateHelcimSubscription } from '../../../server/utils/helcim.js' +import handler from '../../../server/api/members/update-contribution.post.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +vi.mock('../../../server/models/member.js', () => ({ + default: { findByIdAndUpdate: vi.fn() } +})) +vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) +vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() })) +vi.mock('../../../server/config/contributions.js', () => ({ + requiresPayment: vi.fn(), + getHelcimPlanId: vi.fn(), + getContributionTierByValue: vi.fn(), + getTierAmount: vi.fn(), +})) +vi.mock('../../../server/utils/helcim.js', () => ({ + getHelcimCustomer: vi.fn(), + listHelcimCustomerCards: vi.fn(), + createHelcimSubscription: vi.fn(), + updateHelcimSubscription: vi.fn(), + cancelHelcimSubscription: vi.fn(), + generateIdempotencyKey: vi.fn().mockReturnValue('idem-key-123'), +})) + +// Nitro auto-imports +vi.stubGlobal('updateContributionSchema', {}) + +describe('update-contribution endpoint — Case 3 (paid→paid)', () => { + beforeEach(() => { + vi.clearAllMocks() + // Both tiers require payment for all Case 3 tests + requiresPayment.mockReturnValue(true) + }) + + // Helper: set requireAuth global to resolve with the given member + function setMember(mockMember) { + globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember) + } + + it('monthly $5 → $15: calls updateHelcimSubscription with recurringAmount and updates member', async () => { + const mockMember = { + _id: 'member-1', + contributionTier: '5', + helcimSubscriptionId: 'sub-1', + billingCadence: 'monthly', + } + setMember(mockMember) + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + getTierAmount.mockReturnValue(15) + updateHelcimSubscription.mockResolvedValue({ id: 'sub-1', status: 'active' }) + Member.findByIdAndUpdate.mockResolvedValue({}) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15' }, + }) + + const result = await handler(event) + + expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-1', { recurringAmount: 15 }) + expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( + 'member-1', + { $set: { contributionTier: '15' } }, + { runValidators: false } + ) + expect(result.success).toBe(true) + expect(result.message).toBe('Successfully updated contribution level') + }) + + it('annual $5 → $15: calls updateHelcimSubscription with recurringAmount 150', async () => { + const mockMember = { + _id: 'member-2', + contributionTier: '5', + helcimSubscriptionId: 'sub-2', + billingCadence: 'annual', + } + setMember(mockMember) + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + getTierAmount.mockReturnValue(150) + updateHelcimSubscription.mockResolvedValue({ id: 'sub-2', status: 'active' }) + Member.findByIdAndUpdate.mockResolvedValue({}) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15', cadence: 'annual' }, + }) + + const result = await handler(event) + + expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-2', { recurringAmount: 150 }) + expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( + 'member-2', + { $set: { contributionTier: '15' } }, + { runValidators: false } + ) + expect(result.success).toBe(true) + }) + + it('annual $15 → $50: calls updateHelcimSubscription with recurringAmount 500', async () => { + const mockMember = { + _id: 'member-3', + contributionTier: '15', + helcimSubscriptionId: 'sub-3', + billingCadence: 'annual', + } + setMember(mockMember) + getContributionTierByValue.mockReturnValue({ value: '50', amount: '50' }) + getTierAmount.mockReturnValue(500) + updateHelcimSubscription.mockResolvedValue({ id: 'sub-3', status: 'active' }) + Member.findByIdAndUpdate.mockResolvedValue({}) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '50', cadence: 'annual' }, + }) + + await handler(event) + + expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-3', { recurringAmount: 500 }) + }) + + it('cadence mismatch: monthly member + body cadence annual → 400, no Helcim call, no DB write', async () => { + const mockMember = { + _id: 'member-4', + contributionTier: '5', + helcimSubscriptionId: 'sub-4', + billingCadence: 'monthly', + } + setMember(mockMember) + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15', cadence: 'annual' }, + }) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 400, + statusMessage: 'Cadence switch not supported on existing subscription', + }) + + expect(updateHelcimSubscription).not.toHaveBeenCalled() + expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() + }) + + it('Helcim PATCH failure → 500, member NOT updated', async () => { + const mockMember = { + _id: 'member-5', + contributionTier: '5', + helcimSubscriptionId: 'sub-5', + billingCadence: 'monthly', + } + setMember(mockMember) + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + getTierAmount.mockReturnValue(15) + updateHelcimSubscription.mockRejectedValue(new Error('Helcim 400')) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15' }, + }) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 500, + statusMessage: 'Subscription update failed', + }) + + expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() + }) + + it('no helcimSubscriptionId → 400 with requiresPaymentSetup, no Helcim call', async () => { + const mockMember = { + _id: 'member-6', + contributionTier: '5', + helcimSubscriptionId: null, + billingCadence: 'monthly', + } + setMember(mockMember) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15' }, + }) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 400, + data: { requiresPaymentSetup: true }, + }) + + expect(updateHelcimSubscription).not.toHaveBeenCalled() + expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() + }) +}) From 0eeed94772428372a504605b98b9c7d4f57ed20d Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:37:35 +0100 Subject: [PATCH 086/285] feat(contribution): free-to-paid uses cadence plan id, persists billingCadence --- .../api/members/update-contribution.post.js | 47 +++--- tests/server/api/update-contribution.test.js | 154 +++++++++++++++++- 2 files changed, 180 insertions(+), 21 deletions(-) diff --git a/server/api/members/update-contribution.post.js b/server/api/members/update-contribution.post.js index 57f7fdc..fd236f8 100644 --- a/server/api/members/update-contribution.post.js +++ b/server/api/members/update-contribution.post.js @@ -53,6 +53,20 @@ export default defineEventHandler(async (event) => { }); } + // Resolve plan id before entering the try/catch (so missing plan → 500, not swallowed 400) + const cadence = body.cadence; // defaulted to 'monthly' by Zod + const paymentPlanId = getHelcimPlanId(cadence); + if (!paymentPlanId) { + throw createError({ + statusCode: 500, + statusMessage: cadence === 'annual' + ? 'Annual plan id not configured' + : 'Monthly plan id not configured', + }); + } + + const tierInfo = getContributionTierByValue(newTier); + try { const customerData = await getHelcimCustomer(member.helcimCustomerId); const customerCode = customerData.customerCode; @@ -61,7 +75,7 @@ export default defineEventHandler(async (event) => { throw new Error("No customer code found"); } - // Check for saved cards (FIX: use the correct endpoint) + // Check for saved cards const cards = await listHelcimCustomerCards(member.helcimCustomerId); const hasCards = Array.isArray(cards) && cards.length > 0; @@ -69,27 +83,14 @@ export default defineEventHandler(async (event) => { throw new Error("No saved payment methods"); } - // Create new subscription with saved payment method - const newPlanId = getHelcimPlanId(newTier); - - if (!newPlanId) { - throw createError({ - statusCode: 400, - statusMessage: `Plan not configured for tier ${newTier}`, - }); - } - const idempotencyKey = generateIdempotencyKey(); - // Get tier amount - const tierInfo = getContributionTierByValue(newTier); - const subscriptionData = await createHelcimSubscription( { dateActivated: new Date().toISOString().split("T")[0], - paymentPlanId: parseInt(newPlanId), - customerCode: customerCode, - recurringAmount: parseFloat(tierInfo.amount), + paymentPlanId: parseInt(paymentPlanId), + customerCode, + recurringAmount: getTierAmount(tierInfo, cadence), paymentMethod: "card", }, idempotencyKey, @@ -104,11 +105,17 @@ export default defineEventHandler(async (event) => { // Update member record await Member.findByIdAndUpdate( member._id, - { $set: { contributionTier: newTier, helcimSubscriptionId: subscription.id, paymentMethod: "card", status: "active" } }, + { $set: { + contributionTier: newTier, + helcimSubscriptionId: subscription.id, + paymentMethod: "card", + status: "active", + billingCadence: cadence, + } }, { runValidators: false } ); - logContributionChange() + logContributionChange(); return { success: true, @@ -145,7 +152,7 @@ export default defineEventHandler(async (event) => { // Update member to free tier await Member.findByIdAndUpdate( member._id, - { $set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none" } }, + { $set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none", billingCadence: "monthly" } }, { runValidators: false } ); diff --git a/tests/server/api/update-contribution.test.js b/tests/server/api/update-contribution.test.js index f1a4a45..38e18c1 100644 --- a/tests/server/api/update-contribution.test.js +++ b/tests/server/api/update-contribution.test.js @@ -3,10 +3,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import Member from '../../../server/models/member.js' import { requiresPayment, + getHelcimPlanId, getContributionTierByValue, getTierAmount, } from '../../../server/config/contributions.js' -import { updateHelcimSubscription } from '../../../server/utils/helcim.js' +import { + updateHelcimSubscription, + getHelcimCustomer, + listHelcimCustomerCards, + createHelcimSubscription, + cancelHelcimSubscription, +} from '../../../server/utils/helcim.js' import handler from '../../../server/api/members/update-contribution.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' @@ -205,3 +212,148 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => { expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() }) }) + +describe('update-contribution endpoint — Case 1 (free→paid)', () => { + beforeEach(() => { + vi.clearAllMocks() + // old tier = free, new tier = paid + requiresPayment.mockImplementation((tier) => tier !== '0') + }) + + function setMember(mockMember) { + globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember) + } + + const freeMember = { + _id: 'member-c1', + contributionTier: '0', + helcimCustomerId: 'cust-1', + } + + it('monthly: calls createHelcimSubscription with monthly plan id and recurringAmount 15, persists billingCadence monthly', async () => { + setMember(freeMember) + getHelcimPlanId.mockReturnValue('111') + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + getTierAmount.mockReturnValue(15) + getHelcimCustomer.mockResolvedValue({ customerCode: 'code-1' }) + listHelcimCustomerCards.mockResolvedValue([{ id: 'card-1' }]) + createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-new', status: 'active', nextBillingDate: '2026-05-18' }] }) + Member.findByIdAndUpdate.mockResolvedValue({}) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15', cadence: 'monthly' }, + }) + + const result = await handler(event) + + expect(createHelcimSubscription).toHaveBeenCalledWith( + expect.objectContaining({ paymentPlanId: 111, recurringAmount: 15 }), + 'idem-key-123' + ) + expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( + 'member-c1', + { $set: expect.objectContaining({ billingCadence: 'monthly', contributionTier: '15', helcimSubscriptionId: 'sub-new' }) }, + { runValidators: false } + ) + expect(result.success).toBe(true) + expect(result.message).toBe('Successfully upgraded to paid tier') + }) + + it('annual: calls createHelcimSubscription with annual plan id and recurringAmount 150, persists billingCadence annual', async () => { + setMember(freeMember) + getHelcimPlanId.mockReturnValue('222') + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + getTierAmount.mockReturnValue(150) + getHelcimCustomer.mockResolvedValue({ customerCode: 'code-1' }) + listHelcimCustomerCards.mockResolvedValue([{ id: 'card-1' }]) + createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-annual', status: 'active', nextBillingDate: '2027-04-18' }] }) + Member.findByIdAndUpdate.mockResolvedValue({}) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15', cadence: 'annual' }, + }) + + const result = await handler(event) + + expect(createHelcimSubscription).toHaveBeenCalledWith( + expect.objectContaining({ paymentPlanId: 222, recurringAmount: 150 }), + 'idem-key-123' + ) + expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( + 'member-c1', + { $set: expect.objectContaining({ billingCadence: 'annual', contributionTier: '15', helcimSubscriptionId: 'sub-annual' }) }, + { runValidators: false } + ) + expect(result.success).toBe(true) + }) + + it('missing plan id env → 500, createHelcimSubscription NOT called, member NOT updated', async () => { + setMember(freeMember) + getHelcimPlanId.mockReturnValue(null) + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15', cadence: 'monthly' }, + }) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 500, + statusMessage: 'Monthly plan id not configured', + }) + + expect(createHelcimSubscription).not.toHaveBeenCalled() + expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() + }) +}) + +describe('update-contribution endpoint — Case 2 (paid→free)', () => { + beforeEach(() => { + vi.clearAllMocks() + // old tier = paid, new tier = free + requiresPayment.mockImplementation((tier) => tier !== '0') + }) + + function setMember(mockMember) { + globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember) + } + + it('cancels subscription, resets billingCadence to monthly, clears helcimSubscriptionId', async () => { + const mockMember = { + _id: 'member-c2', + contributionTier: '15', + helcimSubscriptionId: 'sub-1', + billingCadence: 'monthly', + } + setMember(mockMember) + cancelHelcimSubscription.mockResolvedValue({}) + Member.findByIdAndUpdate.mockResolvedValue({}) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '0' }, + }) + + const result = await handler(event) + + expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-1') + expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( + 'member-c2', + { $set: expect.objectContaining({ + contributionTier: '0', + helcimSubscriptionId: null, + paymentMethod: 'none', + billingCadence: 'monthly', + }) }, + { runValidators: false } + ) + expect(result.success).toBe(true) + expect(result.message).toBe('Successfully downgraded to free tier') + }) +}) From cd0d3f7167bc55b18451a93a0b057f268f9fde61 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:59:10 +0100 Subject: [PATCH 087/285] =?UTF-8?q?feat(join):=20cadence=20selector=20with?= =?UTF-8?q?=20annual=20pricing=20(monthly=C3=9710)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Radio-pair cadence selector (Monthly / Annual) added to the join form, reusing the existing .circle-radio styling. contributionItems computed reactively; all tier labels and the left-column price list update on toggle. cadence submitted with the subscription payload. payment-setup hardcoded to monthly (annual upgrades go through /join). --- app/pages/join.vue | 73 ++++++++++++++++++++++++------ app/pages/member/payment-setup.vue | 3 +- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/app/pages/join.vue b/app/pages/join.vue index fc1ae21..80bd80f 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -116,19 +116,19 @@

    Pay what you can

    • $0 I need support right now
    • -
    • $5 I can contribute
    • +
    • {{ cadence === 'annual' ? '$50/yr' : '$5/mo' }} I can contribute
    • - $15 I can sustain the community + {{ cadence === 'annual' ? '$150/yr' : '$15/mo' }} I can sustain the community (suggested)
    • -
    • $30 I can support others too
    • +
    • {{ cadence === 'annual' ? '$300/yr' : '$30/mo' }} I can support others too
    • - $50 I want to sponsor multiple + {{ cadence === 'annual' ? '$500/yr' : '$50/mo' }} I want to sponsor multiple members
    @@ -234,9 +234,39 @@
    +
    + +
    +
    + + +
    +
    + + +
    +
    +
    {{ cadence === 'annual' ? 'Annual' : 'Monthly' }} Contribution { + const isAnnual = cadence.value === "annual"; + return Object.values(CONTRIBUTION_TIERS).map((tier) => { + const base = tier.amount; + if (base === 0) return { value: tier.value, label: "$0" }; + const amt = isAnnual ? base * 10 : base; + const suffix = isAnnual ? "/yr" : "/mo"; + const hint = tier.value === "15" && !isAnnual ? " (suggested)" : ""; + return { value: tier.value, label: `$${amt}${suffix}${hint}` }; + }); +}); // Initialize composables const { @@ -585,6 +622,7 @@ const createSubscription = async (cardToken = null) => { customerId: customerId.value, customerCode: customerCode.value, contributionTier: form.contributionTier, + cadence: cadence.value, cardToken: cardToken, }, }); @@ -863,6 +901,13 @@ onUnmounted(() => { color: var(--text-faint); } +/* ---- CADENCE RADIOS ---- */ +.cadence-radios { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} + /* ---- CIRCLE RADIOS ---- */ .circle-radios { display: grid; diff --git a/app/pages/member/payment-setup.vue b/app/pages/member/payment-setup.vue index b604e17..584a5be 100644 --- a/app/pages/member/payment-setup.vue +++ b/app/pages/member/payment-setup.vue @@ -128,7 +128,8 @@ const openModal = async () => { await $fetch('/api/members/update-contribution', { method: 'POST', - body: { contributionTier: targetTier.value }, + // cadence: annual upgrades go through /join; this page is monthly-only + body: { contributionTier: targetTier.value, cadence: 'monthly' }, }); await checkMemberStatus(); From 673f881b5432797a1a79652ead938b20058578c0 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 18:02:04 +0100 Subject: [PATCH 088/285] refactor(join): use getTierAmount helper for cadence pricing --- app/pages/join.vue | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/app/pages/join.vue b/app/pages/join.vue index 80bd80f..35c4035 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -121,14 +121,14 @@

    Pay what you can

    • $0 I need support right now
    • -
    • {{ cadence === 'annual' ? '$50/yr' : '$5/mo' }} I can contribute
    • +
    • {{ formatTierAmount('5', '') }} I can contribute
    • - {{ cadence === 'annual' ? '$150/yr' : '$15/mo' }} I can sustain the community + {{ formatTierAmount('15', '') }} I can sustain the community (suggested)
    • -
    • {{ cadence === 'annual' ? '$300/yr' : '$30/mo' }} I can support others too
    • +
    • {{ formatTierAmount('30', '') }} I can support others too
    • - {{ cadence === 'annual' ? '$500/yr' : '$50/mo' }} I want to sponsor multiple + {{ formatTierAmount('50', '') }} I want to sponsor multiple members
    @@ -472,17 +472,25 @@ const contributionOptions = getContributionOptions(); // Minimal labels for the dropdown — reactive to cadence. const contributionItems = computed(() => { - const isAnnual = cadence.value === "annual"; return Object.values(CONTRIBUTION_TIERS).map((tier) => { const base = tier.amount; if (base === 0) return { value: tier.value, label: "$0" }; - const amt = isAnnual ? base * 10 : base; - const suffix = isAnnual ? "/yr" : "/mo"; - const hint = tier.value === "15" && !isAnnual ? " (suggested)" : ""; + const amt = getTierAmount(tier, cadence.value); + const suffix = cadence.value === "annual" ? "/yr" : "/mo"; + const hint = tier.value === "15" && cadence.value !== "annual" ? " (suggested)" : ""; return { value: tier.value, label: `$${amt}${suffix}${hint}` }; }); }); +// Format tier amount for display in tier list +const formatTierAmount = (value, label) => { + const tier = getContributionTierByValue(value); + if (!tier || tier.amount === 0) return "$0"; + const amt = getTierAmount(tier, cadence.value); + const suffix = cadence.value === "annual" ? "/yr" : "/mo"; + return `$${amt}${suffix}`; +}; + // Initialize composables const { initializeHelcimPay, From 748a84d001f55f35f78045199b277a28eb1f9ae7 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 18:04:54 +0100 Subject: [PATCH 089/285] chore(join): drop unused contributionOptions + formatTierAmount label param --- app/pages/join.vue | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/app/pages/join.vue b/app/pages/join.vue index 35c4035..67d919b 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -121,14 +121,14 @@

    Pay what you can

    • $0 I need support right now
    • -
    • {{ formatTierAmount('5', '') }} I can contribute
    • +
    • {{ formatTierAmount('5') }} I can contribute
    • - {{ formatTierAmount('15', '') }} I can sustain the community + {{ formatTierAmount('15') }} I can sustain the community (suggested)
    • -
    • {{ formatTierAmount('30', '') }} I can support others too
    • +
    • {{ formatTierAmount('30') }} I can support others too
    • - {{ formatTierAmount('50', '') }} I want to sponsor multiple + {{ formatTierAmount('50') }} I want to sponsor multiple members
    @@ -420,7 +420,6 @@ import { reactive, ref, computed, onMounted, onUnmounted } from "vue"; import { getCircleOptions } from "~/config/circles"; import { - getContributionOptions, requiresPayment, getContributionTierByValue, getTierAmount, @@ -467,9 +466,6 @@ const paymentToken = ref(null); // Circle options from central config const circleOptions = getCircleOptions(); -// Contribution options from central config -const contributionOptions = getContributionOptions(); - // Minimal labels for the dropdown — reactive to cadence. const contributionItems = computed(() => { return Object.values(CONTRIBUTION_TIERS).map((tier) => { @@ -482,8 +478,7 @@ const contributionItems = computed(() => { }); }); -// Format tier amount for display in tier list -const formatTierAmount = (value, label) => { +const formatTierAmount = (value) => { const tier = getContributionTierByValue(value); if (!tier || tier.amount === 0) return "$0"; const amt = getTierAmount(tier, cadence.value); From fb337a427776242bf9523e7c63006927e09edb99 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 18:08:10 +0100 Subject: [PATCH 090/285] feat(account): display cadence and annual pricing in tier selector --- app/pages/member/account.vue | 39 ++++++++++++++++++++++++++--------- server/api/auth/member.get.js | 1 + 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/app/pages/member/account.vue b/app/pages/member/account.vue index da9f1fd..0fd4c50 100644 --- a/app/pages/member/account.vue +++ b/app/pages/member/account.vue @@ -57,9 +57,7 @@
    Contribution - ${{ memberData.contributionTier || 0 }} / month + {{ currentContributionLabel }}
    Member since @@ -210,6 +208,8 @@ + + diff --git a/app/pages/accept-invite.vue b/app/pages/accept-invite.vue index d29bdbc..0e4aada 100644 --- a/app/pages/accept-invite.vue +++ b/app/pages/accept-invite.vue @@ -124,7 +124,39 @@
    - + +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    $ Pay what you can. If you can pay more, you're making room for someone who can't.

    +
    +
    +

    + You'll be charged ${{ firstCharge }} today (${{ form.contributionAmount }}/month × 12). +

    +

    + Then ${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}, until you cancel. +

    +
    +
    +
    - -
    -

    Payment Information

    -

    - You're signing up for ${{ form.contributionAmount }} CAD / month. -

    - -
    {{ errorMessage }}
    - - -

    Click "Complete Payment" below to open the secure payment modal and verify your payment method.

    -
    - -
    - - -
    -
    - - -
    -

    Welcome to Ghost Guild!

    -

    Your membership is active. Redirecting to your dashboard...

    - Go to Dashboard -
    + +
    @@ -221,6 +244,7 @@ import { definePageMeta({ layout: false }); const { checkMemberStatus } = useAuth(); +const { initializeHelcimPay, verifyPayment } = useHelcimPay(); const step = ref("verifying"); const errorMessage = ref(""); @@ -228,6 +252,10 @@ const isSubmitting = ref(false); const preRegId = ref(null); const preRegEmail = ref(""); const token = ref(""); +const cadence = ref("annual"); // 'monthly' | 'annual' + +// Flow overlay state — drives the post-submit full-viewport UI. +const flowState = ref("idle"); const form = reactive({ name: "", @@ -255,10 +283,29 @@ const needsPayment = computed(() => { const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount)); -// Helcim state for paid tiers -const memberId = ref(null); -const customerId = ref(null); -const customerCode = ref(null); +const firstCharge = computed(() => { + const amount = form.contributionAmount || 0; + return cadence.value === "annual" ? amount * 12 : amount; +}); + +const formatContributionAmount = (amount) => { + if (!amount || amount === 0) return "$0"; + const display = cadence.value === "annual" ? amount * 12 : amount; + const suffix = cadence.value === "annual" ? "/yr" : "/mo"; + return `$${display}${suffix}`; +}; + +const flowSummary = computed(() => ({ + name: form.name, + email: preRegEmail.value, + circle: form.circle, + contribution: formatContributionAmount(form.contributionAmount), +})); + +const closeFlowOverlay = () => { + flowState.value = "idle"; + errorMessage.value = ""; +}; // On mount: extract token from fragment, verify onMounted(async () => { @@ -294,9 +341,10 @@ const handleAccept = async () => { isSubmitting.value = true; errorMessage.value = ""; + flowState.value = "creating-customer"; try { - const result = await $fetch("/api/invite/accept", { + const accepted = await $fetch("/api/invite/accept", { method: "POST", body: { preRegistrationId: preRegId.value, @@ -311,90 +359,53 @@ const handleAccept = async () => { }, }); - memberId.value = result.member.id; - - if (result.requiresPayment) { - // Need to create Helcim customer + payment - await setupPayment(result.member); - } else { + if (!accepted.requiresPayment) { // Free tier — session cookie already set by accept endpoint await checkMemberStatus(); - step.value = "confirmation"; - setTimeout(() => navigateTo("/welcome"), 3000); + flowState.value = "success"; + setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500); + return; } - } catch (err) { - errorMessage.value = - err.data?.statusMessage || "Failed to accept invitation. Please try again."; - } finally { - isSubmitting.value = false; - } -}; -const setupPayment = async (member) => { - try { - // Create Helcim customer for paid tier - const customerResult = await $fetch("/api/helcim/customer", { + // Paid tier: initialize HelcimPay session, auto-open modal + flowState.value = "opening-payment"; + await initializeHelcimPay(accepted.customerId, accepted.customerCode, 0); + + const paymentResult = await verifyPayment(); + if (!paymentResult?.success) { + throw new Error("Payment was not completed."); + } + + flowState.value = "processing-payment"; + await $fetch("/api/helcim/verify-payment", { method: "POST", body: { - name: member.name, - email: member.email, - circle: member.circle, - contributionAmount: form.contributionAmount, + cardToken: paymentResult.cardToken, + customerId: accepted.customerId, }, }); - customerId.value = customerResult.customerId; - customerCode.value = customerResult.customerCode; + flowState.value = "creating-subscription"; + await $fetch("/api/helcim/subscription", { + method: "POST", + body: { + customerId: accepted.customerId, + customerCode: accepted.customerCode, + contributionAmount: form.contributionAmount, + cadence: cadence.value, + cardToken: paymentResult.cardToken, + }, + }); - // Initialize HelcimPay.js - const { initializeHelcimPay } = useHelcimPay(); - await initializeHelcimPay(customerId.value, customerCode.value, 0); - - step.value = "payment"; + await checkMemberStatus(); + flowState.value = "success"; + setTimeout(() => navigateTo("/member/dashboard?welcome=1"), 1500); } catch (err) { errorMessage.value = - err.data?.statusMessage || "Failed to set up payment. Please try again."; - } -}; - -const processPayment = async () => { - if (isSubmitting.value) return; - - isSubmitting.value = true; - errorMessage.value = ""; - - try { - const { verifyPayment } = useHelcimPay(); - const paymentResult = await verifyPayment(); - - if (paymentResult.success) { - // Verify payment on server - await $fetch("/api/helcim/verify-payment", { - method: "POST", - body: { - cardToken: paymentResult.cardToken, - customerId: customerId.value, - }, - }); - - // Create subscription - await $fetch("/api/helcim/subscription", { - method: "POST", - body: { - customerId: customerId.value, - customerCode: customerCode.value, - contributionAmount: form.contributionAmount, - cardToken: paymentResult.cardToken, - }, - }); - - await checkMemberStatus(); - step.value = "confirmation"; - setTimeout(() => navigateTo("/welcome"), 3000); - } - } catch (err) { - errorMessage.value = - err.message || "Payment verification failed. Please try again."; + err.data?.statusMessage || + err.message || + "Failed to accept invitation. Please try again."; + flowState.value = "error"; } finally { isSubmitting.value = false; } @@ -558,6 +569,26 @@ textarea.form-input { color: var(--ink-soft, currentColor); } +/* ---- BILLING SUMMARY ---- */ +.billing-summary { + padding: 12px 16px; + border: 1px dashed var(--border); + background: var(--surface); +} +.billing-summary-line { + font-size: 13px; + color: var(--text); + line-height: 1.5; + margin: 0; +} +.billing-summary-line + .billing-summary-line { + margin-top: 4px; +} +.billing-summary-line strong { + color: var(--text-bright); + font-weight: 600; +} + /* ---- CIRCLE RADIOS ---- */ .circle-radios { display: grid; @@ -565,6 +596,12 @@ textarea.form-input { gap: 8px; } +.cadence-radios { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} + .circle-radio { position: relative; } @@ -682,5 +719,9 @@ textarea.form-input { .circle-radios { grid-template-columns: 1fr; } + + .cadence-radios { + grid-template-columns: 1fr; + } } diff --git a/app/pages/join.vue b/app/pages/join.vue index 24cad1a..55c3c49 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -194,7 +194,7 @@ value="monthly" >
    @@ -206,14 +206,14 @@ value="annual" >
    $ @@ -240,6 +240,16 @@

    {{ guidanceLabel }}

    +
    +
    +

    + You'll be charged ${{ firstCharge }} today (${{ form.contributionAmount }}/month × 12). +

    +

    + Then ${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}, until you cancel. +

    +
    +
    @@ -503,23 +441,18 @@ const needsPayment = computed(() => { const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount)); -const flowStepLabel = computed(() => { - switch (flowState.value) { - case "creating-customer": - case "opening-payment": - return "Step 2 of 3 — Payment"; - case "processing-payment": - case "creating-subscription": - return "Step 2 of 3 — Finalizing"; - case "success": - return "Step 3 of 3 — Welcome"; - case "error": - return "Something went wrong"; - default: - return ""; - } +const firstCharge = computed(() => { + const amount = form.contributionAmount || 0; + return cadence.value === "annual" ? amount * 12 : amount; }); +const flowSummary = computed(() => ({ + name: form.name, + email: form.email, + circle: form.circle, + contribution: formatContributionAmount(form.contributionAmount), +})); + const handleSubmit = async () => { if (isSubmitting.value || !isFormValid.value) return; @@ -918,6 +851,26 @@ onUnmounted(() => { color: var(--ink-soft, currentColor); } +/* ---- BILLING SUMMARY ---- */ +.billing-summary { + padding: 12px 16px; + border: 1px dashed var(--border); + background: var(--surface); +} +.billing-summary-line { + font-size: 13px; + color: var(--text); + line-height: 1.5; + margin: 0; +} +.billing-summary-line + .billing-summary-line { + margin-top: 4px; +} +.billing-summary-line strong { + color: var(--text-bright); + font-weight: 600; +} + /* ---- CIRCLE RADIOS ---- */ .circle-radios { display: grid; @@ -1073,26 +1026,6 @@ onUnmounted(() => { max-width: 600px; } -/* ---- DETAILS LIST (confirmation) ---- */ -.details-list { - display: flex; - flex-direction: column; - gap: 8px; -} -.details-row { - display: flex; - justify-content: space-between; - align-items: baseline; - font-size: 13px; -} -.details-row dt { - color: var(--text-faint); -} -.details-row dd { - color: var(--text-bright); - font-weight: 500; -} - /* ---- PAYMENT INSTRUCTION ---- */ .payment-instruction { font-size: 13px; @@ -1183,48 +1116,4 @@ onUnmounted(() => { } } -.join-flow-overlay { - position: fixed; - inset: 0; - z-index: 50; - background: rgba(42, 32, 21, 0.72); /* --parch @ 72% */ - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - padding: 24px; -} - -.join-flow-card { - background: var(--bg); - border: 1px dashed var(--border); - padding: 32px; - max-width: 520px; - width: 100%; - max-height: calc(100vh - 48px); - overflow-y: auto; -} - -.join-flow-step { - font-family: var(--font-body); - font-size: 0.75rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-dim); - margin-bottom: 12px; -} - -.join-flow-heading { - font-family: var(--font-display); - font-size: 1.5rem; - color: var(--text-bright); - margin: 0 0 16px; -} - -.join-flow-body { - font-family: var(--font-body); - color: var(--text); - line-height: 1.5; - margin: 0; -} diff --git a/app/pages/member/account.vue b/app/pages/member/account.vue index cf0c3ed..5bccb66 100644 --- a/app/pages/member/account.vue +++ b/app/pages/member/account.vue @@ -72,9 +72,9 @@ - + @@ -200,11 +200,11 @@

    Cancelling closes your account and ends access to member-only - spaces, including Slack. If you're cancelling because of a + spaces, including Slack.

    @@ -242,7 +242,7 @@

    $ @@ -269,8 +269,8 @@

    {{ guidanceLabel }}

    -
    - Changes take effect on your next billing cycle +
    + {{ contributionChangeHint }}