Skip to content

Commit 9d1fdd2

Browse files
authored
Add ICS client support based on event host (#3)
* add GSI for host field * fix dynamo table def * build ical support * fix IAM role for using an index * fix IAM, add tests * add ical parsing tests * add unit tests * remove unneeded imports * fix calendar name * fix name part 3
1 parent 5239c0e commit 9d1fdd2

File tree

12 files changed

+390
-6
lines changed

12 files changed

+390
-6
lines changed

cloudformation/iam.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Resources:
6161
- dynamodb:*
6262
Effect: Allow
6363
Resource:
64+
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-api-records/*
6465
- !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-api-records
6566
PolicyName: lambda-dynamo
6667
Outputs:

cloudformation/main.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,18 @@ Resources:
117117
AttributeDefinitions:
118118
- AttributeName: id
119119
AttributeType: S
120+
- AttributeName: host
121+
AttributeType: S
120122
KeySchema:
121123
- AttributeName: id
122124
KeyType: HASH
125+
GlobalSecondaryIndexes:
126+
- IndexName: HostIndex
127+
KeySchema:
128+
- AttributeName: host
129+
KeyType: HASH
130+
Projection:
131+
ProjectionType: ALL
123132

124133
AppApiGateway:
125134
Type: AWS::Serverless::Api

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"eslint-plugin-prettier": "^5.2.1",
4242
"husky": "^9.1.4",
4343
"lint-staged": "^15.2.8",
44+
"node-ical": "^0.18.0",
4445
"prettier": "^3.3.3",
4546
"request": "^2.88.2",
4647
"supertest": "^7.0.0",
@@ -56,10 +57,13 @@
5657
"@fastify/auth": "^4.6.1",
5758
"@fastify/aws-lambda": "^4.1.0",
5859
"@fastify/cors": "^9.0.1",
60+
"@touch4it/ical-timezones": "^1.9.0",
5961
"fastify": "^4.28.1",
6062
"fastify-plugin": "^4.5.1",
63+
"ical-generator": "^7.2.0",
6164
"jsonwebtoken": "^9.0.2",
6265
"jwks-rsa": "^3.1.0",
66+
"moment": "^2.30.1",
6367
"moment-timezone": "^0.5.45",
6468
"zod": "^3.23.8",
6569
"zod-to-json-schema": "^3.23.2",

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import cors from "@fastify/cors";
1212
import fastifyZodValidationPlugin from "./plugins/validate.js";
1313
import { environmentConfig } from "./config.js";
1414
import organizationsPlugin from "./routes/organizations.js";
15+
import icalPlugin from "./routes/ics.js";
1516

1617
const now = () => Date.now();
1718

@@ -66,6 +67,7 @@ async function init() {
6667
api.register(protectedRoute, { prefix: "/protected" });
6768
api.register(eventsPlugin, { prefix: "/events" });
6869
api.register(organizationsPlugin, { prefix: "/organizations" });
70+
api.register(icalPlugin, { prefix: "/ical" });
6971
},
7072
{ prefix: "/api/v1" },
7173
);

src/orgs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ export const CommitteeList = [
2323
"Social Committee",
2424
"Mentorship Committee",
2525
] as const;
26-
export const OrganizationList = ["ACM", ...SIGList, ...CommitteeList] as const;
26+
export const OrganizationList = ["ACM", ...SIGList, ...CommitteeList];

src/routes/events.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import moment from "moment-timezone";
1818
// POST
1919

2020
const repeatOptions = ["weekly", "biweekly"] as const;
21+
export type EventRepeatOptions = (typeof repeatOptions)[number];
2122

2223
const baseSchema = z.object({
2324
title: z.string().min(1),
@@ -26,7 +27,7 @@ const baseSchema = z.object({
2627
end: z.optional(z.string()),
2728
location: z.string(),
2829
locationLink: z.optional(z.string().url()),
29-
host: z.enum(OrganizationList),
30+
host: z.enum(OrganizationList as [string, ...string[]]),
3031
featured: z.boolean().default(false),
3132
paidEventId: z.optional(z.string().min(1)),
3233
});

src/routes/ics.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { FastifyPluginAsync } from "fastify";
2+
import {
3+
DynamoDBClient,
4+
QueryCommand,
5+
QueryCommandInput,
6+
ScanCommand,
7+
} from "@aws-sdk/client-dynamodb";
8+
import { genericConfig } from "../config.js";
9+
import { unmarshall } from "@aws-sdk/util-dynamodb";
10+
import { NotFoundError, ValidationError } from "../errors/index.js";
11+
import ical, {
12+
ICalCalendarMethod,
13+
ICalEventJSONRepeatingData,
14+
ICalEventRepeatingFreq,
15+
} from "ical-generator";
16+
import moment from "moment";
17+
import { getVtimezoneComponent } from "@touch4it/ical-timezones";
18+
import { OrganizationList } from "../orgs.js";
19+
import { EventRepeatOptions } from "./events.js";
20+
21+
const dynamoClient = new DynamoDBClient({
22+
region: genericConfig.AwsRegion,
23+
});
24+
25+
const repeatingIcalMap: Record<EventRepeatOptions, ICalEventJSONRepeatingData> =
26+
{
27+
weekly: { freq: ICalEventRepeatingFreq.WEEKLY },
28+
biweekly: { freq: ICalEventRepeatingFreq.WEEKLY, interval: 2 },
29+
};
30+
31+
function generateHostName(host: string) {
32+
if (host == "ACM" || !host) {
33+
return "ACM@UIUC";
34+
}
35+
if (host.includes("ACM")) {
36+
return host;
37+
}
38+
return `ACM@UIUC ${host}`;
39+
}
40+
41+
const icalPlugin: FastifyPluginAsync = async (fastify, _options) => {
42+
fastify.get("/:host?", async (request, reply) => {
43+
const host = (request.params as Record<string, string>).host;
44+
let queryParams: QueryCommandInput = {
45+
TableName: genericConfig.DynamoTableName,
46+
};
47+
let response;
48+
if (host) {
49+
if (!OrganizationList.includes(host)) {
50+
throw new ValidationError({
51+
message: `Invalid host parameter "${host}" in path.`,
52+
});
53+
}
54+
queryParams = {
55+
...queryParams,
56+
};
57+
response = await dynamoClient.send(
58+
new QueryCommand({
59+
...queryParams,
60+
ExpressionAttributeValues: {
61+
":host": {
62+
S: host,
63+
},
64+
},
65+
KeyConditionExpression: "host = :host",
66+
IndexName: "HostIndex",
67+
}),
68+
);
69+
} else {
70+
response = await dynamoClient.send(new ScanCommand(queryParams));
71+
}
72+
const dynamoItems = response.Items
73+
? response.Items.map((x) => unmarshall(x))
74+
: null;
75+
if (!dynamoItems) {
76+
throw new NotFoundError({
77+
endpointName: host ? `/api/v1/ical/${host}` : "/api/v1/ical",
78+
});
79+
}
80+
// generate friendly calendar name
81+
let calendarName =
82+
host && host.includes("ACM")
83+
? `${host} Events`
84+
: `ACM@UIUC - ${host} Events`;
85+
if (host == "ACM") {
86+
calendarName = "ACM@UIUC - Major Events";
87+
}
88+
if (!host) {
89+
calendarName = "ACM@UIUC - All Events";
90+
}
91+
const calendar = ical({ name: calendarName });
92+
calendar.timezone({
93+
name: "America/Chicago",
94+
generator: getVtimezoneComponent,
95+
});
96+
calendar.method(ICalCalendarMethod.PUBLISH);
97+
for (const rawEvent of dynamoItems) {
98+
let event = calendar.createEvent({
99+
start: moment.tz(rawEvent.start, "America/Chicago"),
100+
end: rawEvent.end
101+
? moment.tz(rawEvent.end, "America/Chicago")
102+
: moment.tz(rawEvent.start, "America/Chicago"),
103+
summary: rawEvent.title,
104+
description: rawEvent.locationLink
105+
? `Host: ${rawEvent.host}\nGoogle Maps Link: ${rawEvent.locationLink}\n\n` +
106+
rawEvent.description
107+
: `Host: ${rawEvent.host}\n\n` + rawEvent.description,
108+
timezone: "America/Chicago",
109+
organizer: generateHostName(host),
110+
id: rawEvent.id,
111+
});
112+
113+
if (rawEvent.repeats) {
114+
if (rawEvent.repeatEnds) {
115+
event = event.repeating({
116+
...repeatingIcalMap[rawEvent.repeats as EventRepeatOptions],
117+
until: moment.tz(rawEvent.repeatEnds, "America/Chicago"),
118+
});
119+
} else {
120+
event.repeating(
121+
repeatingIcalMap[rawEvent.repeats as EventRepeatOptions],
122+
);
123+
}
124+
}
125+
if (rawEvent.location) {
126+
event = event.location({
127+
title: rawEvent.location,
128+
});
129+
}
130+
}
131+
132+
reply
133+
.headers({
134+
"Content-Type": "text/calendar; charset=utf-8",
135+
"Content-Disposition": 'attachment; filename="calendar.ics"',
136+
})
137+
.send(calendar.toString());
138+
});
139+
};
140+
141+
export default icalPlugin;

tests/live/ical.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { expect, test } from "vitest";
2+
import { InternalServerError } from "../../src/errors/index.js";
3+
import { describe } from "node:test";
4+
import { OrganizationList } from "../../src/orgs.js";
5+
import ical from "node-ical";
6+
7+
const appKey = process.env.APPLICATION_KEY;
8+
if (!appKey) {
9+
throw new InternalServerError({ message: "No application key found" });
10+
}
11+
12+
const baseEndpoint = `https://${appKey}.aws.qa.acmuiuc.org`;
13+
test("getting all events", async () => {
14+
const response = await fetch(`${baseEndpoint}/api/v1/ical`);
15+
expect(response.status).toBe(200);
16+
});
17+
18+
describe("Getting specific calendars", async () => {
19+
for (const org of OrganizationList) {
20+
test(`Get ${org} calendar`, async () => {
21+
const response = await fetch(`${baseEndpoint}/api/v1/ical/${org}`);
22+
expect(response.status).toBe(200);
23+
expect(response.headers.get("Content-Disposition")).toEqual(
24+
'attachment; filename="calendar.ics"',
25+
);
26+
const calendar = ical.sync.parseICS(await response.text());
27+
expect(calendar["vcalendar"]["type"]).toEqual("VCALENDAR");
28+
});
29+
}
30+
});

tests/unit/data/acmWideCalendar.ics

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
BEGIN:VCALENDAR
2+
VERSION:2.0
3+
PRODID:-//sebbo.net//ical-generator//EN
4+
METHOD:PUBLISH
5+
NAME:ACM@UIUC - All Events
6+
X-WR-CALNAME:ACM@UIUC - All Events
7+
BEGIN:VTIMEZONE
8+
TZID:America/Chicago
9+
TZURL:http://tzurl.org/zoneinfo-outlook/America/Chicago
10+
X-LIC-LOCATION:America/Chicago
11+
BEGIN:DAYLIGHT
12+
TZOFFSETFROM:-0600
13+
TZOFFSETTO:-0500
14+
TZNAME:CDT
15+
DTSTART:19700308T020000
16+
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
17+
END:DAYLIGHT
18+
BEGIN:STANDARD
19+
TZOFFSETFROM:-0500
20+
TZOFFSETTO:-0600
21+
TZNAME:CST
22+
DTSTART:19701101T020000
23+
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
24+
END:STANDARD
25+
END:VTIMEZONE
26+
TIMEZONE-ID:America/Chicago
27+
X-WR-TIMEZONE:America/Chicago
28+
BEGIN:VEVENT
29+
UID:3138bead-b2c5-4bfe-bce4-4b478658cb78
30+
SEQUENCE:0
31+
DTSTAMP:20240822T155148
32+
DTSTART;TZID=America/Chicago:20240825T120000
33+
DTEND;TZID=America/Chicago:20240825T160000
34+
SUMMARY:Quad Day
35+
LOCATION:Main Quad
36+
DESCRIPTION:Host: ACM\nGoogle Maps Link: https://maps.app.goo.gl/2ZRYibtE7
37+
Yem5TrP6\n\nJoin us on Quad Day to learn more about ACM and CS at Illinois
38+
!
39+
ORGANIZER;CN="ACM@UIUC":mailto:ACM@UIUC
40+
END:VEVENT
41+
BEGIN:VEVENT
42+
UID:5bc69f3b-e958-4c80-b041-ddeae0385db8
43+
SEQUENCE:0
44+
DTSTAMP:20240822T155148
45+
DTSTART;TZID=America/Chicago:20240725T180000
46+
DTEND;TZID=America/Chicago:20240725T190000
47+
RRULE:FREQ=WEEKLY;UNTIL=20240905T190000
48+
SUMMARY:Infra Meeting
49+
LOCATION:ACM Middle Room
50+
DESCRIPTION:Host: Infrastructure Committee\n\nTest event.
51+
ORGANIZER;CN="ACM@UIUC":mailto:ACM@UIUC
52+
END:VEVENT
53+
BEGIN:VEVENT
54+
UID:4d38608d-90bf-4a58-8701-3f1b659a53db
55+
SEQUENCE:0
56+
DTSTAMP:20240822T155148
57+
DTSTART;TZID=America/Chicago:20240925T180000
58+
DTEND;TZID=America/Chicago:20240925T190000
59+
SUMMARY:Testing Paid and Featured Event
60+
LOCATION:ACM Middle Room
61+
DESCRIPTION:Host: Social Committee\n\nTest paid featured event.
62+
ORGANIZER;CN="ACM@UIUC":mailto:ACM@UIUC
63+
END:VEVENT
64+
BEGIN:VEVENT
65+
UID:accd7fe0-50ac-427b-8041-a2b3ddcd328e
66+
SEQUENCE:0
67+
DTSTAMP:20240822T155148
68+
DTSTART;TZID=America/Chicago:20240725T180000
69+
DTEND;TZID=America/Chicago:20240725T190000
70+
SUMMARY:Event in the past.
71+
LOCATION:ACM Middle Room
72+
DESCRIPTION:Host: Infrastructure Committee\n\nTest event in the past.
73+
ORGANIZER;CN="ACM@UIUC":mailto:ACM@UIUC
74+
END:VEVENT
75+
BEGIN:VEVENT
76+
UID:78be8f2b-3d1d-4481-90b6-85bfd84d38b4
77+
SEQUENCE:0
78+
DTSTAMP:20240822T155148
79+
DTSTART;TZID=America/Chicago:20240830T170000
80+
DTEND;TZID=America/Chicago:20240830T170000
81+
RRULE:FREQ=WEEKLY
82+
SUMMARY:Weekly Happy Hour
83+
LOCATION:Legends
84+
DESCRIPTION:Host: ACM\nGoogle Maps Link: https://goo.gl/maps/CXESXd3otbGZN
85+
qFP7\n\nMeet and chat with your peers and fellow ACM members\, with food o
86+
n us!
87+
ORGANIZER;CN="ACM@UIUC":mailto:ACM@UIUC
88+
END:VEVENT
89+
END:VCALENDAR

tests/unit/eventPost.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ test("Happy path: Adding a non-repeating, featured, paid event", async () => {
144144
featured: true,
145145
host: "Social Committee",
146146
location: "Illini Union",
147+
locationLink: "https://maps.app.goo.gl/rUBhjze5mWuTSUJK9",
147148
start: "2024-09-25T18:00:00",
148149
title: "Fall Semiformal",
149150
paidEventId: "sp24_semiformal",

0 commit comments

Comments
 (0)