WebhookController.java

package edu.ucsb.cs156.frontiers.controllers;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import edu.ucsb.cs156.frontiers.entities.Course;
import edu.ucsb.cs156.frontiers.entities.CourseStaff;
import edu.ucsb.cs156.frontiers.entities.RosterStudent;
import edu.ucsb.cs156.frontiers.enums.OrgStatus;
import edu.ucsb.cs156.frontiers.repositories.CourseRepository;
import edu.ucsb.cs156.frontiers.repositories.CourseStaffRepository;
import edu.ucsb.cs156.frontiers.repositories.RosterStudentRepository;
import edu.ucsb.cs156.frontiers.utilities.WebhookSecurityUtils;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Webhooks Controller")
@RestController
@RequestMapping("/api/webhooks")
@Slf4j
public class WebhookController {

  private final CourseRepository courseRepository;
  private final RosterStudentRepository rosterStudentRepository;
  private final CourseStaffRepository courseStaffRepository;

  @Value("${app.webhook.secret}")
  private String webhookSecret;

  public WebhookController(
      CourseRepository courseRepository,
      RosterStudentRepository rosterStudentRepository,
      CourseStaffRepository courseStaffRepository) {
    this.courseRepository = courseRepository;
    this.rosterStudentRepository = rosterStudentRepository;
    this.courseStaffRepository = courseStaffRepository;
  }

  /**
   * Accepts webhooks from GitHub, currently to update the membership status of a RosterStudent.
   *
   * @param jsonBody body of the webhook. The description of the currently used webhook is available
   *     in docs/webhooks.md
   * @param signature the GitHub webhook signature header for security validation
   * @return either the word success so GitHub will not flag the webhook as a failure, or the
   *     updated RosterStudent
   */
  @PostMapping("/github")
  public ResponseEntity<String> createGitHubWebhook(
      @RequestBody String requestBody,
      @RequestHeader(value = "X-Hub-Signature-256", required = false) String signature)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeyException {

    // Validate webhook signature
    if (!WebhookSecurityUtils.validateGitHubSignature(requestBody, signature, webhookSecret)) {
      log.error("Webhook signature validation failed");
      return ResponseEntity.status(401).body("Unauthorized: Invalid signature");
    }

    // Parse JSON after signature validation
    JsonNode jsonBody;
    try {
      jsonBody = new com.fasterxml.jackson.databind.ObjectMapper().readTree(requestBody);
    } catch (JsonProcessingException e) {
      log.error("Failed to parse webhook JSON body", e);
      return ResponseEntity.badRequest().body("Invalid JSON");
    }

    log.info("Received GitHub webhook: {}", jsonBody.toString());

    if (!jsonBody.has("action")) {
      return ResponseEntity.ok().body("success");
    }

    String action = jsonBody.get("action").asText();
    log.info("Webhook action: {}", action);

    // Handle GitHub App uninstall (installation deleted)
    if (action.equals("deleted")) {
      if (!jsonBody.has("installation") || !jsonBody.get("installation").has("id")) {
        return ResponseEntity.ok().body("success");
      }
      String installationIdForUninstall = jsonBody.get("installation").get("id").asText();
      log.info("Processing uninstall for Installation ID: {}", installationIdForUninstall);
      Optional<Course> courseForUninstall =
          courseRepository.findByInstallationId(installationIdForUninstall);
      log.info("Course found for uninstall: {}", courseForUninstall.isPresent());
      if (courseForUninstall.isPresent()) {
        Course c = courseForUninstall.get();
        c.setInstallationId(null);
        c.setOrgName(null);
        courseRepository.save(c);
      } else {
        log.warn(
            "No course found with installation ID for uninstall: {}", installationIdForUninstall);
      }
      return ResponseEntity.ok().body("success");
    }

    // Early return if not an action we care about
    if (!action.equals("member_added") && !action.equals("member_invited")) {
      return ResponseEntity.ok().body("success");
    }

    // Extract GitHub login based on payload structure
    String githubLogin = null;
    String installationId = null;
    OrgStatus role = null;

    // For member_added events, the structure is different
    if (action.equals("member_added")) {
      if (!jsonBody.has("membership")
          || !jsonBody.get("membership").has("user")
          || !jsonBody.get("membership").has("role")
          || !jsonBody.get("membership").get("user").has("login")
          || !jsonBody.has("installation")
          || !jsonBody.get("installation").has("id")) {
        return ResponseEntity.ok().body("success");
      }

      githubLogin = jsonBody.get("membership").get("user").get("login").asText();
      installationId = jsonBody.get("installation").get("id").asText();
      String textRole = jsonBody.get("membership").get("role").asText();
      if (textRole.equals("admin")) {
        role = OrgStatus.OWNER;
      } else {
        role = OrgStatus.MEMBER;
      }
    }
    // For member_invited events, use the original structure
    else { // must be "member_invited" based on earlier check
      if (!jsonBody.has("user")
          || !jsonBody.get("user").has("login")
          || !jsonBody.has("installation")
          || !jsonBody.get("installation").has("id")) {
        return ResponseEntity.ok().body("success");
      }

      githubLogin = jsonBody.get("user").get("login").asText();
      installationId = jsonBody.get("installation").get("id").asText();
    }

    log.info("GitHub login: {}, Installation ID: {}", githubLogin, installationId);

    Optional<Course> course = courseRepository.findByInstallationId(installationId);
    log.info("Course found: {}", course.isPresent());

    if (!course.isPresent()) {
      log.warn("No course found with installation ID: {}", installationId);
      return ResponseEntity.ok().body("success");
    }

    Optional<RosterStudent> student =
        rosterStudentRepository.findByCourseAndGithubLogin(course.get(), githubLogin);
    Optional<CourseStaff> staff =
        courseStaffRepository.findByCourseAndGithubLogin(course.get(), githubLogin);
    log.info("Student found: {}", student.isPresent());
    log.info("Staff found: {}", staff.isPresent());

    if (!student.isPresent() && !staff.isPresent()) {
      log.warn(
          "No student or staff found with GitHub login: {} in course: {}",
          githubLogin,
          course.get().getCourseName());
      return ResponseEntity.ok().body("success");
    }
    StringBuilder response = new StringBuilder();
    if (student.isPresent()) {
      RosterStudent updatedStudent = student.get();
      log.info("Current student org status: {}", updatedStudent.getOrgStatus());

      // Update status based on action
      if (action.equals("member_added")) {
        updatedStudent.setOrgStatus(role);
        log.info("Setting status to {}", role.toString());
      } else { // must be "member_invited" based on earlier check
        updatedStudent.setOrgStatus(OrgStatus.INVITED);
        log.info("Setting status to INVITED");
      }

      rosterStudentRepository.save(updatedStudent);
      log.info("Student saved with new org status: {}", updatedStudent.getOrgStatus());
      response.append(updatedStudent);
    }
    if (staff.isPresent()) {
      CourseStaff updatedStaff = staff.get();
      log.info("Current course staff member org status: {}", updatedStaff.getOrgStatus());

      // Update status based on action
      if (action.equals("member_added")) {
        updatedStaff.setOrgStatus(role);
        log.info("Setting status to {}", role.toString());
      } else { // must be "member_invited" based on earlier check
        updatedStaff.setOrgStatus(OrgStatus.INVITED);
        log.info("Setting status to INVITED");
      }

      courseStaffRepository.save(updatedStaff);
      log.info("Course staff member saved with new org status: {}", updatedStaff.getOrgStatus());
      response.append(updatedStaff);
    }
    return ResponseEntity.ok().body(response.toString());
  }
}