@@ -2,59 +2,119 @@ import { Calendar, ONE_MINUTE_MS, parseCalendar } from 'iamcal'
22import { NextApiRequest , NextApiResponse } from 'next'
33import { 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. */
52108async 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+ */
58118async 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+ */
72144async 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+ */
92166function 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
147234export 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