Skip to content

Commit 4e8fd78

Browse files
committed
Cleanup schema.ics.ts
Add documentation, fix bugs and optimize code
1 parent 8646e6d commit 4e8fd78

File tree

1 file changed

+163
-73
lines changed

1 file changed

+163
-73
lines changed

pages/api/schema.ics.ts

Lines changed: 163 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -2,59 +2,119 @@ import { Calendar, ONE_MINUTE_MS, parseCalendar } from 'iamcal'
22
import { NextApiRequest, NextApiResponse } from 'next'
33
import { prisma } from '../../prisma/prismaclient'
44

5-
/** Represents the default calendar cache expiration time, in minutes. */
6-
const DEFAULT_CALENDAR_CACHE_EXPIRATION = 180 // Three hours in minutes
5+
/** Represents the default expiration time of the calendar cache, in minutes. */
6+
const DEFAULT_CALENDAR_CACHE_EXPIRATION_TIME = 180 // Three hours in minutes
77

8-
/** Represents the calendar cache expiration time, in minutes. */
9-
const calendarCacheExpiration = getCalendarCacheExpiration()
8+
/** Represents the expiration time of the calendar cache, in minutes. */
9+
const calendarCacheExpirationTime = getCalendarCacheExpirationTime()
1010

11-
function getCalendarCacheExpiration(
12-
defaultMinutes: number = DEFAULT_CALENDAR_CACHE_EXPIRATION
11+
/**
12+
* Get calendar cache expiration time from the environment.
13+
* @param defaultExpirationMinutes The time to return if `CALENDAR_CACHE_EXPIRATION_TIME` is unset or invalid.
14+
* @returns The parsed value of `CALENDAR_CACHE_EXPIRATION_TIME` or the default time, in minutes.
15+
*/
16+
function getCalendarCacheExpirationTime(
17+
defaultExpirationMinutes: number = DEFAULT_CALENDAR_CACHE_EXPIRATION_TIME
1318
) {
14-
try {
15-
if (process.env.CALENDAR_CACHE_EXPIRATION) {
16-
return parseInt(process.env.CALENDAR_CACHE_EXPIRATION)
17-
} else {
18-
return defaultMinutes
19+
const environmentExpirationTime = process.env.CALENDAR_CACHE_EXPIRATION_TIME
20+
21+
if (environmentExpirationTime) {
22+
try {
23+
return parseInt(environmentExpirationTime)
24+
} catch {
25+
console.warn(
26+
`Failed to parse calendar cache expiration time "${environmentExpirationTime}" ` +
27+
`from environment, should be an integer representing the time in minutes. ` +
28+
`Using default time of ${defaultExpirationMinutes} minutes.`
29+
)
1930
}
20-
} catch {
21-
console.warn(
22-
`Failed to parse calendar cache expiration "${process.env.CALENDAR_CACHE_EXPIRATION}", ` +
23-
`should be an integer representing the time in minutes. `+
24-
`Using default time of ${defaultMinutes} minutes.`
25-
)
26-
return defaultMinutes
2731
}
32+
33+
return defaultExpirationMinutes
2834
}
2935

30-
let cachedCalendarBody: string | null = null
31-
let cachedCalendarTime: number = 0
32-
let cachedCalendarUrls: string = ''
36+
class CachedCalendar {
37+
/** The serialized body of the calendar in iCalendar format. */
38+
calendarBody: string
39+
/** The URLs this calendar was created from. */
40+
urls: string[]
41+
/** How long after creation the cache will be considered stale, in minutes. */
42+
expirationTimeMinutes: number
43+
/** The time when this cache was created. Expressed in milliseconds elapsed since midnight, January 1, 1970 Universal Coordinated Time (UTC). */
44+
time: number
45+
46+
/**
47+
* @param calendarBody The serialized body of the calendar in iCalendar format.
48+
* @param urls The URLs this calendar was created from.
49+
* @param expirationTime How long after creation the cache will be considered stale, in minutes.
50+
*/
51+
constructor(
52+
calendarBody: string,
53+
urls: string[],
54+
expirationTime: number = calendarCacheExpirationTime
55+
) {
56+
this.calendarBody = calendarBody
57+
this.urls = urls
58+
this.expirationTimeMinutes = expirationTime
59+
this.time = Date.now()
60+
}
61+
62+
/**
63+
* Get the age of this cache.
64+
* @returns The age of this cache, in minutes rounded down.
65+
*/
66+
getAgeMinutes(): number {
67+
return Math.floor((Date.now() - this.time) / ONE_MINUTE_MS)
68+
}
3369

34-
/** Get the cached calendar body, or nothing if unset or expired. */
35-
async function getCachedCalendarBody(): Promise<string | null> {
36-
const cacheAgeMinutes = (Date.now() - cachedCalendarTime) / ONE_MINUTE_MS
37-
if (cacheAgeMinutes >= calendarCacheExpiration) {
38-
// Cache invalidated by expiration time
39-
return null
70+
/**
71+
* Check if this cache is expired.
72+
* @returns `true` if expired, `false` otherwise.
73+
*/
74+
isExpired(): boolean {
75+
return this.getAgeMinutes() >= this.expirationTimeMinutes
4076
}
4177

42-
const calendarUrls = await (await getCalendarUrls()).join()
43-
if (calendarUrls != cachedCalendarUrls) {
44-
// Cache invalidated by changed URLs
45-
return null
78+
/**
79+
* Check if this cache is invalidated by changed URLs.
80+
* @param urls The current calendar URLs.
81+
* @returns `true` if the URLs have changed, `false` otherwise.
82+
*/
83+
isInvalidated(urls: string[]): boolean {
84+
return this.urls.join() !== urls.join()
4685
}
4786

48-
return cachedCalendarBody
87+
/**
88+
* Get the cached calendar body if not stale.
89+
* @returns The cached calendar body or nothing if stale.
90+
*/
91+
async getCalendarBody(): Promise<string | null> {
92+
if (cachedCalendar === null || cachedCalendar.isExpired()) {
93+
return null
94+
}
95+
96+
const calendarUrls = await getCalendarUrls()
97+
if (cachedCalendar.isInvalidated(calendarUrls)) {
98+
return null
99+
}
100+
101+
return cachedCalendar.calendarBody
102+
}
49103
}
50104

51-
/** Save a calendar body to the cache and save the cached time. */
105+
let cachedCalendar: CachedCalendar | null = null
106+
107+
/** Save a calendar body to the cache. */
52108
async function cacheCalendarBody(calendarBody: string) {
53-
cachedCalendarBody = calendarBody
54-
cachedCalendarTime = Date.now()
55-
cachedCalendarUrls = (await getCalendarUrls()).join()
109+
const calendarUrls = await getCalendarUrls()
110+
cachedCalendar = new CachedCalendar(calendarBody, calendarUrls)
56111
}
57112

113+
/**
114+
* Get the calendar URLs from the database. Calendar links are all links that
115+
* start with `_kalender`.
116+
* @returns A string array containing the URLs.
117+
*/
58118
async function getCalendarUrls(): Promise<string[]> {
59119
const calendarLinks = await prisma.links.findMany({
60120
where: {
@@ -65,30 +125,44 @@ async function getCalendarUrls(): Promise<string[]> {
65125
})
66126

67127
const calendarUrls = calendarLinks.map(link => link.url)
128+
calendarUrls.forEach((url, index) => {
129+
if (calendarUrls.lastIndexOf(url) !== index) {
130+
throw new Error('Links contain duplicate calendar URLs. Calendar events must be unique.')
131+
}
132+
})
68133

69134
return calendarUrls
70135
}
71136

137+
/**
138+
* Fetch and parse a calendar hosted at `url`.
139+
* @param url The URL to fetch the calendar from.
140+
* @throws If the calendar cannot be fetched.
141+
* @throws If parsing the calendar text failed.
142+
* @returns The parsed calendar after fetching the URL.
143+
*/
72144
async function fetchCalendar(url: string): Promise<Calendar> {
73145
return fetch(url)
74-
.then(response => {
75-
if (!response.ok)
76-
throw new Error(`Failed fetching calendar: ${response}`)
77-
return response.text()
78-
})
79-
.then(text => {
80-
return parseCalendar(text)
81-
})
82-
.catch(reason => {
83-
throw reason
84-
})
146+
.then(response => {
147+
if (!response.ok)
148+
throw new Error(`Failed fetching calendar: ${response}`)
149+
return response.text()
150+
})
151+
.then(text => {
152+
return parseCalendar(text)
153+
})
154+
.catch(reason => {
155+
throw reason
156+
})
85157
}
86158

87159
/**
88-
* Merge multiple calendars together. The name will be taken from the primary calendar.
89-
* @param calendars The calendars to merge together, where the first is the primary calendar.
90-
* @throws If `calendars` is empty.
91-
*/
160+
* Merge multiple calendars together. The name and other properties will be
161+
* taken from the first calendar.
162+
* @param calendars The calendars to merge together.
163+
* @throws If `calendars` is empty.
164+
* @returns The merged calendar.
165+
*/
92166
function mergeCalendars(calendars: Calendar[]): Calendar {
93167
if (calendars.length === 0)
94168
throw new Error('At least one calendar must be provided')
@@ -104,10 +178,14 @@ function mergeCalendars(calendars: Calendar[]): Calendar {
104178
return mergedCalendar
105179
}
106180

107-
async function createMergedCalendar(
108-
productId: string
109-
): Promise<Calendar> {
110-
const calendar = await getCalendarUrls()
181+
/**
182+
* Create a merged calendar from the calendar links in the database.
183+
* @param productId The product id of the newly created calendar.
184+
* @throws If the calendar could not be created.
185+
* @returns A merged calendar generated from the links.
186+
*/
187+
async function createMergedCalendar(productId: string): Promise<Calendar> {
188+
const calendar: Calendar = await getCalendarUrls()
111189
.then(urls =>
112190
Promise.all(
113191
urls.map(url => {
@@ -118,44 +196,56 @@ async function createMergedCalendar(
118196
.then(calendars => {
119197
const isCalendar = (
120198
maybeCalendar: Calendar | undefined
121-
): maybeCalendar is Calendar => calendar !== undefined
122-
199+
): maybeCalendar is Calendar => maybeCalendar !== undefined
200+
123201
const filteredCalendars: Calendar[] = calendars.filter(isCalendar)
124202
return mergeCalendars(filteredCalendars)
125203
})
204+
.catch(reason => {
205+
throw reason
206+
})
207+
126208
calendar.setProductId(productId)
127209
return calendar
128210
}
129211

130212
/**
131213
* Create a merged calendar, or return the cached calendar if it exists. And cache
132214
* it if creating a new one.
133-
* @param productId The product id of the calendar if creating a new one.
134215
* @returns The new merged calendar, or the cached calendar if it exists and is not expired.
135216
*/
136-
async function createCalendarBodyWithCache(productId: string): Promise<string> {
137-
const cached = await getCachedCalendarBody()
217+
async function getCalendarBodyWithCache(): Promise<string> {
218+
const cached = await cachedCalendar?.getCalendarBody()
138219
if (cached) {
139220
return cached
140221
}
141-
const mergedCalendar = await createMergedCalendar(productId)
142-
const calendarBody = mergedCalendar.serialize()
222+
223+
const productId = '-//Teknologsektionen Informationsteknik//nollk.it//SV'
224+
const calendarBody = await createMergedCalendar(productId)
225+
.then(calendar => calendar.serialize())
226+
.catch(reason => {
227+
throw new Error(`Failed to create calendar body: ${reason}`)
228+
})
229+
143230
cacheCalendarBody(calendarBody)
144231
return calendarBody
145232
}
146233

147234
export default async function handler(
148-
req: NextApiRequest,
149-
res: NextApiResponse
235+
req: NextApiRequest,
236+
res: NextApiResponse
150237
) {
151-
const productId = '-//Teknologsektionen Informationsteknik//nollk.it//SV'
152-
const calendarBody: string = await createCalendarBodyWithCache(productId)
153-
.catch(reason => {
154-
console.warn(`Failed to create merged calendar: ${reason}`)
155-
return new Calendar(productId).serialize()
238+
try {
239+
const calendarBody = await getCalendarBodyWithCache()
240+
res.setHeader('Content-Type', 'text/calendar')
241+
res.setHeader('Content-Disposition', 'attachment; filename=schema.ics')
242+
res.status(200).end(calendarBody)
243+
} catch (e) {
244+
res.status(500).json({
245+
error: {
246+
message: "Failed to get calendar content",
247+
details: String(e)
248+
}
156249
})
157-
158-
res.setHeader('Content-Type', 'text/calendar')
159-
res.setHeader('Content-Disposition', 'attachment; filename=schema.ics')
160-
res.status(200).send(calendarBody)
250+
}
161251
}

0 commit comments

Comments
 (0)