From fcbad24f3eab14f87b09d9593b840c8fb7b614ef Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:32:11 +0100 Subject: [PATCH] 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 + }) + }) +})