Skip to content

Commit 89d4ce0

Browse files
committed
Add Meetup calendar datasource
1 parent ffa2caa commit 89d4ce0

17 files changed

+809
-2
lines changed

src/configuration.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ const Config = t.strict({
4343
),
4444
GOOGLE_SERVICE_ACCOUNT_KEY_JSON: tt.NonEmptyString, // Don't default so we don't accidentally disable.
4545
TROUBLE_TICKET_SHEET: t.string,
46+
MEETUP_ICAL_URL: withDefaultIfEmpty(
47+
t.string,
48+
'https://www.meetup.com/makespace/events/ical/'
49+
),
4650
});
4751

4852
export type Config = t.TypeOf<typeof Config>;

src/dependencies.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
SheetDataTable,
1313
TroubleTicketDataTable,
1414
} from './sync-worker/google/sheet-data-table';
15+
import {MeetupEventRow} from './sync-worker/db/get-meetup-events';
1516

1617
export type Dependencies = {
1718
commitEvent: (
@@ -49,4 +50,5 @@ export type Dependencies = {
4950
getTroubleTicketData: (
5051
from: O.Option<Date>
5152
) => TE.TaskEither<string, O.Option<TroubleTicketDataTable['rows']>>;
53+
getMeetupEvents: () => TE.TaskEither<string, ReadonlyArray<MeetupEventRow>>;
5254
};

src/http/calendar-ics-handler.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {RequestHandler} from 'express';
2+
import {StatusCodes} from 'http-status-codes';
3+
import {Dependencies} from '../dependencies';
4+
import * as E from 'fp-ts/Either';
5+
import {MeetupEventRow} from '../sync-worker/db/get-meetup-events';
6+
7+
const formatICalDate = (timestamp: number): string => {
8+
const date = new Date(timestamp);
9+
return date
10+
.toISOString()
11+
.replace(/[-:]/g, '')
12+
.replace(/\.\d{3}/, '');
13+
};
14+
15+
const escapeICalText = (text: string): string =>
16+
text
17+
.replace(/\\/g, '\\\\')
18+
.replace(/;/g, '\\;')
19+
.replace(/,/g, '\\,')
20+
.replace(/\n/g, '\\n');
21+
22+
const foldLine = (line: string): string => {
23+
const maxLength = 75;
24+
if (line.length <= maxLength) {
25+
return line;
26+
}
27+
const parts: string[] = [];
28+
let remaining = line;
29+
while (remaining.length > 0) {
30+
if (parts.length === 0) {
31+
parts.push(remaining.slice(0, maxLength));
32+
remaining = remaining.slice(maxLength);
33+
} else {
34+
// Continuation lines start with a space
35+
parts.push(' ' + remaining.slice(0, maxLength - 1));
36+
remaining = remaining.slice(maxLength - 1);
37+
}
38+
}
39+
return parts.join('\r\n');
40+
};
41+
42+
const generateICalendar = (
43+
events: ReadonlyArray<MeetupEventRow>
44+
): string => {
45+
const lines: string[] = [
46+
'BEGIN:VCALENDAR',
47+
'VERSION:2.0',
48+
'PRODID:-//MakeSpace//Members App//EN',
49+
'CALSCALE:GREGORIAN',
50+
'METHOD:PUBLISH',
51+
'X-WR-CALNAME:MakeSpace Events',
52+
];
53+
54+
for (const event of events) {
55+
lines.push('BEGIN:VEVENT');
56+
lines.push(foldLine(`UID:${event.uid}`));
57+
lines.push(`DTSTART:${formatICalDate(event.dtstart)}`);
58+
lines.push(`DTEND:${formatICalDate(event.dtend)}`);
59+
lines.push(foldLine(`SUMMARY:${escapeICalText(event.summary)}`));
60+
if (event.description) {
61+
lines.push(foldLine(`DESCRIPTION:${escapeICalText(event.description)}`));
62+
}
63+
if (event.location) {
64+
lines.push(foldLine(`LOCATION:${escapeICalText(event.location)}`));
65+
}
66+
if (event.url) {
67+
lines.push(foldLine(`URL:${event.url}`));
68+
}
69+
lines.push('END:VEVENT');
70+
}
71+
72+
lines.push('END:VCALENDAR');
73+
return lines.join('\r\n');
74+
};
75+
76+
export const calendarIcsHandler =
77+
(deps: Dependencies): RequestHandler =>
78+
async (req, res) => {
79+
const events = await deps.getMeetupEvents()();
80+
81+
if (E.isLeft(events)) {
82+
deps.logger.error('Failed to get Meetup events: %s', events.left);
83+
return res
84+
.status(StatusCodes.INTERNAL_SERVER_ERROR)
85+
.send('Failed to load calendar');
86+
}
87+
88+
const icalContent = generateICalendar(events.right);
89+
90+
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
91+
res.setHeader(
92+
'Content-Disposition',
93+
'attachment; filename="makespace-events.ics"'
94+
);
95+
return res.status(StatusCodes.OK).send(icalContent);
96+
};

src/http/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {queryToHandler} from './query-to-handler';
22
export {commandToHandlers} from './command-to-handlers';
33
export {ping} from './ping';
4+
export {calendarIcsHandler} from './calendar-ics-handler';
45
export {createRouter} from './router';

src/init-dependencies/init-dependencies.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {initSharedReadModel} from '../read-models/shared-state';
1414
import {lastSync} from '../sync-worker/db/last_sync';
1515
import {getSheetData} from '../sync-worker/db/get_sheet_data';
1616
import {getTroubleTicketData} from '../sync-worker/db/get_trouble_ticket_data';
17+
import {getMeetupEvents} from '../sync-worker/db/get-meetup-events';
1718

1819
export const initLogger = (conf: Config) => {
1920
let loggerOptions: LoggerOptions;
@@ -83,6 +84,7 @@ export const initDependencies = (
8384
googleDB,
8485
O.fromNullable(conf.TROUBLE_TICKET_SHEET)
8586
),
87+
getMeetupEvents: getMeetupEvents(googleDB),
8688
// getPassedQuizResults: getPassedQuizResults(dbClient),
8789
// getFailedQuizResults: getFailedQuizResults(dbClient),
8890
};

src/routes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {commands, sendEmailCommands} from './commands';
44
import * as queries from './queries';
55
import {Route, get} from './types/route';
66
import {authRoutes} from './authentication';
7-
import {queryToHandler, commandToHandlers, ping} from './http';
7+
import {queryToHandler, commandToHandlers, ping, calendarIcsHandler} from './http';
88
import {emailHandler} from './http/email-handler';
99

1010
export const initRoutes = (
@@ -89,6 +89,7 @@ export const initRoutes = (
8989
),
9090
email('owner-agreement-invite', sendEmailCommands.ownerAgreementInvite),
9191
get('/ping', ping),
92+
get('/calendar.ics', calendarIcsHandler(deps)),
9293
query('/db', queries.db),
9394
query('/debug/dump-shared-db/json', queries.dumpSharedDbAsJson),
9495
query('/debug/dump-shared-db/buffer', queries.dumpSharedDbAsBuffer),
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {Client} from '@libsql/client';
2+
import * as TE from 'fp-ts/TaskEither';
3+
import {pipe} from 'fp-ts/lib/function';
4+
5+
export const clearMeetupEvents =
6+
(googleDB: Client) => (): TE.TaskEither<string, void> =>
7+
pipe(
8+
TE.tryCatch(
9+
() => googleDB.execute('DELETE FROM meetup_event_data'),
10+
reason =>
11+
`Failed to clear meetup events: ${(reason as Error).message}`
12+
),
13+
TE.map(() => {})
14+
);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {Client} from '@libsql/client';
2+
import * as TE from 'fp-ts/TaskEither';
3+
import {pipe} from 'fp-ts/lib/function';
4+
5+
export type MeetupEventRow = {
6+
uid: string;
7+
summary: string;
8+
description: string | null;
9+
location: string | null;
10+
dtstart: number;
11+
dtend: number;
12+
url: string | null;
13+
cached_at: number;
14+
};
15+
16+
export const getMeetupEvents =
17+
(googleDB: Client) => (): TE.TaskEither<string, ReadonlyArray<MeetupEventRow>> =>
18+
pipe(
19+
TE.tryCatch(
20+
async () => {
21+
const result = await googleDB.execute(
22+
'SELECT uid, summary, description, location, dtstart, dtend, url, cached_at FROM meetup_event_data ORDER BY dtstart ASC'
23+
);
24+
return result.rows as unknown as MeetupEventRow[];
25+
},
26+
reason =>
27+
`Failed to get meetup events: ${(reason as Error).message}`
28+
)
29+
);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {Client} from '@libsql/client';
2+
import * as TE from 'fp-ts/TaskEither';
3+
import * as RA from 'fp-ts/ReadonlyArray';
4+
import {pipe} from 'fp-ts/lib/function';
5+
import {inspect} from 'node:util';
6+
import {MeetupEvent} from '../meetup/fetch-meetup-ical';
7+
8+
export const storeMeetupEvents =
9+
(googleDB: Client) =>
10+
(data: ReadonlyArray<MeetupEvent>): TE.TaskEither<string, void> =>
11+
pipe(
12+
data,
13+
RA.map(event =>
14+
TE.tryCatch(
15+
() =>
16+
googleDB.execute({
17+
sql: `INSERT OR REPLACE INTO meetup_event_data(
18+
uid, summary, description, location, dtstart, dtend, url, cached_at
19+
) VALUES (
20+
$uid, $summary, $description, $location, $dtstart, $dtend, $url, $cached_at
21+
)`,
22+
args: {
23+
$uid: event.uid,
24+
$summary: event.summary,
25+
$description: event.description,
26+
$location: event.location,
27+
$dtstart: event.dtstart.getTime(),
28+
$dtend: event.dtend.getTime(),
29+
$url: event.url,
30+
$cached_at: Date.now(),
31+
},
32+
}),
33+
reason =>
34+
`Failed to insert meetup event '${inspect(event)}': ${(reason as Error).message}`
35+
)
36+
),
37+
TE.sequenceArray,
38+
TE.map(() => {})
39+
);

src/sync-worker/dependencies.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
SheetDataTable,
88
TroubleTicketDataTable,
99
} from './google/sheet-data-table';
10+
import {MeetupEvent} from './meetup/fetch-meetup-ical';
1011
import {ReadonlyRecord} from 'fp-ts/lib/ReadonlyRecord';
1112
import {UUID} from 'io-ts-types';
1213
import {DomainEvent, Email, Failure, ResourceVersion} from '../types';
@@ -58,4 +59,8 @@ export interface SyncWorkerDependencies {
5859
version: ResourceVersion;
5960
}
6061
>;
62+
storeMeetupEvents: (
63+
data: ReadonlyArray<MeetupEvent>
64+
) => TE.TaskEither<string, void>;
65+
clearMeetupEvents: () => TE.TaskEither<string, void>;
6166
}

0 commit comments

Comments
 (0)