diff --git a/src/main/java/com/slotline/ical/ICalController.java b/src/main/java/com/slotline/ical/ICalController.java new file mode 100644 index 0000000..cdeb0df --- /dev/null +++ b/src/main/java/com/slotline/ical/ICalController.java @@ -0,0 +1,47 @@ +package com.slotline.ical; + +import com.slotline.security.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Tag(name = "iCal export") +public class ICalController { + + private static final MediaType calendarType = MediaType.parseMediaType("text/calendar;charset=UTF-8"); + + private final ICalService icalService; + + public ICalController(ICalService icalService) { + this.icalService = icalService; + } + + @GetMapping("/api/resources/{resourceId}/calendar.ics") + @Operation(summary = "iCal (.ics) feed of active reservations for a resource") + public ResponseEntity forResource(@AuthenticationPrincipal AuthenticatedUser actor, + @PathVariable Long resourceId) { + String body = icalService.forResource(actor.organizationId(), resourceId); + return calendar(body, "resource-" + resourceId + ".ics"); + } + + @GetMapping("/api/reservations/calendar.ics") + @Operation(summary = "iCal (.ics) feed of the caller's active reservations") + public ResponseEntity forCaller(@AuthenticationPrincipal AuthenticatedUser actor) { + String body = icalService.forUser(actor.id()); + return calendar(body, "my-reservations.ics"); + } + + private ResponseEntity calendar(String body, String filename) { + return ResponseEntity.ok() + .contentType(calendarType) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .body(body); + } +} diff --git a/src/main/java/com/slotline/ical/ICalService.java b/src/main/java/com/slotline/ical/ICalService.java new file mode 100644 index 0000000..bb1f3c0 --- /dev/null +++ b/src/main/java/com/slotline/ical/ICalService.java @@ -0,0 +1,73 @@ +package com.slotline.ical; + +import com.slotline.reservation.Reservation; +import com.slotline.reservation.ReservationRepository; +import com.slotline.reservation.ReservationStatus; +import com.slotline.resource.Resource; +import com.slotline.resource.ResourceService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +public class ICalService { + + private static final DateTimeFormatter icalTimestamp = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC); + private static final String lineBreak = "\r\n"; + + private final ReservationRepository reservations; + private final ResourceService resources; + + public ICalService(ReservationRepository reservations, ResourceService resources) { + this.reservations = reservations; + this.resources = resources; + } + + @Transactional(readOnly = true) + public String forResource(Long organizationId, Long resourceId) { + Resource resource = resources.require(organizationId, resourceId); + List active = reservations + .findByResourceIdAndStatusOrderByStartTimeAsc(resource.getId(), ReservationStatus.ACTIVE); + return render("resource-" + resource.getId(), active); + } + + @Transactional(readOnly = true) + public String forUser(Long userId) { + List active = + reservations.findByUserIdAndStatusOrderByStartTimeAsc(userId, ReservationStatus.ACTIVE); + return render("user-" + userId, active); + } + + private String render(String feedName, List active) { + StringBuilder calendar = new StringBuilder(); + calendar.append("BEGIN:VCALENDAR").append(lineBreak); + calendar.append("VERSION:2.0").append(lineBreak); + calendar.append("PRODID:-//slotline//reservation feed//EN").append(lineBreak); + calendar.append("CALSCALE:GREGORIAN").append(lineBreak); + calendar.append("X-WR-CALNAME:slotline ").append(feedName).append(lineBreak); + String stamp = icalTimestamp.format(Instant.now()); + for (Reservation reservation : active) { + calendar.append("BEGIN:VEVENT").append(lineBreak); + calendar.append("UID:reservation-").append(reservation.getId()).append("@slotline").append(lineBreak); + calendar.append("DTSTAMP:").append(stamp).append(lineBreak); + calendar.append("DTSTART:").append(icalTimestamp.format(reservation.getStartTime())).append(lineBreak); + calendar.append("DTEND:").append(icalTimestamp.format(reservation.getEndTime())).append(lineBreak); + calendar.append("SUMMARY:").append(escape(reservation.getResource().getName())).append(lineBreak); + calendar.append("END:VEVENT").append(lineBreak); + } + calendar.append("END:VCALENDAR").append(lineBreak); + return calendar.toString(); + } + + private String escape(String text) { + return text.replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\n", "\\n"); + } +}