GoogleCalendarService.java
package com.soen390.backend.service;
import com.soen390.backend.object.GoogleCalendarDto;
import com.soen390.backend.object.GoogleTokenSession;
import com.soen390.backend.object.GoogleEventDto;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class GoogleCalendarService {
private static final Pattern ROOM_PATTERN = Pattern.compile("\\bRm\\.?\\s*([-A-Z0-9.]+)\\b", Pattern.CASE_INSENSITIVE);
private static final Pattern CLASSROOM_PATTERN = Pattern.compile("Classroom:\\s*([-A-Z0-9.]+)", Pattern.CASE_INSENSITIVE);
private static final String NO_CAMPUS = "(no campus)";
private static final String NO_BUILDING = "(no building)";
private static final String MISSING = "(missing)";
private static final String NO_NAME = "(no name)";
private static final String NO_TITLE = "(no title)";
private final RestTemplate restTemplate;
private final GoogleSessionService sessionService;
public GoogleCalendarService(RestTemplate restTemplate, GoogleSessionService sessionService) {
this.restTemplate = restTemplate;
this.sessionService = sessionService;
}
public List<GoogleCalendarDto> listCalendars(String sessionId) {
GoogleTokenSession session = sessionService.require(sessionId);
// MVP: if expired, force re-login (we’ll add refresh next step)
if (session.getExpiresAt() != null && Instant.now().isAfter(session.getExpiresAt())) {
throw new IllegalStateException("Session expired. Please sign in again.");
}
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(session.getAccessToken());
String url = "https://www.googleapis.com/calendar/v3/users/me/calendarList";
ResponseEntity<Map<String, Object>> res = restTemplate.exchange(
url,
HttpMethod.GET,
new HttpEntity<>(headers),
new ParameterizedTypeReference<Map<String, Object>>() {}
);
if (!res.getStatusCode().is2xxSuccessful() || res.getBody() == null) {
throw new IllegalStateException("Failed to fetch calendar list from Google.");
}
Object itemsObj = res.getBody().get("items");
if (!(itemsObj instanceof List<?> items)) return List.of();
List<GoogleCalendarDto> out = new ArrayList<>();
for (Object o : items) {
if (!(o instanceof Map<?, ?> m)) continue;
String id = (String) m.get("id");
String summary = (String) m.get("summary");
Boolean primary = (Boolean) m.get("primary");
if (id != null) {
out.add(new GoogleCalendarDto(
id,
summary != null ? summary : NO_NAME,
primary != null && primary
));
}
}
return out;
}
public List<GoogleEventDto> importEvents(String sessionId, String calendarId, int days, String timeZone) {
GoogleTokenSession session = sessionService.require(sessionId);
String url = buildImportEventsUrl(calendarId, days, timeZone);
HttpHeaders headers = buildBearerHeaders(session.getAccessToken());
try {
List<?> items = fetchEventItems(url, headers);
return toEventDtos(items);
} catch (HttpStatusCodeException e) {
// This is the key: expose what Google actually said
throw new IllegalStateException(
"Google Events API error (" + e.getStatusCode() + "): " + e.getResponseBodyAsString(),
e
);
}
}
private String buildImportEventsUrl(String calendarId, int days, String timeZone) {
Instant timeMin = Instant.now();
Instant timeMax = timeMin.plusSeconds((long) days * 24 * 60 * 60);
// IMPORTANT: don't manually URLEncode calendarId in the path; let Spring encode it correctly
return UriComponentsBuilder
.fromUriString("https://www.googleapis.com/calendar/v3/calendars/{calendarId}/events")
.queryParam("timeMin", timeMin.toString())
.queryParam("timeMax", timeMax.toString())
.queryParam("singleEvents", "true")
.queryParam("orderBy", "startTime")
.queryParam("maxResults", "250")
.queryParam("timeZone", timeZone)
.buildAndExpand(calendarId)
.encode()
.toUriString();
}
private HttpHeaders buildBearerHeaders(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
return headers;
}
private List<?> fetchEventItems(String url, HttpHeaders headers) {
ResponseEntity<Map<String, Object>> res = restTemplate.exchange(
url,
HttpMethod.GET,
new HttpEntity<>(headers),
new ParameterizedTypeReference<Map<String, Object>>() {}
);
Map<String, Object> body = res.getBody();
Object itemsObj = (body != null) ? body.get("items") : null;
if (!(itemsObj instanceof List<?> items)) {
return List.of();
}
return items;
}
private List<GoogleEventDto> toEventDtos(List<?> items) {
List<GoogleEventDto> out = new ArrayList<>();
for (Object item : items) {
if (!(item instanceof Map<?, ?> event)) {
continue;
}
out.add(toEventDto(event));
}
return out;
}
private GoogleEventDto toEventDto(Map<?, ?> event) {
String id = (String) event.get("id");
String summary = (String) event.get("summary");
String location = (String) event.get("location");
Map<?, ?> start = (Map<?, ?>) event.get("start");
Map<?, ?> end = (Map<?, ?>) event.get("end");
String startDate = getStringValue(start, "date");
String endDate = getStringValue(end, "date");
boolean allDay = startDate != null && endDate != null;
String startIso;
String endIso;
if (allDay) {
startIso = startDate;
endIso = endDate;
} else {
startIso = getStringValue(start, "dateTime");
endIso = getStringValue(end, "dateTime");
}
return new GoogleEventDto(
id,
summary != null ? summary : NO_TITLE,
location,
startIso,
endIso,
allDay
);
}
private String getStringValue(Map<?, ?> source, String key) {
if (source == null || key == null) {
return null;
}
Object value = source.get(key);
return (value instanceof String str) ? str : null;
}
public GoogleEventDto getNextEvent(String sessionId, String calendarId, int days, String timeZone) {
List<GoogleEventDto> events = importEvents(sessionId, calendarId, days, timeZone);
Instant now = Instant.now();
for (GoogleEventDto event : events) {
Instant eventStart = toEventStartInstant(event, timeZone);
if (eventStart != null && eventStart.isAfter(now)) {
return event;
}
}
return null;
}
public void setSelectedCalendar(String sessionId, GoogleCalendarDto selectedCalendar) {
GoogleTokenSession session = sessionService.require(sessionId);
if (selectedCalendar == null || selectedCalendar.getId() == null || selectedCalendar.getId().isBlank()) {
throw new IllegalArgumentException("calendar.id is required.");
}
session.setSelectedCalendarId(selectedCalendar.getId());
session.setSelectedCalendarSummary(
selectedCalendar.getSummary() != null ? selectedCalendar.getSummary() : NO_NAME
);
session.setSelectedCalendarPrimary(selectedCalendar.isPrimary());
sessionService.put(sessionId, session);
}
public Map<String, Object> getState(String sessionId, int days, String timeZone) {
return getState(sessionId, days, timeZone, false);
}
public Map<String, Object> getState(String sessionId, int days, String timeZone, boolean includeCalendars) {
GoogleTokenSession session = sessionService.require(sessionId);
if (session.getExpiresAt() != null && Instant.now().isAfter(session.getExpiresAt())) {
throw new IllegalStateException("Session expired. Please sign in again.");
}
GoogleCalendarDto selectedCalendar = null;
GoogleEventDto nextEvent = null;
boolean calendarSelected = session.getSelectedCalendarId() != null && !session.getSelectedCalendarId().isBlank();
if (calendarSelected) {
selectedCalendar = new GoogleCalendarDto(
session.getSelectedCalendarId(),
session.getSelectedCalendarSummary() != null ? session.getSelectedCalendarSummary() : NO_NAME,
session.isSelectedCalendarPrimary()
);
nextEvent = getNextEvent(sessionId, selectedCalendar.getId(), days, timeZone);
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("connected", true);
out.put("calendarSelected", calendarSelected);
out.put("selectedCalendar", selectedCalendar);
out.put("nextEvent", nextEvent);
out.put(
"nextEventDetailsText",
nextEvent != null ? formatEventDetailsText(nextEvent, timeZone) : "No upcoming events found in the next 7 days."
);
if (includeCalendars) {
List<GoogleCalendarDto> calendars = listCalendars(sessionId);
out.put("calendars", calendars);
}
return out;
}
private Instant toEventStartInstant(GoogleEventDto event, String timeZone) {
if (event == null || event.getStart() == null || event.getStart().isBlank()) {
return null;
}
String start = event.getStart();
if (event.isAllDay()) {
try {
ZoneId zone = ZoneId.of(timeZone);
return LocalDate.parse(start).atStartOfDay(zone).toInstant();
} catch (RuntimeException e) {
return null;
}
}
try {
return OffsetDateTime.parse(start).toInstant();
} catch (DateTimeParseException e) {
try {
return Instant.parse(start);
} catch (DateTimeParseException ignored) {
return null;
}
}
}
private String formatEventDetailsText(GoogleEventDto event, String timeZone) {
LocationParts location = parseBuildingAndRoom(event != null ? event.getLocation() : null);
String when = formatWhen(event, timeZone);
return location.campus() + "\n" + location.building() + "\nClassroom: " + location.room() + "\n" + when;
}
private LocationParts parseBuildingAndRoom(String locationRaw) {
String location = locationRaw != null ? locationRaw.trim() : "";
if (location.isEmpty()) {
return new LocationParts(NO_CAMPUS, NO_BUILDING, MISSING);
}
Matcher rmMatch = ROOM_PATTERN.matcher(location);
if (rmMatch.find()) {
return parseFromRoomPattern(location, rmMatch);
}
Matcher classroomMatch = CLASSROOM_PATTERN.matcher(location);
if (classroomMatch.find()) {
return parseFromClassroomPattern(classroomMatch);
}
return new LocationParts(NO_CAMPUS, location, MISSING);
}
private LocationParts parseFromRoomPattern(String location, Matcher rmMatch) {
String room = rmMatch.group(1) != null ? rmMatch.group(1).trim() : MISSING;
String buildingLine = location.replace(rmMatch.group(0), "").trim();
String campus = NO_CAMPUS;
String building = buildingLine;
String[] parts = buildingLine.split("-", 2);
if (parts.length == 2) {
campus = parts[0].trim();
building = parts[1].trim();
}
if (campus.isEmpty()) campus = NO_CAMPUS;
if (building.isEmpty()) building = NO_BUILDING;
if (room.isEmpty()) room = MISSING;
return new LocationParts(campus, building, room);
}
private LocationParts parseFromClassroomPattern(Matcher classroomMatch) {
String room = classroomMatch.group(1) != null ? classroomMatch.group(1).trim() : MISSING;
if (room.isEmpty()) room = MISSING;
return new LocationParts(NO_CAMPUS, NO_BUILDING, room);
}
private String formatWhen(GoogleEventDto event, String timeZone) {
if (event == null) {
return "(missing time)";
}
if (event.isAllDay()) {
return "All day";
}
Instant start = parseEventInstant(event.getStart());
Instant end = parseEventInstant(event.getEnd());
if (start == null || end == null) {
return "(missing time)";
}
ZoneId zone = ZoneId.of(timeZone);
ZonedDateTime startZdt = start.atZone(zone);
ZonedDateTime endZdt = end.atZone(zone);
String day = DateTimeFormatter.ofPattern("EEE", Locale.CANADA).format(startZdt);
String startT = DateTimeFormatter.ofPattern("HH:mm", Locale.CANADA).format(startZdt);
String endT = DateTimeFormatter.ofPattern("HH:mm", Locale.CANADA).format(endZdt);
return day + ", " + startT + " - " + endT;
}
private Instant parseEventInstant(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return OffsetDateTime.parse(value).toInstant();
} catch (DateTimeParseException e) {
try {
return Instant.parse(value);
} catch (DateTimeParseException ignored) {
return null;
}
}
}
private record LocationParts(String campus, String building, String room) {}
}