Skip to content

Commit 910416d

Browse files
HugoGresseclaude
andauthored
Add API endpoint to export event session votes (#1675)
* Add API endpoint to export event session votes GET /events/:projectId/votes returns the per-session vote export, reusing the core transformation of scripts/exportAllEventVotes.ts (fetch the event's public sessions/speakers JSON, join each session with its sessionVotes doc, flatten votes into columns keyed by vote-item name). The CSV/file IO stays in the script; the API returns JSON rows. Auth accepts either key type, scoped to avoid cross-tenant access: - event API key (ofproj_): only its own event, - organization key (oforg_): any event in that organization. Mismatches return 404 (not 403) so event ids aren't enumerable. Tested: project key (match/mismatch), org key (in-org/other-org), missing key, and missing public data URL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address review: export votes hardening + 404 enumeration fix - Default a missing vote `plus` to 0 instead of emitting "(undefined)". - Convert fetch failures (network error, non-2xx, invalid JSON) into a 400 with a clear message instead of an opaque 500. - Fetch session vote docs concurrently (Promise.all) and pre-index vote items by id, avoiding sequential awaits and repeated .find() on large events. - Org-key path: a non-existent project and a wrong-org project now return the identical 404 "Event not found", so event ids stay non-enumerable. Tests cover the unified 404, the plus default, and the fetch-error 400. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8a9071d commit 910416d

4 files changed

Lines changed: 458 additions & 0 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2+
import { mockFirebaseAdminApp } from '../../../testUtils/firestoreMock'
3+
import { mockWhere } from 'firestore-vitest/mocks/firestore'
4+
5+
// Event (project) doc as stored in Firestore, with the public-data URL and the
6+
// vote items + a seeded sessionVotes subcollection.
7+
const seededProject = {
8+
id: 'proj_123',
9+
name: 'Test Event',
10+
owner: 'user_123',
11+
members: ['user_123'],
12+
organizationId: 'org_123',
13+
config: { jsonUrl: 'https://data.example.test/openfeedback.json' },
14+
voteItems: [{ id: 'q1', name: 'Quality' }],
15+
_collections: {
16+
sessionVotes: [
17+
{
18+
id: 'session1',
19+
// uidB has no `plus` — must default to 0, not "(undefined)".
20+
q1: {
21+
uidA: { text: 'Great talk', plus: 2 },
22+
uidB: { text: 'Loved it' },
23+
},
24+
},
25+
],
26+
},
27+
}
28+
29+
// The event's public sessions/speakers JSON (fetched from config.jsonUrl).
30+
const openFeedbackJson = {
31+
sessions: {
32+
session1: {
33+
title: 'Talk 1',
34+
speakers: ['spk1'],
35+
tags: ['frontend'],
36+
trackTitle: 'Track A',
37+
},
38+
},
39+
speakers: { spk1: { name: 'Alice' } },
40+
}
41+
42+
const stubFetch = (json: unknown = openFeedbackJson, ok = true, status = 200) =>
43+
vi.stubGlobal(
44+
'fetch',
45+
vi.fn(async () => ({ ok, status, json: async () => json }))
46+
)
47+
48+
// Stub the private-subcollection key resolution used by authenticateRequest:
49+
// collectionGroup('private').where(...).limit(1).get(), grandparent = entity.
50+
const mockKeyResolves = (
51+
parentCollection: string,
52+
doc: Record<string, unknown>
53+
) => {
54+
const entityDoc = { exists: true, id: doc.id as string, data: () => doc }
55+
const entityRef = {
56+
id: doc.id as string,
57+
parent: { id: parentCollection },
58+
get: () => entityDoc,
59+
}
60+
const integrationDoc = {
61+
ref: {
62+
id: 'integration',
63+
parent: { parent: entityRef },
64+
set: () => Promise.resolve(),
65+
},
66+
}
67+
mockWhere.mockImplementation(() => ({
68+
limit: () => ({
69+
get: () => ({ empty: false, docs: [integrationDoc] }),
70+
}),
71+
}))
72+
}
73+
74+
const mockProjectKey = (project = seededProject) =>
75+
mockKeyResolves('projects', project)
76+
const mockOrgKey = (org: Record<string, unknown> = { id: 'org_123' }) =>
77+
mockKeyResolves('organizations', org)
78+
79+
const expectedRow = {
80+
sessionId: 'session1',
81+
title: 'Talk 1',
82+
speakers: 'spk1',
83+
speakersName: 'Alice',
84+
tags: 'frontend',
85+
trackTitle: 'Track A',
86+
Quality: 'Great talk (2), Loved it (0)',
87+
}
88+
89+
describe('/events/:projectId/votes', () => {
90+
let fastify: any
91+
92+
beforeEach(async () => {
93+
mockFirebaseAdminApp({ projects: [seededProject] })
94+
stubFetch()
95+
const { createFastifyAPI } = await import('../../api')
96+
fastify = await createFastifyAPI()
97+
})
98+
99+
afterEach(async () => {
100+
if (fastify) {
101+
await fastify.close()
102+
}
103+
vi.unstubAllGlobals()
104+
vi.clearAllMocks()
105+
})
106+
107+
it('returns 401 when no API key is provided', async () => {
108+
const response = await fastify.inject({
109+
method: 'GET',
110+
url: '/events/proj_123/votes',
111+
})
112+
expect(response.statusCode).toBe(401)
113+
})
114+
115+
it('exports votes for the event matching a project key', async () => {
116+
mockProjectKey()
117+
118+
const response = await fastify.inject({
119+
method: 'GET',
120+
url: '/events/proj_123/votes',
121+
headers: { 'x-api-key': 'ofproj_test-key' },
122+
})
123+
124+
expect(response.statusCode).toBe(200)
125+
const body = JSON.parse(response.body)
126+
expect(body.projectId).toBe('proj_123')
127+
expect(body.sessionsCount).toBe(1)
128+
expect(body.sessions[0]).toEqual(expectedRow)
129+
})
130+
131+
it('rejects a project key for a different event with 404', async () => {
132+
mockProjectKey({ ...seededProject, id: 'proj_other' })
133+
134+
const response = await fastify.inject({
135+
method: 'GET',
136+
url: '/events/proj_123/votes',
137+
headers: { 'x-api-key': 'ofproj_test-key' },
138+
})
139+
140+
expect(response.statusCode).toBe(404)
141+
})
142+
143+
it('exports votes for an event in the organization (org key)', async () => {
144+
mockOrgKey({ id: 'org_123' })
145+
146+
const response = await fastify.inject({
147+
method: 'GET',
148+
url: '/events/proj_123/votes',
149+
headers: { 'x-api-key': 'oforg_test-key' },
150+
})
151+
152+
expect(response.statusCode).toBe(200)
153+
const body = JSON.parse(response.body)
154+
expect(body.sessionsCount).toBe(1)
155+
expect(body.sessions[0]).toEqual(expectedRow)
156+
})
157+
158+
it('rejects an org key for an event in another organization with 404', async () => {
159+
mockOrgKey({ id: 'org_999' })
160+
161+
const response = await fastify.inject({
162+
method: 'GET',
163+
url: '/events/proj_123/votes',
164+
headers: { 'x-api-key': 'oforg_test-key' },
165+
})
166+
167+
expect(response.statusCode).toBe(404)
168+
expect(JSON.parse(response.body).error).toBe('Event not found')
169+
})
170+
171+
it('returns the same 404 for a non-existent event (not enumerable)', async () => {
172+
mockOrgKey({ id: 'org_123' })
173+
174+
const response = await fastify.inject({
175+
method: 'GET',
176+
url: '/events/proj_missing/votes',
177+
headers: { 'x-api-key': 'oforg_test-key' },
178+
})
179+
180+
expect(response.statusCode).toBe(404)
181+
// Identical message to the wrong-org case above: ids stay opaque.
182+
expect(JSON.parse(response.body).error).toBe('Event not found')
183+
})
184+
185+
it('returns 400 when the public data URL responds with an error', async () => {
186+
stubFetch(openFeedbackJson, false, 502)
187+
mockProjectKey()
188+
189+
const response = await fastify.inject({
190+
method: 'GET',
191+
url: '/events/proj_123/votes',
192+
headers: { 'x-api-key': 'ofproj_test-key' },
193+
})
194+
195+
expect(response.statusCode).toBe(400)
196+
})
197+
198+
it('returns 400 when the event has no public data URL', async () => {
199+
const { config: _omit, ...noUrlProject } = seededProject
200+
mockProjectKey(noUrlProject as typeof seededProject)
201+
202+
const response = await fastify.inject({
203+
method: 'GET',
204+
url: '/events/proj_123/votes',
205+
headers: { 'x-api-key': 'ofproj_test-key' },
206+
})
207+
208+
expect(response.statusCode).toBe(400)
209+
})
210+
})
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { FastifyPluginAsync } from 'fastify'
2+
import { Type } from '@sinclair/typebox'
3+
import { ErrorSchema, IdSchema } from '../../schemas'
4+
import { authenticateRequest } from '../../plugins/apiKeyPlugin'
5+
import { ProjectDao } from '../../dao/ProjectDao'
6+
import { NotFoundError } from '../../others/Errors'
7+
import {
8+
buildEventVotesExport,
9+
ExportableProject,
10+
} from '../../services/exportEventVotes'
11+
12+
const ParamsSchema = Type.Object({
13+
projectId: IdSchema,
14+
})
15+
16+
const EventVotesResponseSchema = Type.Object({
17+
projectId: IdSchema,
18+
sessionsCount: Type.Integer({ minimum: 0 }),
19+
// Each row has fixed session fields plus one column per vote item, so the
20+
// shape is open (additionalProperties).
21+
sessions: Type.Array(Type.Record(Type.String(), Type.Any())),
22+
})
23+
24+
export const getEventVotesRoute: FastifyPluginAsync = async (server) => {
25+
server.get(
26+
'/:projectId/votes',
27+
{
28+
schema: {
29+
description:
30+
'Export all session votes for an event (project). ' +
31+
'Accepts an event API key (`ofproj_`) for its own event, ' +
32+
'or an organization key (`oforg_`) for any event in that ' +
33+
'organization.',
34+
tags: ['Events'],
35+
params: ParamsSchema,
36+
response: {
37+
200: EventVotesResponseSchema,
38+
400: ErrorSchema,
39+
401: ErrorSchema,
40+
404: ErrorSchema,
41+
},
42+
},
43+
preHandler: authenticateRequest,
44+
},
45+
async (request) => {
46+
const { projectId } = request.params as { projectId: string }
47+
48+
// Authorize against the authenticated key. Use 404 (not 403) on a
49+
// mismatch so we never reveal which event ids exist.
50+
let project: ExportableProject
51+
if (request.project) {
52+
if (request.project.id !== projectId) {
53+
throw new NotFoundError('Event not found')
54+
}
55+
project = request.project as unknown as ExportableProject
56+
} else if (request.organization) {
57+
// Resolve the event and confirm it belongs to this org. A
58+
// missing project and a wrong-org project must look identical
59+
// (same 404 message) so event ids stay non-enumerable.
60+
let resolved
61+
try {
62+
resolved = await ProjectDao.getProjectFromId(
63+
server.firebase,
64+
projectId
65+
)
66+
} catch (error) {
67+
if (error instanceof NotFoundError) {
68+
throw new NotFoundError('Event not found')
69+
}
70+
throw error
71+
}
72+
if (resolved.organizationId !== request.organization.id) {
73+
throw new NotFoundError('Event not found')
74+
}
75+
project = resolved as unknown as ExportableProject
76+
} else {
77+
// authenticateRequest guarantees one of the two; defensive only.
78+
throw new NotFoundError('Event not found')
79+
}
80+
81+
const sessions = await buildEventVotesExport(
82+
server.firebase,
83+
project
84+
)
85+
86+
return {
87+
projectId,
88+
sessionsCount: sessions.length,
89+
sessions,
90+
}
91+
}
92+
)
93+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { FastifyPluginAsync } from 'fastify'
22
import { getEventByApiKeyRoute } from './getByApiKey'
3+
import { getEventVotesRoute } from './getVotes'
34

45
export const eventsRoutes: FastifyPluginAsync = async (server) => {
56
await server.register(getEventByApiKeyRoute)
7+
await server.register(getEventVotesRoute)
68
}

0 commit comments

Comments
 (0)