Skip to content

Commit 9f7605a

Browse files
HugoGresseclaude
andauthored
feat: include tickets node in public static JSON (#264)
* feat: include tickets node in public static JSON Tickets are stored at events/{eventId}/tickets but were never emitted in the generated public/private static JSON. Add a TicketDao, a JsonTicket type, and wire tickets into both outputs so deployed event sites can consume them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: address review feedback on tickets static JSON - Widen TicketData date fields to allow ISO string or Timestamp - Tighten JsonTicket.currency to TicketCurrency - Convert ticket dates once via unknownToDateTime().toISO() - Add generateStaticJson tests for tickets node Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f01e15f commit 9f7605a

4 files changed

Lines changed: 177 additions & 4 deletions

File tree

functions/src/api/dao/ticketDao.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import firebase from 'firebase-admin'
2+
import { Timestamp } from 'firebase-admin/firestore'
3+
4+
export interface TicketData {
5+
id: string
6+
name: string
7+
price: number
8+
currency: string
9+
url: string
10+
ticketsCount: number
11+
available: boolean
12+
soldOut: boolean
13+
highlighted: boolean
14+
displayNewsletterRegistration: boolean
15+
startDate: Timestamp | string | null
16+
endDate: Timestamp | string | null
17+
message: string
18+
}
19+
20+
export class TicketDao {
21+
public static async getTickets(firebaseApp: firebase.app.App, eventId: string): Promise<TicketData[]> {
22+
const db = firebaseApp.firestore()
23+
const snapshot = await db.collection(`events/${eventId}/tickets`).get()
24+
return snapshot.docs.map((doc) => ({
25+
...(doc.data() as TicketData),
26+
id: doc.id,
27+
}))
28+
}
29+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, test, vi, beforeEach } from 'vitest'
2+
import { generateStaticJson } from './generateStaticJson'
3+
import { SessionDao } from '../../../dao/sessionDao'
4+
import { SpeakerDao } from '../../../dao/speakerDao'
5+
import { SponsorDao } from '../../../dao/sponsorDao'
6+
import { TeamDao } from '../../../dao/teamDao'
7+
import { FaqDao } from '../../../dao/faqDao'
8+
import { JobPostDao } from '../../../dao/jobPostDao'
9+
import { TicketDao } from '../../../dao/ticketDao'
10+
import { Event } from '../../../../types'
11+
12+
const event = {
13+
id: 'evt-1',
14+
name: 'Test Event',
15+
scheduleVisible: true,
16+
dates: { start: new Date('2026-01-01T09:00:00Z'), end: new Date('2026-01-02T18:00:00Z') },
17+
formats: [],
18+
categories: [],
19+
tracks: [],
20+
updatedAt: new Date('2026-01-01T00:00:00Z'),
21+
timezone: 'Europe/Paris',
22+
enableVoxxrin: false,
23+
} as unknown as Event
24+
25+
const firebaseApp = {} as any
26+
27+
beforeEach(() => {
28+
vi.spyOn(SessionDao, 'getSessions').mockResolvedValue([])
29+
vi.spyOn(SpeakerDao, 'getSpeakers').mockResolvedValue([])
30+
vi.spyOn(SponsorDao, 'getSponsors').mockResolvedValue([])
31+
vi.spyOn(TeamDao, 'getTeams').mockResolvedValue({ team: [], teams: [] })
32+
vi.spyOn(FaqDao, 'getFullFaqs').mockResolvedValue([])
33+
vi.spyOn(JobPostDao, 'getAllJobPosts').mockResolvedValue([])
34+
})
35+
36+
describe('generateStaticJson tickets node', () => {
37+
test('includes tickets in both public and private outputs', async () => {
38+
vi.spyOn(TicketDao, 'getTickets').mockResolvedValue([
39+
{
40+
id: 't1',
41+
name: 'Early Bird',
42+
price: 100,
43+
currency: 'EUR',
44+
url: 'https://example.com/t1',
45+
ticketsCount: 50,
46+
available: true,
47+
soldOut: false,
48+
highlighted: true,
49+
displayNewsletterRegistration: false,
50+
startDate: '2026-01-01T00:00:00.000+00:00',
51+
endDate: null,
52+
message: '',
53+
},
54+
])
55+
56+
const { outputPublic, outputPrivate } = await generateStaticJson(firebaseApp, event)
57+
58+
expect(outputPublic.tickets).toHaveLength(1)
59+
expect(outputPrivate.tickets).toHaveLength(1)
60+
const ticket = outputPublic.tickets[0]
61+
expect(ticket.id).toBe('t1')
62+
expect(ticket.currency).toBe('EUR')
63+
expect(typeof ticket.startDate).toBe('string')
64+
expect(ticket.endDate).toBeNull()
65+
})
66+
67+
test('normalizes Firestore Timestamp dates to ISO strings', async () => {
68+
vi.spyOn(TicketDao, 'getTickets').mockResolvedValue([
69+
{
70+
id: 't2',
71+
name: 'Regular',
72+
price: 200,
73+
currency: 'USD',
74+
url: 'https://example.com/t2',
75+
ticketsCount: 100,
76+
available: true,
77+
soldOut: false,
78+
highlighted: false,
79+
displayNewsletterRegistration: false,
80+
startDate: { toDate: () => new Date('2026-02-01T10:00:00Z') } as any,
81+
endDate: { toDate: () => new Date('2026-02-10T10:00:00Z') } as any,
82+
message: '',
83+
},
84+
])
85+
86+
const { outputPublic } = await generateStaticJson(firebaseApp, event)
87+
const ticket = outputPublic.tickets[0]
88+
expect(ticket.startDate).toContain('2026-02-01')
89+
expect(ticket.endDate).toContain('2026-02-10')
90+
})
91+
92+
test('emits an empty tickets array when there are no tickets', async () => {
93+
vi.spyOn(TicketDao, 'getTickets').mockResolvedValue([])
94+
95+
const { outputPublic, outputPrivate } = await generateStaticJson(firebaseApp, event)
96+
expect(outputPublic.tickets).toEqual([])
97+
expect(outputPrivate.tickets).toEqual([])
98+
})
99+
})

functions/src/api/routes/deploy/updateWebsiteActions/generateStaticJson.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,54 @@
11
import firebase from 'firebase-admin'
2-
import { Event } from '../../../../../../src/types'
2+
import { Event, TicketCurrency } from '../../../../../../src/types'
33
import { generateOpenFeedbackJson } from './generateOpenFeedbackJson'
44
import { generateVoxxrinJson } from './generateVoxxrinJson'
5-
import { JsonOutput, JsonSession, JsonSessionPrivate, JsonPublicOutput, JsonPrivateOutput } from './jsonTypes'
5+
import {
6+
JsonOutput,
7+
JsonSession,
8+
JsonSessionPrivate,
9+
JsonPublicOutput,
10+
JsonPrivateOutput,
11+
JsonTicket,
12+
} from './jsonTypes'
613
import { SessionDao } from '../../../dao/sessionDao'
714
import { SpeakerDao } from '../../../dao/speakerDao'
815
import { SponsorDao } from '../../../dao/sponsorDao'
916
import { TeamDao } from '../../../dao/teamDao'
1017
import { FaqDao } from '../../../dao/faqDao'
1118
import { JobPostDao } from '../../../dao/jobPostDao'
19+
import { TicketDao } from '../../../dao/ticketDao'
1220
import { JobStatus } from '../../../../../../src/constants/jobStatus'
13-
import { dateToString } from '../../../other/dateConverter'
21+
import { dateToString, unknownToDateTime } from '../../../other/dateConverter'
1422

1523
export const generateStaticJson = async (firebaseApp: firebase.app.App, event: Event): Promise<JsonOutput> => {
16-
const [sessions, speakers, sponsors, { team, teams }, faq, jobPosts] = await Promise.all([
24+
const [sessions, speakers, sponsors, { team, teams }, faq, jobPosts, tickets] = await Promise.all([
1725
SessionDao.getSessions(firebaseApp, event.id),
1826
SpeakerDao.getSpeakers(firebaseApp, event.id),
1927
SponsorDao.getSponsors(firebaseApp, event.id),
2028
TeamDao.getTeams(firebaseApp, event.id),
2129
FaqDao.getFullFaqs(firebaseApp, event.id),
2230
JobPostDao.getAllJobPosts(firebaseApp, event.id, JobStatus.APPROVED),
31+
TicketDao.getTickets(firebaseApp, event.id),
2332
])
2433

2534
const faqPublic = faq.filter((f) => !f.private)
2635

36+
const outputTickets: JsonTicket[] = tickets.map((t) => ({
37+
id: t.id,
38+
name: t.name,
39+
price: t.price,
40+
currency: t.currency as TicketCurrency,
41+
url: t.url,
42+
ticketsCount: t.ticketsCount,
43+
available: t.available,
44+
soldOut: t.soldOut,
45+
highlighted: t.highlighted,
46+
displayNewsletterRegistration: t.displayNewsletterRegistration,
47+
startDate: t.startDate ? unknownToDateTime(t.startDate as any).toISO() : null,
48+
endDate: t.endDate ? unknownToDateTime(t.endDate as any).toISO() : null,
49+
message: t.message,
50+
}))
51+
2752
const openFeedbackOutput = generateOpenFeedbackJson(event, sessions, speakers)
2853
const voxxrinJson = event.enableVoxxrin ? generateVoxxrinJson(event, sessions, speakers, sponsors) : null
2954

@@ -173,6 +198,7 @@ export const generateStaticJson = async (firebaseApp: firebase.app.App, event: E
173198
team,
174199
teams,
175200
faq: faqPublic,
201+
tickets: outputTickets,
176202
timezone: event.timezone,
177203
generatedAt: dateToString(new Date()),
178204
}
@@ -184,6 +210,7 @@ export const generateStaticJson = async (firebaseApp: firebase.app.App, event: E
184210
team,
185211
teams,
186212
faq,
213+
tickets: outputTickets,
187214
timezone: event.timezone,
188215
generatedAt: dateToString(new Date()),
189216
}

functions/src/api/routes/deploy/updateWebsiteActions/jsonTypes.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Social,
99
SponsorCustomField,
1010
SpeakerCustomField,
11+
TicketCurrency,
1112
} from '../../../../../../src/types'
1213

1314
export interface JsonSession {
@@ -52,6 +53,22 @@ export interface JsonSpeaker {
5253
customFields?: { [key: string]: string | boolean }
5354
}
5455

56+
export interface JsonTicket {
57+
id: string
58+
name: string
59+
price: number
60+
currency: TicketCurrency
61+
url: string
62+
ticketsCount: number
63+
available: boolean
64+
soldOut: boolean
65+
highlighted: boolean
66+
displayNewsletterRegistration: boolean
67+
startDate: string | null
68+
endDate: string | null
69+
message: string
70+
}
71+
5572
export interface JsonEvent {
5673
id: string
5774
name: string
@@ -82,6 +99,7 @@ export interface JsonPublicOutput {
8299
team: TeamMember[]
83100
teams: { id: string; members: TeamMember[]; order: number }[]
84101
faq: FaqCategory[]
102+
tickets: JsonTicket[]
85103
timezone: string | null | undefined
86104
generatedAt: string
87105
}

0 commit comments

Comments
 (0)