diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46fc685..6bdd04f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,11 @@ jobs: GH_CONTENT_TOKEN: ${{ secrets.GH_CONTENT_TOKEN }} GOOGLE_FORMS_API_KEY: ${{ secrets.GOOGLE_FORMS_API_KEY }} GOOGLE_FORMS_FILE_ID: ${{ secrets.GOOGLE_FORMS_FILE_ID }} + EVENTS_AIR_CLIENT_ID: ${{ secrets.EVENTS_AIR_CLIENT_ID }} + EVENTS_AIR_CLIENT_SECRET: ${{ secrets.EVENTS_AIR_CLIENT_SECRET }} + EVENTS_AIR_TENANT_ID: ${{ secrets.EVENTS_AIR_TENANT_ID }} + EVENTS_AIR_EVENT_ID: ${{ secrets.EVENTS_AIR_EVENT_ID }} + TITO_SECURITY_TOKEN: ${{ secrets.TITO_SECURITY_TOKEN }} steps: - name: Checkout diff --git a/infra/app/ddd.bicep b/infra/app/ddd.bicep index e2d25e6..8d1b5ae 100644 --- a/infra/app/ddd.bicep +++ b/infra/app/ddd.bicep @@ -17,6 +17,13 @@ param googleFormsApiKey string @secure() param googleFormsFileId string param exists bool +param eventsAirClientId string +@secure() +param eventsAirClientSecret string +param eventsAirTenantId string +param eventsAirEventId string +@secure() +param titoSecurityToken string resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { name: identityName @@ -104,6 +111,26 @@ resource app 'Microsoft.App/containerApps@2024-03-01' = { name: 'google-forms-file-id' value: googleFormsFileId } + { + name: 'events-air-client-id' + value: eventsAirClientId + } + { + name: 'events-air-client-secret' + value: eventsAirClientSecret + } + { + name: 'events-air-tenant-id' + value: eventsAirTenantId + } + { + name: 'events-air-event-id' + value: eventsAirEventId + } + { + name: 'tito-security-token' + value: titoSecurityToken + } ] } template: { @@ -144,6 +171,26 @@ resource app 'Microsoft.App/containerApps@2024-03-01' = { name: 'GOOGLE_FORMS_FILE_ID' secretRef: 'google-forms-file-id' } + { + name: 'TITO_SECURITY_TOKEN' + secretRef: 'tito-security-token' + } + { + name: 'EVENTS_AIR_CLIENT_ID' + secretRef: 'events-air-client-id' + } + { + name: 'EVENTS_AIR_CLIENT_SECRET' + secretRef: 'events-air-client-secret' + } + { + name: 'EVENTS_AIR_TENANT_ID' + secretRef: 'events-air-tenant-id' + } + { + name: 'EVENTS_AIR_EVENT_ID' + secretRef: 'events-air-event-id' + } ] resources: { diff --git a/infra/main.bicep b/infra/main.bicep index c570ae6..5483f98 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -32,6 +32,15 @@ param domainName string @minLength(1) param certificateId string +param eventsAirClientId string +@secure() +param eventsAirClientSecret string +param eventsAirTenantId string +param eventsAirEventId string +@secure() +param titoSecurityToken string + + // Tags that should be applied to all resources. // // Note that 'azd-service-name' tags should be applied separately to service host resources. @@ -125,6 +134,11 @@ module ddd './app/ddd.bicep' = { googleFormsFileId: googleFormsFileId domainName: domainName certificateId: certificateId + eventsAirClientId: eventsAirClientId + eventsAirClientSecret: eventsAirClientSecret + eventsAirTenantId: eventsAirTenantId + eventsAirEventId: eventsAirEventId + titoSecurityToken: titoSecurityToken } scope: rg } diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 0f78d65..fd2232b 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -53,6 +53,21 @@ }, "certificateId": { "value": "/subscriptions/dd079971-2023-4b56-be3a-85a346d51344/resourceGroups/dddper-rg-prd/providers/Microsoft.App/managedEnvironments/dddper-cae-ivoki745ew4qo/certificates/cloudflare" + }, + "eventsAirClientId": { + "value": "${EVENTS_AIR_CLIENT_ID}" + }, + "eventsAirClientSecret": { + "value": "${EVENTS_AIR_CLIENT_SECRET}" + }, + "eventsAirTenantId": { + "value": "${EVENTS_AIR_TENANT_ID}" + }, + "eventsAirEventId": { + "value": "${EVENTS_AIR_EVENT_ID}" + }, + "titoSecurityToken": { + "value": "${TITO_SECURITY_TOKEN}" } } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c157e57..be9bbb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,6 +279,9 @@ importers: '@opentelemetry/instrumentation-http': specifier: ^0.52.1 version: 0.52.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-undici': + specifier: ^0.7.1 + version: 0.7.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': specifier: ^1.25.1 version: 1.25.1(@opentelemetry/api@1.9.0) @@ -2432,6 +2435,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.9.0 + '@opentelemetry/instrumentation-undici@0.7.1': + resolution: {integrity: sha512-sIl4zrRDP7pR+2Pmdm9XJQULMKiUmvZze2cEW6gUz7TXCEaYmJ+vNMdd7qgeRo8C7AMm+T08mptobFVKPzdz+A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/instrumentation@0.52.1': resolution: {integrity: sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==} engines: {node: '>=14'} @@ -11567,6 +11576,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/instrumentation-undici@0.7.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + '@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 diff --git a/website/.env.example b/website/.env.example index 582c079..ec5f2ec 100644 --- a/website/.env.example +++ b/website/.env.example @@ -6,3 +6,8 @@ USE_GITHUB_CONTENT=false GITHUB_REPO=ddd-2024 GITHUB_ORGANIZATION=dddwa SESSION_SECRET=shhh +EVENTS_AIR_CLIENT_ID= +EVENTS_AIR_CLIENT_SECRET= +EVENTS_AIR_TENANT_ID= +EVENTS_AIR_EVENT_ID= +TITO_SECURITY_TOKEN= diff --git a/website/app/config/years/2024.ts b/website/app/config/years/2024.ts index 5b78db9..7f5e670 100644 --- a/website/app/config/years/2024.ts +++ b/website/app/config/years/2024.ts @@ -51,10 +51,24 @@ export const conference2024: ConferenceYear = { feedbackOpenUntilDateTime: DateTime.fromISO('2024-11-21T23:59:59', { zone: 'Australia/Perth', }), + ticketInfo: { type: 'tito', accountId: 'dddperth', eventId: '2024', + + generalTicketSlugs: [ + 'general-attendee', + 'dqvd7i58iig', + 'general-attendee-company', + 'general-attendee-free', + 'lyfer', + 'volunteer', + 'speaker', + 'sponsor', + ], + afterPartyTicketSlugs: ['after-party', 'el5pexoj6m8'], + afterPartyUpgradeActivityId: '1071952', }, sponsors: { @@ -263,4 +277,44 @@ Computing, software and big data will be critical in the success of this mega sc }, ], }, + + foodInfo: { + lunch: [ + { + meal: 'Vegetable Korma, potato, cauliflower, carrots, peas, steamed basmati rice (V DF, contains coconut milk)', + shortCode: 'VK', + foodZone: '1', + }, + { + meal: 'Slow cooked beef cheek in red wine sauce with creamy mashed potato (GF)', + shortCode: 'BR', + foodZone: '1', + }, + { + meal: 'Roast pork belly with pineapple jus, cheesy roast potatoes and mustard spring beans (GF)', + shortCode: 'PB', + foodZone: '2', + }, + { + meal: 'Classic Caesar salad, cos lettuce, parmesan cheese, egg, bacon, Caesar dressing', + shortCode: 'CS', + foodZone: '3', + }, + { + meal: 'Cashew butter chicken with basmati rice, yoghurt, and coriander (GF)', + shortCode: 'BC', + foodZone: '3', + }, + { + meal: 'Beef cheese and bacon burger served with chips', + shortCode: 'BU', + foodZone: '4', + }, + { + meal: 'Caribbean chicken salad with honey lime dressing (DF)', + shortCode: 'CC', + foodZone: '4', + }, + ], + }, } diff --git a/website/app/lib/conference-state.ts b/website/app/lib/conference-state.ts index b0b18d4..3b7ce53 100644 --- a/website/app/lib/conference-state.ts +++ b/website/app/lib/conference-state.ts @@ -25,7 +25,6 @@ export function getCurrentConferenceState( (year): year is [Year, ConferenceYear] => !('cancelledMessage' in year[1]), ) const [latestConference, previousConference] = conferenceList - .sort(([, a], [, b]) => { const dateA = a.conferenceDate?.valueOf() ?? Infinity const dateB = b.conferenceDate?.valueOf() ?? Infinity diff --git a/website/app/lib/config-types.ts b/website/app/lib/config-types.ts index f80f50a..51c12de 100644 --- a/website/app/lib/config-types.ts +++ b/website/app/lib/config-types.ts @@ -41,6 +41,10 @@ export interface TitoTicketInfo { type: 'tito' accountId: string eventId: string + + generalTicketSlugs?: string[] + afterPartyTicketSlugs?: string[] + afterPartyUpgradeActivityId?: string } export type TicketInfo = TitoTicketInfo @@ -69,6 +73,14 @@ export interface ConferenceYear { sessions: SessionizeConferenceSessions | SessionData | undefined sponsors: YearSponsors + + foodInfo?: { + lunch: Array<{ + meal: string + foodZone: string + shortCode: string + }> + } } export interface YearSponsors { diff --git a/website/app/lib/config.server.ts b/website/app/lib/config.server.ts index 0e9bdec..216ee3a 100644 --- a/website/app/lib/config.server.ts +++ b/website/app/lib/config.server.ts @@ -12,6 +12,11 @@ export const { GITHUB_REF, GITHUB_REPO, GITHUB_TOKEN, + TITO_SECURITY_TOKEN, + EVENTS_AIR_CLIENT_ID, + EVENTS_AIR_CLIENT_SECRET, + EVENTS_AIR_TENANT_ID, + EVENTS_AIR_EVENT_ID, } = z .object({ NODE_ENV: z.string(), @@ -32,5 +37,10 @@ export const { GITHUB_TOKEN: z.string().optional(), GITHUB_ORGANIZATION: z.string(), GITHUB_REPO: z.string(), + TITO_SECURITY_TOKEN: z.string().optional(), + EVENTS_AIR_CLIENT_ID: z.string().optional(), + EVENTS_AIR_CLIENT_SECRET: z.string().optional(), + EVENTS_AIR_TENANT_ID: z.string().optional(), + EVENTS_AIR_EVENT_ID: z.string().optional(), }) .parse(process.env) diff --git a/website/app/lib/events-air.server.ts b/website/app/lib/events-air.server.ts new file mode 100644 index 0000000..7781b42 --- /dev/null +++ b/website/app/lib/events-air.server.ts @@ -0,0 +1,393 @@ +import { trace } from '@opentelemetry/api' +import { EVENTS_AIR_CLIENT_ID, EVENTS_AIR_CLIENT_SECRET, EVENTS_AIR_TENANT_ID } from './config.server' + +// EventsAir contact data type +export interface EventsAirContactData { + firstName: string | null + lastName: string | null + primaryEmail: string | null + externalIdentifier: string | null + userDefinedField1?: string + userDefinedField2?: string + userDefinedField3?: string +} + +export async function getAccessToken(): Promise { + if (!EVENTS_AIR_CLIENT_ID || !EVENTS_AIR_CLIENT_SECRET) { + throw new Error('EventsAir client ID and secret are not configured') + } + + return await trace.getTracer('default').startActiveSpan('getAccessToken', async (span) => { + try { + const tokenResponse = await fetch( + `https://login.microsoftonline.com/${EVENTS_AIR_TENANT_ID}/oauth2/v2.0/token`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: EVENTS_AIR_CLIENT_ID!, + client_secret: EVENTS_AIR_CLIENT_SECRET!, + scope: 'https://eventsairprod.onmicrosoft.com/85d8f626-4e3d-4357-89c6-327d4e6d3d93/.default', + grant_type: 'client_credentials', + }).toString(), + }, + ) + + if (!tokenResponse.ok) { + throw new Error('Failed to retrieve access token for EventsAir') + } + + const { access_token } = await tokenResponse.json() + + return access_token + } finally { + span.end() + } + }) +} + +const EVENTS_AIR_API_BASE_URL = 'https://api.eventsair.com/graphql' + +export const registrationTypes = { + Volunteer: '96099FA8-43E0-447F-9FCA-820BA01283E7', + Sponsor: '44483936-B542-4996-9405-364EA3F7A6BE', + Speaker: '1EA2CB0F-7946-4267-BF36-EE8484370A48', + 'Event Management': '7E5C05AB-55AA-4A67-A3ED-7AEF2605F167', + 'Event Crew': 'AA880F4B-61AB-4016-BE21-64952C9209D8', + Attendee: '4101E07A-EC92-41F0-BDCB-A80D065A912A', +} as const + +export async function createEventsAirContact( + accessToken: string, + createContactData: EventsAirContactData & { eventId: string }, +): Promise { + return await trace.getTracer('default').startActiveSpan('createEventsAirContact', async (span) => { + try { + const mutation = ` + mutation ($input: CreateContactInput!) { + createContact(input: $input) { + contact { + id + } + } + } + ` + + const variables = { + input: createContactData, + } + + const response = await fetch(EVENTS_AIR_API_BASE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ query: mutation, variables }), + }) + + if (!response.ok) { + const span = trace.getActiveSpan() + span?.addEvent('Mutation error', { + responseBody: await response.text(), + }) + throw new Error('Failed to create contact in EventsAir') + } + + const data = await response.json() + trace.getActiveSpan()?.addEvent('Contact created', { + contactResponse: JSON.stringify(data), + }) + return data?.data?.createContact?.contact?.id + } finally { + span.end() + } + }) +} + +export interface CreateRegistrationInput { + contactId: string + eventId: string + registrationTypeId: (typeof registrationTypes)[keyof typeof registrationTypes] + dateTime: string + paymentDetails: { + adjustmentAmount: number + paymentStatus: 'INCLUSIVE' + } +} + +export async function createEventsAirRegistration( + accessToken: string, + createRegistrationInput: CreateRegistrationInput, +) { + return await trace.getTracer('default').startActiveSpan('createEventsAirRegistration', async (span) => { + try { + const mutation = ` + mutation CreateRegistration($input: CreateRegistrationInput!) { + createRegistration(input: $input) { + registration { + id + } + } + } + ` + + const variables = { + input: createRegistrationInput, + } + + const response = await fetch(EVENTS_AIR_API_BASE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ query: mutation, variables }), + }) + + if (!response.ok) { + span?.addEvent('Mutation error', { + responseBody: await response.text(), + }) + throw new Error('Failed to create registration in EventsAir') + } + + const data = await response.json() + trace.getActiveSpan()?.addEvent('Registration created', { + registrationResponse: JSON.stringify(data), + }) + return data?.data?.createContact?.contact?.id + } finally { + span.end() + } + }) +} + +export async function updateEventsAirContact( + accessToken: string, + contactData: Partial, + contactId: string, +) { + return await trace.getTracer('default').startActiveSpan('updateEventsAirContact', async (span) => { + try { + const mutation = ` + mutation ($input: UpdateContactDetailsInput!) { + updateContactDetails(input: $input) { + contact { + id + } + } + } + ` + + const variables = { + contactData: { ...contactData, contactId }, + } + + const response = await fetch(EVENTS_AIR_API_BASE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ query: mutation, variables }), + }) + + if (!response.ok) { + span?.addEvent('Mutation error', { + responseBody: await response.text(), + }) + throw new Error('Failed to update contact in EventsAir') + } + + const result = await response.json() + span.addEvent('Contact updated', { + contactResponse: JSON.stringify(result), + }) + return result.data?.updateContact + } finally { + span.end() + } + }) +} + +export async function checkIfContactExistsByExternalIdentifier( + accessToken: string, + eventId: string, + externalIdentifier: string, +): Promise<{ id: string } | null> { + return await trace + .getTracer('default') + .startActiveSpan('checkIfContactExistsByExternalIdentifier', async (span) => { + try { + const query = ` + query ($eventId: ID!, $externalIdentifier: String!) { + event(id: $eventId) { + contacts(input: { contactFilter: { externalIdentifier: $externalIdentifier } }) { + id + } + } + } + ` + + const variables = { eventId, externalIdentifier } + + const response = await fetch(EVENTS_AIR_API_BASE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ query, variables }), + }) + + if (!response.ok) { + trace.getActiveSpan()?.addEvent('Query error', { + responseBody: await response.text(), + }) + trace.getActiveSpan()?.recordException(new Error('Failed to query contact existence in EventsAir')) + throw new Error('Failed to query contact existence in EventsAir') + } + + const data = await response.json() + span.addEvent('Contact query response', { + contactResponse: JSON.stringify(data), + }) + const contact = data?.data?.event?.contacts?.[0] + return contact ? { id: contact.id } : null + } finally { + span.end() + } + }) +} + +export async function checkIfContactExistsByEmail( + accessToken: string, + eventId: string, + email: string, +): Promise<{ id: string } | null> { + return await trace.getTracer('default').startActiveSpan('checkIfContactExistsByEmail', async (span) => { + try { + const query = ` + query ($eventId: ID!, $email: String!) { + event(id: $eventId) { + contacts(where: { email: $email }) { + id + } + } + } + ` + + const variables = { eventId, email } + + const response = await fetch(EVENTS_AIR_API_BASE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ query, variables }), + }) + + if (!response.ok) { + throw new Error('Failed to query contact existence in EventsAir') + } + + const data = await response.json() + const contact = data?.data?.event?.contacts?.[0] + return contact ? { id: contact.id } : null + } finally { + span.end() + } + }) +} + +export async function getContactRegistrations( + accessToken: string, + eventId: string, + contactId: string, +): Promise<{ id: string; type: { id: string } }[]> { + return await trace.getTracer('default').startActiveSpan('getContactRegistrations', async (span) => { + try { + const query = ` + query ($eventId: ID!, $contactId: ID!) { + event(id: $eventId) { + contact(id: $contactId) { + registrations { + id + type { + id + } + } + } + } + } + ` + + const variables = { eventId, contactId } + + const response = await fetch(EVENTS_AIR_API_BASE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ query, variables }), + }) + + if (!response.ok) { + throw new Error('Failed to fetch contact registrations in EventsAir') + } + + const data = await response.json() + span.addEvent('Contact registrations fetched', { + contactRegistrations: JSON.stringify(data), + }) + return data?.data?.event?.contact?.registrations || [] + } finally { + span.end() + } + }) +} + +export async function deleteRegistration( + accessToken: string, + contactId: string, + eventId: string, + registrationId: string, +): Promise { + return await trace.getTracer('default').startActiveSpan('deleteRegistration', async (span) => { + try { + const mutation = ` + mutation DeleteRegistration($input: DeleteRegistrationInput!) { + deleteRegistration(input: $input) + } + ` + + const variables = { + input: { + contactId, + eventId, + registrationId, + }, + } + + const response = await fetch(EVENTS_AIR_API_BASE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ query: mutation, variables }), + }) + + if (!response.ok) { + throw new Error('Failed to delete registration in EventsAir') + } + } finally { + span.end() + } + }) +} diff --git a/website/app/lib/setupOpenTelemetry.tsx b/website/app/lib/setupOpenTelemetry.tsx index 2f8d3bc..5175899 100644 --- a/website/app/lib/setupOpenTelemetry.tsx +++ b/website/app/lib/setupOpenTelemetry.tsx @@ -1,7 +1,7 @@ import { - AzureMonitorLogExporter, - AzureMonitorMetricExporter, - AzureMonitorTraceExporter, + AzureMonitorLogExporter, + AzureMonitorMetricExporter, + AzureMonitorTraceExporter, } from '@azure/monitor-opentelemetry-exporter' import { createAzureSdkInstrumentation } from '@azure/opentelemetry-instrumentation-azure-sdk' import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api' @@ -11,6 +11,7 @@ import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express' import { HttpInstrumentation } from '@opentelemetry/instrumentation-http' +import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici' import { Resource } from '@opentelemetry/resources' import { BatchLogRecordProcessor, ConsoleLogRecordExporter, LoggerProvider } from '@opentelemetry/sdk-logs' import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' @@ -20,93 +21,102 @@ import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from '@opentele import fs, { existsSync } from 'node:fs' import { RemixInstrumentation } from 'opentelemetry-instrumentation-remix' import { - APPLICATIONINSIGHTS_CONNECTION_STRING, - OTEL_EXPORTER_OTLP_ENDPOINT, - OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, - OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, - OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + APPLICATIONINSIGHTS_CONNECTION_STRING, + OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, } from '../lib/config.server' export function configureOpenTelemetry() { - const enableTracingConsoleFallback = false + const enableTracingConsoleFallback = false - const traceExporter = APPLICATIONINSIGHTS_CONNECTION_STRING - ? new AzureMonitorTraceExporter({ - connectionString: APPLICATIONINSIGHTS_CONNECTION_STRING, - }) - : OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? OTEL_EXPORTER_OTLP_ENDPOINT - ? new OTLPTraceExporter() - : enableTracingConsoleFallback - ? new ConsoleSpanExporter() - : undefined + const traceExporter = APPLICATIONINSIGHTS_CONNECTION_STRING + ? new AzureMonitorTraceExporter({ + connectionString: APPLICATIONINSIGHTS_CONNECTION_STRING, + }) + : (OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? OTEL_EXPORTER_OTLP_ENDPOINT) + ? new OTLPTraceExporter() + : enableTracingConsoleFallback + ? new ConsoleSpanExporter() + : undefined - const metricExporter = APPLICATIONINSIGHTS_CONNECTION_STRING - ? new AzureMonitorMetricExporter({ - connectionString: APPLICATIONINSIGHTS_CONNECTION_STRING, - }) - : OTEL_EXPORTER_OTLP_METRICS_ENDPOINT ?? OTEL_EXPORTER_OTLP_ENDPOINT - ? new OTLPMetricExporter() - : undefined + const metricExporter = APPLICATIONINSIGHTS_CONNECTION_STRING + ? new AzureMonitorMetricExporter({ + connectionString: APPLICATIONINSIGHTS_CONNECTION_STRING, + }) + : (OTEL_EXPORTER_OTLP_METRICS_ENDPOINT ?? OTEL_EXPORTER_OTLP_ENDPOINT) + ? new OTLPMetricExporter() + : undefined - const logsExporter = APPLICATIONINSIGHTS_CONNECTION_STRING - ? new AzureMonitorLogExporter({ - connectionString: APPLICATIONINSIGHTS_CONNECTION_STRING, - }) - : OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ?? OTEL_EXPORTER_OTLP_ENDPOINT - ? new OTLPLogExporter() - : enableTracingConsoleFallback - ? new ConsoleLogRecordExporter() - : undefined + const logsExporter = APPLICATIONINSIGHTS_CONNECTION_STRING + ? new AzureMonitorLogExporter({ + connectionString: APPLICATIONINSIGHTS_CONNECTION_STRING, + }) + : (OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ?? OTEL_EXPORTER_OTLP_ENDPOINT) + ? new OTLPLogExporter() + : enableTracingConsoleFallback + ? new ConsoleLogRecordExporter() + : undefined - const metricReader = metricExporter - ? new PeriodicExportingMetricReader({ - exporter: metricExporter, - }) - : undefined + const metricReader = metricExporter + ? new PeriodicExportingMetricReader({ + exporter: metricExporter, + }) + : undefined - const loggerProvider = new LoggerProvider() - const logRecordProcessor = logsExporter ? new BatchLogRecordProcessor(logsExporter) : undefined - if (logRecordProcessor) { - loggerProvider.addLogRecordProcessor(logRecordProcessor) - } + const loggerProvider = new LoggerProvider() + const logRecordProcessor = logsExporter ? new BatchLogRecordProcessor(logsExporter) : undefined + if (logRecordProcessor) { + loggerProvider.addLogRecordProcessor(logRecordProcessor) + } - if (traceExporter || metricReader || logRecordProcessor) { - console.log('Configuring open telemetry') + if (traceExporter || metricReader || logRecordProcessor) { + console.log('Configuring open telemetry') - logs.setGlobalLoggerProvider(loggerProvider) - diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR) - const sdk = new NodeSDK({ - traceExporter: traceExporter, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - metricReader: metricReader as any, - logRecordProcessor, - instrumentations: [ - // Express instrumentation expects HTTP layer to be instrumented - new HttpInstrumentation({ - // Ignore specific endpoints - ignoreIncomingRequestHook: (request) => { - const ignorePaths = ['/healthcheck', '/favicon.svg', '/static', '/@fs', '/@id', '/@vite', '/app/'] - const shouldIgnore = ignorePaths.some((path) => request.url?.startsWith(path)) + logs.setGlobalLoggerProvider(loggerProvider) + diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR) + const sdk = new NodeSDK({ + traceExporter: traceExporter, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metricReader: metricReader as any, + logRecordProcessor, + instrumentations: [ + // Express instrumentation expects HTTP layer to be instrumented + new HttpInstrumentation({ + // Ignore specific endpoints + ignoreIncomingRequestHook: (request) => { + const ignorePaths = [ + '/healthcheck', + '/favicon.svg', + '/static', + '/@fs', + '/@id', + '/@vite', + '/app/', + ] + const shouldIgnore = ignorePaths.some((path) => request.url?.startsWith(path)) - return shouldIgnore - }, - }), - new ExpressInstrumentation({}), - new RemixInstrumentation(), - createAzureSdkInstrumentation(), - ], - resource: new Resource({ - [SEMRESATTRS_SERVICE_NAME]: 'DDD-Website', - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - [SEMRESATTRS_SERVICE_VERSION]: JSON.parse( - existsSync('./server/package.json') - ? fs.readFileSync('./server/package.json', 'utf-8') - : fs.readFileSync('./package.json', 'utf-8'), - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - ), - }), - }) + return shouldIgnore + }, + }), + new UndiciInstrumentation(), + new ExpressInstrumentation({}), + new RemixInstrumentation(), + createAzureSdkInstrumentation(), + ], + resource: new Resource({ + [SEMRESATTRS_SERVICE_NAME]: 'DDD-Website', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + [SEMRESATTRS_SERVICE_VERSION]: JSON.parse( + existsSync('./server/package.json') + ? fs.readFileSync('./server/package.json', 'utf-8') + : fs.readFileSync('./package.json', 'utf-8'), + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + ), + }), + }) - sdk.start() - } + sdk.start() + } } diff --git a/website/app/lib/tito.server.ts b/website/app/lib/tito.server.ts new file mode 100644 index 0000000..7ad76aa --- /dev/null +++ b/website/app/lib/tito.server.ts @@ -0,0 +1,39 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' +import { z } from 'zod' +import { TITO_SECURITY_TOKEN } from './config.server' + +export function verifySignature(rawBody: string, signature: string): boolean { + if (!TITO_SECURITY_TOKEN) { + return false + } + + const hmac = createHmac('sha256', TITO_SECURITY_TOKEN) + hmac.update(rawBody) + const digest = hmac.digest('base64') + + return timingSafeEqual(Buffer.from(digest), Buffer.from(signature)) +} + +// Define Zod schema for the Tito payload +export const TitoPayloadSchema = z.object({ + slug: z.string(), + release_slug: z.string(), + first_name: z.string().nullable(), + last_name: z.string().nullable(), + email: z.string().email().nullable(), + responses: z.object({ + 'what-is-your-pronoun': z.string().optional(), + 'what-industry-are-you-in': z.string().optional(), + 'we-may-send-you-emails-about-this-year-s': z.string().optional(), + 'indicate-your-level-of-comfort-being-pho': z.string().optional(), + 'how-do-you-describe-your-skill-level': z.string().optional(), + 'how-do-you-describe-your-job-role': z.string().optional(), + 'do-you-agree-to-abide-by-the-code-of-con': z.string().optional(), + 'ddd-perth-remains-low-cost-due-to-our-ge': z.string().optional(), + 'please-indicate-lunch-preference': z.string().optional(), + }), + upgrade_ids: z.array(z.string()).optional(), +}) + +// Define TypeScript type based on Zod schema +export type TitoPayload = z.infer diff --git a/website/app/routes/tito-webhook.tsx b/website/app/routes/tito-webhook.tsx new file mode 100644 index 0000000..2a0405c --- /dev/null +++ b/website/app/routes/tito-webhook.tsx @@ -0,0 +1,242 @@ +import { trace } from '@opentelemetry/api' +import { ActionFunction, json } from '@remix-run/node' +import { conferenceConfig } from '~/config/conference-config' +import { ConferenceYear, Year } from '~/lib/config-types' +import { EVENTS_AIR_EVENT_ID } from '~/lib/config.server' +import { + checkIfContactExistsByExternalIdentifier, + createEventsAirContact, + createEventsAirRegistration, + deleteRegistration, + EventsAirContactData, + getAccessToken, + getContactRegistrations, + registrationTypes, + updateEventsAirContact, +} from '~/lib/events-air.server' +import { resolveError } from '~/lib/resolve-error' +import { TitoPayloadSchema } from '~/lib/tito.server' + +const ticketTypeMapping = { + 'general-attendee': registrationTypes.Attendee, + dqvd7i58iig: registrationTypes.Attendee, + 'general-attendee-company': registrationTypes.Attendee, + 'general-attendee-free': registrationTypes.Attendee, + lyfer: registrationTypes.Attendee, + volunteer: registrationTypes.Volunteer, + speaker: registrationTypes.Speaker, + sponsor: registrationTypes.Sponsor, +} as const + +export const action: ActionFunction = async ({ request, context }) => { + const configForYear = (conferenceConfig.conferences as unknown as Record)[ + context.conferenceState.conference.year + ] + const payload = await request.json() + const webhookType = request.headers.get('X-Webhook-Name') + + // Validate the payload using Zod + const parsedPayload = TitoPayloadSchema.safeParse(payload) + if (!parsedPayload.success) { + trace.getActiveSpan()?.addEvent('Failed to parse Tito payload', { + error: JSON.stringify(parsedPayload.error), + }) + return json({ success: false, error: 'Invalid Tito payload' }, { status: 400 }) + } + + const { slug, release_slug, email, first_name, last_name, responses, upgrade_ids } = parsedPayload.data + + const isGeneralTicket = configForYear?.ticketInfo?.generalTicketSlugs?.includes(release_slug) + const isAfterPartyTicket = configForYear?.ticketInfo?.afterPartyTicketSlugs?.includes(release_slug) + const hasAfterPartyUpgrade = upgrade_ids?.includes(configForYear?.ticketInfo?.afterPartyUpgradeActivityId ?? '') + + if (!isGeneralTicket && !isAfterPartyTicket) { + trace.getActiveSpan()?.addEvent('Unknown ticket type', { release_slug }) + // Not a ticket we care about + return json({ success: true }) + } + + if (!EVENTS_AIR_EVENT_ID) { + trace.getActiveSpan()?.recordException(new Error('EVENTS_AIR_EVENT_ID is not set')) + return json({ success: true }) + } + + const accessToken = await getAccessToken() + const externalIdentifier = slug + const eventId = EVENTS_AIR_EVENT_ID + + if (isAfterPartyTicket) { + // We don't handle re-assignment of dedicated after party tickets. This will have to be resolved on the day + if (webhookType === 'ticket.completed' || webhookType === 'ticket.updated') { + // There will be a race condition here if someone buys both tickets, to keep things simple and avoid a queue + // we will just sleep for 5 seconds to give the other webhook time to process. It should be fine + // as the ticket assignment is second, so really should never cause a problem + await new Promise((resolve) => setTimeout(resolve, 5_00)) + const existingTicketHolder = await checkIfContactExistsByExternalIdentifier( + accessToken, + eventId, + externalIdentifier, + ) + + if (existingTicketHolder) { + await updateEventsAirContact(accessToken, { userDefinedField3: 'Y' }, existingTicketHolder.id) + } + } + return json({ success: true }) + } + + const lunch = configForYear?.foodInfo?.lunch.find( + (lunch) => lunch.meal === responses['please-indicate-lunch-preference'], + ) + + const contactData: EventsAirContactData = { + firstName: first_name, + lastName: last_name, + primaryEmail: email, + externalIdentifier: slug, + userDefinedField1: responses['what-is-your-pronoun'], + userDefinedField2: lunch ? `${lunch.foodZone} (${lunch.shortCode})` : 'TBD', + // We don't set 'N' here, as we don't want to clear the after party upgrade if it was set and we update the ticket holder + userDefinedField3: hasAfterPartyUpgrade ? 'Y' : undefined, + } + + await trace.getTracer('default').startActiveSpan('processTitoWebhook', async (span) => { + try { + if ( + webhookType === 'ticket.completed' || + webhookType === 'ticket.updated' || + webhookType === 'ticket.unvoided' || + // Note, ticket re-assignment we will just update the name of the contact + webhookType === 'ticket.reassigned' + ) { + const contactId = await ensureContactExistsByExternalId( + accessToken, + externalIdentifier, + eventId, + contactData, + ) + + await ensureRegistrationExists(release_slug, accessToken, contactId, eventId) + } + + if (webhookType === 'ticket.voided') { + const existingTicketHolder = await checkIfContactExistsByExternalIdentifier( + accessToken, + eventId, + externalIdentifier, + ) + + if (existingTicketHolder) { + const registrations = await getContactRegistrations(accessToken, eventId, existingTicketHolder.id) + const existingRegistrationOfType = registrations.find( + (reg) => reg.type.id === ticketTypeMapping[release_slug as keyof typeof ticketTypeMapping], + ) + + if (existingRegistrationOfType) { + await deleteRegistration( + accessToken, + eventId, + existingTicketHolder.id, + existingRegistrationOfType.id, + ) + } + } + } + // Leaving this here, which will instead remove the external id of the current ticket holder + // Then ensure a new contact is created with the ticket's external id, and fixes up the registrations + // else if (webhookType === 'ticket.reassigned') { + // const existingTicketHolder = await checkIfContactExistsByExternalIdentifier( + // accessToken, + // eventId, + // externalIdentifier, + // ) + + // // Remove externalIdentifier from the existing ticket holder + // if (existingTicketHolder) { + // await updateEventsAirContact( + // accessToken, + // { ...contactData, externalIdentifier: null }, + // existingTicketHolder.id, + // ) + // // TODO Remove the current ticket holder's registration + // } + + // const contactId = await ensureContactExistsByEmail(accessToken, email, eventId, contactData) + // await ensureRegistrationExists(release_slug, accessToken, contactId, eventId) + // } + } catch (error) { + console.error('Error processing Tito webhook:', error) + trace.getActiveSpan()?.recordException(resolveError(error)) + return json({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) + } finally { + span.end() + } + }) + + return json({ success: true }) +} + +async function ensureContactExistsByExternalId( + accessToken: string, + externalIdentifier: string, + eventId: string, + contactData: EventsAirContactData, +) { + const contactExists = await checkIfContactExistsByExternalIdentifier(accessToken, eventId, externalIdentifier) + let createdContactId: string | undefined + + if (contactExists) { + await updateEventsAirContact(accessToken, contactData, contactExists.id) + } else { + createdContactId = await createEventsAirContact(accessToken, { + ...contactData, + // Default new contact to not having an after party upgrade + userDefinedField3: contactData.userDefinedField3 ?? 'N', + eventId, + }) + } + + const contactId = contactExists?.id ?? createdContactId + return contactId! +} + +// async function ensureContactExistsByEmail( +// accessToken: string, +// email: string, +// eventId: string, +// contactData: EventsAirContactData, +// ) { +// const contactExists = await checkIfContactExistsByEmail(accessToken, eventId, email) +// let createdContactId: string | undefined + +// if (contactExists) { +// await updateEventsAirContact(accessToken, contactData, contactExists.id) +// } else { +// createdContactId = await createEventsAirContact(accessToken, { +// ...contactData, +// eventId, +// }) +// } + +// const contactId = contactExists?.id ?? createdContactId +// return contactId! +// } + +async function ensureRegistrationExists(release_slug: string, accessToken: string, contactId: string, eventId: string) { + const mappedTicketType = ticketTypeMapping[release_slug as keyof typeof ticketTypeMapping] + const registrations = await getContactRegistrations(accessToken, eventId, contactId) + const existingRegistrationOfType = registrations.find((reg) => reg.type.id === mappedTicketType) + + if (!existingRegistrationOfType) { + await createEventsAirRegistration(accessToken, { + eventId, + contactId, + paymentDetails: { + adjustmentAmount: 0, + paymentStatus: 'INCLUSIVE', + }, + registrationTypeId: mappedTicketType, + dateTime: new Date().toISOString(), + }) + } +} diff --git a/website/package.json b/website/package.json index be1ed7f..769fbb0 100644 --- a/website/package.json +++ b/website/package.json @@ -21,6 +21,7 @@ "@opentelemetry/instrumentation": "^0.52.1", "@opentelemetry/instrumentation-express": "^0.41.1", "@opentelemetry/instrumentation-http": "^0.52.1", + "@opentelemetry/instrumentation-undici": "^0.7.1", "@opentelemetry/resources": "^1.25.1", "@opentelemetry/sdk-logs": "^0.52.1", "@opentelemetry/sdk-metrics": "^1.25.1", diff --git a/website/remix-routes.d.ts b/website/remix-routes.d.ts index fe7fb5c..d24c364 100644 --- a/website/remix-routes.d.ts +++ b/website/remix-routes.d.ts @@ -88,6 +88,11 @@ declare module "remix-routes" { query: ExportedQuery, }; + "/tito-webhook": { + params: never, + query: ExportedQuery, + }; + } type RoutesWithParams = Pick< @@ -112,7 +117,8 @@ declare module "remix-routes" { | 'routes/app-announcements' | 'routes/app-config' | 'routes/app-content.$' - | 'routes/blog.rss[.xml]'; + | 'routes/blog.rss[.xml]' + | 'routes/tito-webhook'; export function $path< Route extends keyof Routes,