feat: add iCal export for reservations

This commit is contained in:
dualfroz committed 2026-08-27 12:16:14 +02:00
1 parent aec31a4555
commit 2bdd119246
2 files changed
+120

No files matched your search

@@ -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<String> 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<String> forCaller(@AuthenticationPrincipal AuthenticatedUser actor) {
String body = icalService.forUser(actor.id());
return calendar(body, "my-reservations.ics");
}
private ResponseEntity<String> calendar(String body, String filename) {
return ResponseEntity.ok()
.contentType(calendarType)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.body(body);
}
}
@@ -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<Reservation> active = reservations
.findByResourceIdAndStatusOrderByStartTimeAsc(resource.getId(), ReservationStatus.ACTIVE);
return render("resource-" + resource.getId(), active);
}
@Transactional(readOnly = true)
public String forUser(Long userId) {
List<Reservation> active =
reservations.findByUserIdAndStatusOrderByStartTimeAsc(userId, ReservationStatus.ACTIVE);
return render("user-" + userId, active);
}
private String render(String feedName, List<Reservation> 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");
}
}