From 62f516631e7c1a967145551995f7fff7ccfbc3dd Mon Sep 17 00:00:00 2001 From: BekahHW <34313413+BekahHW@users.noreply.github.com> Date: Sun, 17 May 2026 16:45:39 -0400 Subject: [PATCH 1/3] feat: migrate events to Google Calendar API Refactor event data structures and add Google Calendar event fetching functionality. --- src/data/events.ts | 271 ++++++++++++++++++++++----------------------- 1 file changed, 133 insertions(+), 138 deletions(-) diff --git a/src/data/events.ts b/src/data/events.ts index a6b319ca..c1f1117d 100644 --- a/src/data/events.ts +++ b/src/data/events.ts @@ -1,153 +1,148 @@ 'use server'; import { unstable_cache } from 'next/cache'; -import { GraphQLClient, gql } from 'graphql-request'; import { DateTime } from 'luxon'; import { sanitizeHtml } from '@/util/sanitizeCmsData'; import { ics, google, outlook } from 'calendar-link'; -const calendarsQuery = gql` - query getCalendars { - solspace_calendar { - calendars { - handle - } - } - } -`; - -/** - * Defining the interface for the SolspaceCalendar object. - * @link https://docs.solspace.com/craft/calendar/v3/developer/graphql.html#calendar-interface - */ -interface SolspaceCalendar { - id: number; - uid: string; - name: string; - handle: string; - description: string; - color: string; - lighterColor: string; - darkerColor: string; - icsHash: string; - allowRepeatingEvents: boolean; +export interface EventItem { + id: string; + title: string; + startDateLocalized: string; + endDateLocalized: string; + eventCalendarDescription: string; + eventCalendarLinks: { + google: string; + outlook: string; + ics: string; + }; } -interface SolspaceEventResponse { - id: number | string; - title: string; - startDateLocalized: string; - endDateLocalized: string; - eventCalendarDescription: string; +export type EventsResponse = Array; + +interface GoogleCalendarEvent { + id: string; + summary?: string; + description?: string; + start: { dateTime?: string; date?: string }; + end: { dateTime?: string; date?: string }; + status?: string; } -export interface EventItem extends SolspaceEventResponse { - eventCalendarLinks: { - google: string; - outlook: string; - ics: string; - }; + +interface GoogleCalendarResponse { + items: GoogleCalendarEvent[]; } -export type EventsResponse = Array; -function createEventsQuery( - calendars: Pick[], - rangeStart: string, - rangeEnd: string, - limit: number, -) { - return gql` - query getEvents { - solspace_calendar { - events(rangeStart: "${rangeStart}", rangeEnd: "${rangeEnd}", limit: ${limit}) { - id - title - startDateLocalized - endDateLocalized - ${calendars.map( - ({ handle }) => ` - ... on ${handle}_Event { - eventCalendarDescription - id - } - `, - )} - } - } - } -`; +async function fetchCalendarEvents( + calendarId: string, + apiKey: string, + rangeStart: string, + rangeEnd: string, + limit: number, +): Promise { + const params = new URLSearchParams({ + key: apiKey, + timeMin: rangeStart, + timeMax: rangeEnd, + maxResults: String(limit), + singleEvents: 'true', + orderBy: 'startTime', + }); + + const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`; + const res = await fetch(url, { next: { revalidate: 43200 } }); + + if (!res.ok) { + console.error(`Failed to fetch calendar ${calendarId}: ${res.status} ${res.statusText}`); + return []; + } + + const data: GoogleCalendarResponse = await res.json(); + return data.items ?? []; } export const getEvents = unstable_cache( - async ({ limit }: { limit: number }): Promise => { - const rangeStart = DateTime.now().toUTC().set({ hour: 0 }).toISO(); - const rangeEnd = DateTime.now() - .toUTC() - .set({ hour: 0 }) - .plus({ days: 30 }) - .toISO(); - - if (!(process.env.CMS_URL && process.env.CMS_TOKEN)) { - const fakeData = await import('./mocks/events'); - return fakeData.createEventsData({ limit, rangeEnd, rangeStart }); - } - - const graphQLClient = new GraphQLClient(`${process.env.CMS_URL}/api`, { - headers: { - Authorization: `bearer ${process.env.CMS_TOKEN}`, - }, - }); - - try { - const { - solspace_calendar: { calendars }, - } = await graphQLClient.request<{ - solspace_calendar: { calendars: Pick[] }; - }>(calendarsQuery); - - const { - solspace_calendar: { events }, - } = await graphQLClient.request<{ - solspace_calendar: { events: EventsResponse }; - }>(createEventsQuery(calendars, rangeStart, rangeEnd, limit)); - - return await Promise.all( - events.map(async (event) => { - const sanitizedDescription = await sanitizeHtml( - event.eventCalendarDescription, - ); - const calendarLinkGoogle = google({ - title: event.title, - start: event.startDateLocalized, - end: event.endDateLocalized, - description: sanitizedDescription, - }); - const calendarLinkOutlook = outlook({ - title: event.title, - start: event.startDateLocalized, - end: event.endDateLocalized, - description: sanitizedDescription, - }); - const calendarLinkIcs = ics({ - title: event.title, - start: event.startDateLocalized, - end: event.endDateLocalized, - description: sanitizedDescription, - }); - return { - ...event, - eventCalendarDescription: sanitizedDescription, - eventCalendarLinks: { - google: calendarLinkGoogle, - outlook: calendarLinkOutlook, - ics: calendarLinkIcs, - }, - }; - }), - ); - } catch (e) { - console.error(e); - return []; - } - }, - [], - { revalidate: 43200, tags: ['events'] }, + async ({ limit }: { limit: number }): Promise => { + const rangeStart = DateTime.now().toUTC().set({ hour: 0 }).toISO()!; + const rangeEnd = DateTime.now() + .toUTC() + .set({ hour: 0 }) + .plus({ days: 30 }) + .toISO()!; + + const apiKey = process.env.GOOGLE_CALENDAR; + const calendarIds = process.env.GOOGLE_CALENDAR_IDS; + + if (!apiKey || !calendarIds) { + const fakeData = await import('./mocks/events'); + return fakeData.createEventsData({ limit, rangeEnd, rangeStart }); + } + + const ids = calendarIds.split(',').map((id) => id.trim()).filter(Boolean); + + try { + const allEventsNested = await Promise.all( + ids.map((calendarId) => + fetchCalendarEvents(calendarId, apiKey, rangeStart, rangeEnd, limit), + ), + ); + + const allEvents = allEventsNested + .flat() + .filter((e) => e.status !== 'cancelled') + .sort((a, b) => { + const aStart = a.start.dateTime ?? a.start.date ?? ''; + const bStart = b.start.dateTime ?? b.start.date ?? ''; + return aStart.localeCompare(bStart); + }) + .slice(0, limit); + + return await Promise.all( + allEvents.map(async (event) => { + const startDate = event.start.dateTime ?? event.start.date ?? ''; + const endDate = event.end.dateTime ?? event.end.date ?? ''; + const title = event.summary ?? 'Virtual Coffee Event'; + const rawDescription = event.description ?? ''; + + const sanitizedDescription = await sanitizeHtml(rawDescription); + + const calendarLinkGoogle = google({ + title, + start: startDate, + end: endDate, + description: sanitizedDescription, + }); + const calendarLinkOutlook = outlook({ + title, + start: startDate, + end: endDate, + description: sanitizedDescription, + }); + const calendarLinkIcs = ics({ + title, + start: startDate, + end: endDate, + description: sanitizedDescription, + }); + + return { + id: event.id, + title, + startDateLocalized: startDate, + endDateLocalized: endDate, + eventCalendarDescription: sanitizedDescription, + eventCalendarLinks: { + google: calendarLinkGoogle, + outlook: calendarLinkOutlook, + ics: calendarLinkIcs, + }, + }; + }), + ); + } catch (e) { + console.error(e); + return []; + } + }, + [], + { revalidate: 43200, tags: ['events'] }, ); From 9e3a7ab904910cb49b1d8011a78e82370b0c748b Mon Sep 17 00:00:00 2001 From: BekahHW Date: Sun, 17 May 2026 20:47:03 +0000 Subject: [PATCH 2/3] Prettified Code! --- src/data/events.ts | 255 +++++++++++++++++++++++---------------------- 1 file changed, 130 insertions(+), 125 deletions(-) diff --git a/src/data/events.ts b/src/data/events.ts index c1f1117d..610af78f 100644 --- a/src/data/events.ts +++ b/src/data/events.ts @@ -6,143 +6,148 @@ import { sanitizeHtml } from '@/util/sanitizeCmsData'; import { ics, google, outlook } from 'calendar-link'; export interface EventItem { - id: string; - title: string; - startDateLocalized: string; - endDateLocalized: string; - eventCalendarDescription: string; - eventCalendarLinks: { - google: string; - outlook: string; - ics: string; - }; + id: string; + title: string; + startDateLocalized: string; + endDateLocalized: string; + eventCalendarDescription: string; + eventCalendarLinks: { + google: string; + outlook: string; + ics: string; + }; } export type EventsResponse = Array; interface GoogleCalendarEvent { - id: string; - summary?: string; - description?: string; - start: { dateTime?: string; date?: string }; - end: { dateTime?: string; date?: string }; - status?: string; + id: string; + summary?: string; + description?: string; + start: { dateTime?: string; date?: string }; + end: { dateTime?: string; date?: string }; + status?: string; } interface GoogleCalendarResponse { - items: GoogleCalendarEvent[]; + items: GoogleCalendarEvent[]; } async function fetchCalendarEvents( - calendarId: string, - apiKey: string, - rangeStart: string, - rangeEnd: string, - limit: number, + calendarId: string, + apiKey: string, + rangeStart: string, + rangeEnd: string, + limit: number, ): Promise { - const params = new URLSearchParams({ - key: apiKey, - timeMin: rangeStart, - timeMax: rangeEnd, - maxResults: String(limit), - singleEvents: 'true', - orderBy: 'startTime', - }); - - const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`; - const res = await fetch(url, { next: { revalidate: 43200 } }); - - if (!res.ok) { - console.error(`Failed to fetch calendar ${calendarId}: ${res.status} ${res.statusText}`); - return []; - } - - const data: GoogleCalendarResponse = await res.json(); - return data.items ?? []; + const params = new URLSearchParams({ + key: apiKey, + timeMin: rangeStart, + timeMax: rangeEnd, + maxResults: String(limit), + singleEvents: 'true', + orderBy: 'startTime', + }); + + const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`; + const res = await fetch(url, { next: { revalidate: 43200 } }); + + if (!res.ok) { + console.error( + `Failed to fetch calendar ${calendarId}: ${res.status} ${res.statusText}`, + ); + return []; + } + + const data: GoogleCalendarResponse = await res.json(); + return data.items ?? []; } export const getEvents = unstable_cache( - async ({ limit }: { limit: number }): Promise => { - const rangeStart = DateTime.now().toUTC().set({ hour: 0 }).toISO()!; - const rangeEnd = DateTime.now() - .toUTC() - .set({ hour: 0 }) - .plus({ days: 30 }) - .toISO()!; - - const apiKey = process.env.GOOGLE_CALENDAR; - const calendarIds = process.env.GOOGLE_CALENDAR_IDS; - - if (!apiKey || !calendarIds) { - const fakeData = await import('./mocks/events'); - return fakeData.createEventsData({ limit, rangeEnd, rangeStart }); - } - - const ids = calendarIds.split(',').map((id) => id.trim()).filter(Boolean); - - try { - const allEventsNested = await Promise.all( - ids.map((calendarId) => - fetchCalendarEvents(calendarId, apiKey, rangeStart, rangeEnd, limit), - ), - ); - - const allEvents = allEventsNested - .flat() - .filter((e) => e.status !== 'cancelled') - .sort((a, b) => { - const aStart = a.start.dateTime ?? a.start.date ?? ''; - const bStart = b.start.dateTime ?? b.start.date ?? ''; - return aStart.localeCompare(bStart); - }) - .slice(0, limit); - - return await Promise.all( - allEvents.map(async (event) => { - const startDate = event.start.dateTime ?? event.start.date ?? ''; - const endDate = event.end.dateTime ?? event.end.date ?? ''; - const title = event.summary ?? 'Virtual Coffee Event'; - const rawDescription = event.description ?? ''; - - const sanitizedDescription = await sanitizeHtml(rawDescription); - - const calendarLinkGoogle = google({ - title, - start: startDate, - end: endDate, - description: sanitizedDescription, - }); - const calendarLinkOutlook = outlook({ - title, - start: startDate, - end: endDate, - description: sanitizedDescription, - }); - const calendarLinkIcs = ics({ - title, - start: startDate, - end: endDate, - description: sanitizedDescription, - }); - - return { - id: event.id, - title, - startDateLocalized: startDate, - endDateLocalized: endDate, - eventCalendarDescription: sanitizedDescription, - eventCalendarLinks: { - google: calendarLinkGoogle, - outlook: calendarLinkOutlook, - ics: calendarLinkIcs, - }, - }; - }), - ); - } catch (e) { - console.error(e); - return []; - } - }, - [], - { revalidate: 43200, tags: ['events'] }, + async ({ limit }: { limit: number }): Promise => { + const rangeStart = DateTime.now().toUTC().set({ hour: 0 }).toISO()!; + const rangeEnd = DateTime.now() + .toUTC() + .set({ hour: 0 }) + .plus({ days: 30 }) + .toISO()!; + + const apiKey = process.env.GOOGLE_CALENDAR; + const calendarIds = process.env.GOOGLE_CALENDAR_IDS; + + if (!apiKey || !calendarIds) { + const fakeData = await import('./mocks/events'); + return fakeData.createEventsData({ limit, rangeEnd, rangeStart }); + } + + const ids = calendarIds + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + + try { + const allEventsNested = await Promise.all( + ids.map((calendarId) => + fetchCalendarEvents(calendarId, apiKey, rangeStart, rangeEnd, limit), + ), + ); + + const allEvents = allEventsNested + .flat() + .filter((e) => e.status !== 'cancelled') + .sort((a, b) => { + const aStart = a.start.dateTime ?? a.start.date ?? ''; + const bStart = b.start.dateTime ?? b.start.date ?? ''; + return aStart.localeCompare(bStart); + }) + .slice(0, limit); + + return await Promise.all( + allEvents.map(async (event) => { + const startDate = event.start.dateTime ?? event.start.date ?? ''; + const endDate = event.end.dateTime ?? event.end.date ?? ''; + const title = event.summary ?? 'Virtual Coffee Event'; + const rawDescription = event.description ?? ''; + + const sanitizedDescription = await sanitizeHtml(rawDescription); + + const calendarLinkGoogle = google({ + title, + start: startDate, + end: endDate, + description: sanitizedDescription, + }); + const calendarLinkOutlook = outlook({ + title, + start: startDate, + end: endDate, + description: sanitizedDescription, + }); + const calendarLinkIcs = ics({ + title, + start: startDate, + end: endDate, + description: sanitizedDescription, + }); + + return { + id: event.id, + title, + startDateLocalized: startDate, + endDateLocalized: endDate, + eventCalendarDescription: sanitizedDescription, + eventCalendarLinks: { + google: calendarLinkGoogle, + outlook: calendarLinkOutlook, + ics: calendarLinkIcs, + }, + }; + }), + ); + } catch (e) { + console.error(e); + return []; + } + }, + [], + { revalidate: 43200, tags: ['events'] }, ); From 62fec43cae03a2b39512da9cb7ddec11b4263721 Mon Sep 17 00:00:00 2001 From: JoeKarow Date: Tue, 19 May 2026 14:50:41 +0000 Subject: [PATCH 3/3] Prettified Code! --- .../functions/event-reminders-hourly/index.ts | 123 +++++++++--------- netlify/functions/join-coffee.ts | 4 +- netlify/functions/slack/index.ts | 6 +- 3 files changed, 68 insertions(+), 65 deletions(-) diff --git a/netlify/functions/event-reminders-hourly/index.ts b/netlify/functions/event-reminders-hourly/index.ts index d1f87f28..186a6635 100644 --- a/netlify/functions/event-reminders-hourly/index.ts +++ b/netlify/functions/event-reminders-hourly/index.ts @@ -197,77 +197,74 @@ export default async (req: Request) => { emoji: true, }, }, - ...filteredList.reduce[]>( - (list, event) => { - const eventDate = DateTime.fromISO(event.startDateLocalized); + ...filteredList.reduce[]>((list, event) => { + const eventDate = DateTime.fromISO(event.startDateLocalized); - const titleBlock: Record = { - type: 'section', + const titleBlock: Record = { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${ + event.title + }*\n`, + }, + }; + + if ( + event.eventJoinLink && + event.eventJoinLink.substring(0, 4) === 'http' + ) { + titleBlock.accessory = { + type: 'button', text: { - type: 'mrkdwn', - text: `*${ - event.title - }*\n`, + type: 'plain_text', + text: 'Join Event', + emoji: true, }, + value: `join_event_${event.id}`, + url: event.eventJoinLink, + action_id: 'button-join-event', }; + } - if ( - event.eventJoinLink && - event.eventJoinLink.substring(0, 4) === 'http' - ) { - titleBlock.accessory = { - type: 'button', - text: { - type: 'plain_text', - text: 'Join Event', - emoji: true, - }, - value: `join_event_${event.id}`, - url: event.eventJoinLink, - action_id: 'button-join-event', - }; - } - - return [ - ...list, - titleBlock, - { - type: 'section', - text: { - type: 'mrkdwn', - text: `*Location:* ${event.eventJoinLink}`, - }, + return [ + ...list, + titleBlock, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Location:* ${event.eventJoinLink}`, }, - ...(event.eventZoomHostCode - ? [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: `*Host Code:* ${event.eventZoomHostCode}`, - }, + }, + ...(event.eventZoomHostCode + ? [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Host Code:* ${event.eventZoomHostCode}`, }, - ] - : []), - { - type: 'section', - text: { - type: 'mrkdwn', - text: `*Announcement posted to:* <#${ - event.eventSlackAnnouncementsChannelId || - DEFAULT_SLACK_EVENT_CHANNEL - }>`, - }, - }, - { - type: 'divider', + }, + ] + : []), + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Announcement posted to:* <#${ + event.eventSlackAnnouncementsChannelId || + DEFAULT_SLACK_EVENT_CHANNEL + }>`, }, - ]; - }, - [], - ), + }, + { + type: 'divider', + }, + ]; + }, []), ], }; diff --git a/netlify/functions/join-coffee.ts b/netlify/functions/join-coffee.ts index 1758dace..14fb3da6 100644 --- a/netlify/functions/join-coffee.ts +++ b/netlify/functions/join-coffee.ts @@ -12,7 +12,9 @@ export default async (req: Request) => { console.log(`Joining ${day}: ${code}`); const target = - day === 'tuesday' ? requireEnv('ZOOM_TUESDAYS') : requireEnv('ZOOM_THURSDAYS'); + day === 'tuesday' + ? requireEnv('ZOOM_TUESDAYS') + : requireEnv('ZOOM_THURSDAYS'); return Response.redirect(target, 302); }; diff --git a/netlify/functions/slack/index.ts b/netlify/functions/slack/index.ts index af521086..702a4c52 100644 --- a/netlify/functions/slack/index.ts +++ b/netlify/functions/slack/index.ts @@ -48,7 +48,11 @@ class NetlifyReceiver implements Receiver { }); } - const isValid = verifySlackRequest(rawBody, req.headers, this.signingSecret); + const isValid = verifySlackRequest( + rawBody, + req.headers, + this.signingSecret, + ); if (!isValid.valid) { console.log('Failed validation:', isValid.reason); return new Response(isValid.reason, { status: 401 });