Skip to content

Commit 3efbfe0

Browse files
committed
build ical support
1 parent 295e1a6 commit 3efbfe0

File tree

7 files changed

+173
-8
lines changed

7 files changed

+173
-8
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,13 @@
5656
"@fastify/auth": "^4.6.1",
5757
"@fastify/aws-lambda": "^4.1.0",
5858
"@fastify/cors": "^9.0.1",
59+
"@touch4it/ical-timezones": "^1.9.0",
5960
"fastify": "^4.28.1",
6061
"fastify-plugin": "^4.5.1",
62+
"ical-generator": "^7.2.0",
6163
"jsonwebtoken": "^9.0.2",
6264
"jwks-rsa": "^3.1.0",
65+
"moment": "^2.30.1",
6366
"moment-timezone": "^0.5.45",
6467
"zod": "^3.23.8",
6568
"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: 10 additions & 6 deletions
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
});
@@ -91,11 +92,14 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
9192
await dynamoClient.send(
9293
new PutItemCommand({
9394
TableName: genericConfig.DynamoTableName,
94-
Item: marshall({
95-
...request.body,
96-
id: entryUUID,
97-
createdBy: request.username,
98-
}),
95+
Item: marshall(
96+
{
97+
...request.body,
98+
id: entryUUID,
99+
createdBy: request.username,
100+
},
101+
{ removeUndefinedValues: true },
102+
),
99103
}),
100104
);
101105
reply.send({

src/routes/ics.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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) {
86+
calendarName = "ACM@UIUC - All Events";
87+
}
88+
const calendar = ical({ name: calendarName });
89+
calendar.timezone({
90+
name: "America/Chicago",
91+
generator: getVtimezoneComponent,
92+
});
93+
calendar.method(ICalCalendarMethod.PUBLISH);
94+
for (const rawEvent of dynamoItems) {
95+
let event = calendar.createEvent({
96+
start: moment.tz(rawEvent.start, "America/Chicago"),
97+
end: rawEvent.end
98+
? moment.tz(rawEvent.end, "America/Chicago")
99+
: moment.tz(rawEvent.start, "America/Chicago"),
100+
summary: rawEvent.title,
101+
description: rawEvent.locationLink
102+
? `Host: ${rawEvent.host}\nGoogle Maps Link: ${rawEvent.locationLink}\n\n` +
103+
rawEvent.description
104+
: `Host: ${rawEvent.host}\n\n` + rawEvent.description,
105+
timezone: "America/Chicago",
106+
organizer: generateHostName(host),
107+
id: rawEvent.id,
108+
});
109+
110+
if (rawEvent.repeats) {
111+
if (rawEvent.repeatEnds) {
112+
event = event.repeating({
113+
...repeatingIcalMap[rawEvent.repeats as EventRepeatOptions],
114+
until: moment.tz(rawEvent.repeatEnds, "America/Chicago"),
115+
});
116+
} else {
117+
event.repeating(
118+
repeatingIcalMap[rawEvent.repeats as EventRepeatOptions],
119+
);
120+
}
121+
}
122+
if (rawEvent.location) {
123+
event = event.location({
124+
title: rawEvent.location,
125+
});
126+
}
127+
}
128+
129+
reply
130+
.headers({
131+
"Content-Type": "text/calendar; charset=utf-8",
132+
"Content-Disposition": 'attachment; filename="calendar.ics"',
133+
})
134+
.send(calendar.toString());
135+
});
136+
};
137+
138+
export default icalPlugin;

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",

yarn.lock

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1521,6 +1521,11 @@
15211521
"@smithy/types" "^3.3.0"
15221522
tslib "^2.6.2"
15231523

1524+
"@touch4it/ical-timezones@^1.9.0":
1525+
version "1.9.0"
1526+
resolved "https://registry.yarnpkg.com/@touch4it/ical-timezones/-/ical-timezones-1.9.0.tgz#bbd85014f55b5cc3e9079ed7caccd8649b5170a3"
1527+
integrity sha512-UAiZMrFlgMdOIaJDPsKu5S7OecyMLr3GGALJTYkRgHmsHAA/8Ixm1qD09ELP2X7U1lqgrctEgvKj9GzMbczC+g==
1528+
15241529
"@tsconfig/node20@^20.1.4":
15251530
version "20.1.4"
15261531
resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.4.tgz#3457d42eddf12d3bde3976186ab0cd22b85df928"
@@ -3541,6 +3546,13 @@ husky@^9.1.4:
35413546
resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.4.tgz#926fd19c18d345add5eab0a42b2b6d9a80259b34"
35423547
integrity sha512-bho94YyReb4JV7LYWRWxZ/xr6TtOTt8cMfmQ39MQYJ7f/YE268s3GdghGwi+y4zAeqewE5zYLvuhV0M0ijsDEA==
35433548

3549+
ical-generator@^7.2.0:
3550+
version "7.2.0"
3551+
resolved "https://registry.yarnpkg.com/ical-generator/-/ical-generator-7.2.0.tgz#45589146e81693065a39c6f42007abe34e07c4b9"
3552+
integrity sha512-7I34QvxWqIRthaao81lmapa0OjftfDaSBZmADjV0IqxVMUWT5ywlATRsv/hZN9Rgf2VgRsnMY+xUUaA4ZvAJLA==
3553+
dependencies:
3554+
uuid-random "^1.3.2"
3555+
35443556
iconv-lite@^0.4.24:
35453557
version "0.4.24"
35463558
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -4226,7 +4238,7 @@ moment-timezone@^0.5.45:
42264238
dependencies:
42274239
moment "^2.29.4"
42284240

4229-
moment@^2.29.4:
4241+
moment@^2.29.4, moment@^2.30.1:
42304242
version "2.30.1"
42314243
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
42324244
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==
@@ -5467,6 +5479,11 @@ uri-js@^4.2.2:
54675479
dependencies:
54685480
punycode "^2.1.0"
54695481

5482+
uuid-random@^1.3.2:
5483+
version "1.3.2"
5484+
resolved "https://registry.yarnpkg.com/uuid-random/-/uuid-random-1.3.2.tgz#96715edbaef4e84b1dcf5024b00d16f30220e2d0"
5485+
integrity sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ==
5486+
54705487
uuid@^3.3.2:
54715488
version "3.4.0"
54725489
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"

0 commit comments

Comments
 (0)