diff --git a/pom.xml b/pom.xml index 18c01ed6f..f038fe136 100644 --- a/pom.xml +++ b/pom.xml @@ -232,13 +232,6 @@ spring-boot-starter-logging - - - jakarta.mail - jakarta.mail-api - 2.1.3 - - tech.tablesaw diff --git a/src/main/java/com/open/spring/mvc/comment/CommentApiController.java b/src/main/java/com/open/spring/mvc/comment/CommentApiController.java index ffc38f9f2..56ccec6ed 100644 --- a/src/main/java/com/open/spring/mvc/comment/CommentApiController.java +++ b/src/main/java/com/open/spring/mvc/comment/CommentApiController.java @@ -1,6 +1,10 @@ package com.open.spring.mvc.comment; +import java.util.HashMap; import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -11,12 +15,27 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.CrossOrigin; + +import com.open.spring.mvc.slack.CalendarIssueService; +import com.open.spring.mvc.slack.EmailNotificationService; + @RestController @RequestMapping("/api/Comment") +@CrossOrigin(origins = { "http://127.0.0.1:4500", "https://pages.opencodingsociety.com" }, allowCredentials = "true") public class CommentApiController { + @Autowired private final CommentJPA CommentJPA; + @Autowired + private CalendarIssueService calendarIssueService; + + @Autowired + private EmailNotificationService emailNotificationService; + // Constructor injection for CommentJPA public CommentApiController(CommentJPA CommentJPA) { this.CommentJPA = CommentJPA; @@ -86,4 +105,103 @@ public ResponseEntity> getCommentsByAuthor(@RequestParam String au return new ResponseEntity<>(comments, HttpStatus.OK); // Return 200 with the list of comments } + + @GetMapping("/issue/{issueId}") + public ResponseEntity> getCommentsByIssue(@PathVariable Long issueId, + @AuthenticationPrincipal UserDetails userDetails) { + if (userDetails == null) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } + + List comments = CommentJPA.findByAssignmentOrderByTimestampDesc(issueAssignmentKey(issueId)); + return new ResponseEntity<>(comments, HttpStatus.OK); + } + + @PostMapping("/issue/{issueId}") + public ResponseEntity createIssueComment(@PathVariable Long issueId, + @RequestBody Map payload, + @AuthenticationPrincipal UserDetails userDetails) { + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("message", "Authentication required")); + } + + String text = payload.get("text") == null ? "" : String.valueOf(payload.get("text")).trim(); + if (text.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "Comment text is required")); + } + + return calendarIssueService.getIssueById(issueId, userDetails.getUsername(), hasPrivilegedRole(userDetails)) + .>map(issue -> { + String authorUid = userDetails.getUsername(); + Comment savedComment = CommentJPA.save(new Comment(issueAssignmentKey(issueId), text, authorUid)); + emailNotificationService.notifyOnIssueComment(issue, savedComment); + emailNotificationService.notifyAllStarredIssueFollowers(issue, savedComment); + + Map response = new HashMap<>(); + response.put("comment", savedComment); + response.put("commentCount", CommentJPA.countByAssignment(issueAssignmentKey(issueId))); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + }) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("message", "Issue not found"))); + } + + @PostMapping("/issue/{issueId}/star") + public ResponseEntity toggleIssueStar(@PathVariable Long issueId, + @AuthenticationPrincipal UserDetails userDetails) { + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("message", "Authentication required")); + } + + return calendarIssueService.getIssueById(issueId, userDetails.getUsername(), hasPrivilegedRole(userDetails)) + .>map(issue -> { + String starKey = issueStarAssignmentKey(issueId); + String authorUid = userDetails.getUsername(); + boolean starred = false; + + // Find existing star comments for this assignment with text "star" + List existingStars = CommentJPA.findByAssignmentAndText(starKey, "star"); + Comment toRemove = null; + if (existingStars != null) { + for (Comment c : existingStars) { + if (authorUid.equals(c.getAuthor())) { + toRemove = c; + break; + } + } + } + + if (toRemove != null) { + CommentJPA.delete(toRemove); + starred = false; + } else { + CommentJPA.save(new Comment(starKey, "star", authorUid)); + starred = true; + } + + Map response = new HashMap<>(); + response.put("starred", starred); + response.put("starCount", CommentJPA.countByAssignment(starKey)); + response.put("issueId", issueId); + return ResponseEntity.ok(response); + }) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("message", "Issue not found"))); + } + + private String issueAssignmentKey(Long issueId) { + return "issue-" + issueId; + } + + private String issueStarAssignmentKey(Long issueId) { + return issueAssignmentKey(issueId) + "::star"; + } + + private boolean hasPrivilegedRole(UserDetails userDetails) { + return userDetails.getAuthorities().stream() + .map(authority -> authority.getAuthority()) + .anyMatch(role -> "ROLE_ADMIN".equals(role) || "ROLE_TEACHER".equals(role)); + } } diff --git a/src/main/java/com/open/spring/mvc/comment/CommentJPA.java b/src/main/java/com/open/spring/mvc/comment/CommentJPA.java index 67600c76e..570ef9a80 100644 --- a/src/main/java/com/open/spring/mvc/comment/CommentJPA.java +++ b/src/main/java/com/open/spring/mvc/comment/CommentJPA.java @@ -8,5 +8,15 @@ public interface CommentJPA extends JpaRepository { List findByAssignment(String assignment); + List findByAssignmentOrderByTimestampDesc(String assignment); + List findAllByOrderByTimestampDesc(); + + long countByAssignment(String assignment); + + boolean existsByAssignmentAndAuthor(String assignment, String author); + + void deleteByAssignmentAndAuthor(String assignment, String author); + + List findByAssignmentAndText(String assignment, String text); } diff --git a/src/main/java/com/open/spring/mvc/person/Email/Email.java b/src/main/java/com/open/spring/mvc/person/Email/Email.java index a4c6d78f0..922f2e930 100644 --- a/src/main/java/com/open/spring/mvc/person/Email/Email.java +++ b/src/main/java/com/open/spring/mvc/person/Email/Email.java @@ -3,138 +3,296 @@ // Java program to send email +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; import java.util.Properties; -import jakarta.mail.Authenticator; -import jakarta.mail.Message; import jakarta.mail.MessagingException; import jakarta.mail.Multipart; -import jakarta.mail.PasswordAuthentication; -import jakarta.mail.Session; -import jakarta.mail.Transport; -import jakarta.mail.internet.InternetAddress; -import jakarta.mail.internet.MimeBodyPart; -import jakarta.mail.internet.MimeMessage; -import jakarta.mail.internet.MimeMultipart; - +import org.json.JSONObject; //dot env for email username/password import io.github.cdimascio.dotenv.Dotenv; public class Email { - - public static void sendEmail(String recipient, String subject, Multipart multipart){ - // email ID of Recipient. - - // email ID of Sender. - String sender = "sender@gmail.com"; - - // Getting system properties - Properties properties = System.getProperties(); - - // Setting up mail server - properties.put("mail.smtp.auth", "true"); - properties.put("mail.smtp.starttls.enable", "true"); - properties.put("mail.smtp.host", "smtp.gmail.com"); - properties.put("mail.smtp.port", 587); - properties.put("mail.smtp.ssl.protocols", "TLSv1.2"); - - // creating session object to get properties - - final Dotenv dotenv = Dotenv.load(); - final String emailUsername = dotenv.get("EMAIL_USERNAME"); - final String emailPassword = dotenv.get("EMAIL_PASSWORD"); - Session session = Session.getDefaultInstance(properties,new Authenticator() { - protected PasswordAuthentication getPasswordAuthentication() { - return new PasswordAuthentication(emailUsername,emailPassword); // email and password, see this for app passwords https://support.google.com/accounts/answer/185833?visit_id=638748419667916449-2613033234&p=InvalidSecondFactor&rd=1 - } - }); - - try - { - // MimeMessage object. - MimeMessage message = new MimeMessage(session); - - // Set From Field: adding senders email to from field. - message.setFrom(new InternetAddress(sender)); - - // Set To Field: adding recipient's email to from field. - message.addRecipient(Message.RecipientType.TO, new InternetAddress(recipient)); - - // Set Subject: subject of the email - message.setSubject(subject); - - // SetContent: content (Multipart) of the email - message.setContent(multipart); - - // Send email. - Transport.send(message); - System.out.println("Mail successfully sent"); - } - catch (MessagingException mex) - { - mex.printStackTrace(); - } + private static final Properties APPLICATION_PROPERTIES = loadApplicationProperties(); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private static Properties loadApplicationProperties() { + Properties props = new Properties(); + try (InputStream input = Email.class.getClassLoader().getResourceAsStream("application.properties")) { + if (input != null) { + props.load(input); + } + } catch (IOException e) { + // Fall back to env/system properties if classpath properties cannot be loaded. + } + return props; } - public static void sendEmail(String recipient, String subject, String content){ + private static String resolveCredential(String key, String applicationKey) { + String value = System.getProperty(key); + if (value != null && !value.isBlank()) { + return value; + } - try{ - MimeMultipart emailContent = new MimeMultipart(); - MimeBodyPart body1 = new MimeBodyPart(); - body1.setContent("

"+content+"

","text/html"); + value = System.getenv(key); + if (value != null && !value.isBlank()) { + return value; + } - emailContent.addBodyPart(body1); + try { + final Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + value = dotenv.get(key); + if (value != null && !value.isBlank()) { + return value; + } + } catch (Exception e) { + // Ignore and fall back to packaged properties. + } - sendEmail(recipient, subject, emailContent); + value = APPLICATION_PROPERTIES.getProperty(key); + if (value != null && !value.isBlank()) { + return value; } - catch (MessagingException mex) - { - mex.printStackTrace(); - } + + if (applicationKey != null && !applicationKey.isBlank()) { + value = APPLICATION_PROPERTIES.getProperty(applicationKey); + if (value != null && !value.isBlank()) { + return value; + } + } + + return null; } - public static void sendPasswordResetEmail(String recipient,String code){ + private static String resolveEmailProvider() { + String provider = resolveCredential("EMAIL_PROVIDER", "email.provider"); + return provider == null || provider.isBlank() ? "formsubmit" : provider.trim().toLowerCase(Locale.ROOT); + } - try{ - MimeMultipart emailContent = new MimeMultipart(); + private static String sanitizeText(String input) { + if (input == null || input.isBlank()) { + return ""; + } + + return input.replace("\r\n", "\n") + .replace("\r", "\n") + .replaceAll("(?i)", "\n") + .replaceAll("(?i)

", "\n\n") + .replaceAll("(?i)", "\n") + .replaceAll("<[^>]+>", "") + .replace(" ", " ") + .trim(); + } - MimeBodyPart body1 = new MimeBodyPart(); - body1.setContent("

To reset your password use the following code:

","text/html"); - MimeBodyPart body2 = new MimeBodyPart(); - body2.setContent(""+code+"","text/html"); + private static String multipartToText(Multipart multipart) throws MessagingException, IOException { + if (multipart == null) { + return ""; + } - emailContent.addBodyPart(body1); - emailContent.addBodyPart(body2); + StringBuilder body = new StringBuilder(); + for (int i = 0; i < multipart.getCount(); i++) { + Object content = multipart.getBodyPart(i).getContent(); + if (content == null) { + continue; + } + if (body.length() > 0) { + body.append("\n"); + } + body.append(sanitizeText(content.toString())); + } + return body.toString(); + } - sendEmail(recipient, "Password Reset", emailContent); + private static void addField(StringBuilder builder, String key, String value) { + if (value == null) { + return; } - catch (MessagingException mex) - { - mex.printStackTrace(); - } + if (builder.length() > 0) { + builder.append('&'); + } + builder.append(URLEncoder.encode(key, StandardCharsets.UTF_8)); + builder.append('='); + builder.append(URLEncoder.encode(value, StandardCharsets.UTF_8)); } - public static void sendVerificationEmail(String recipient,String code){ + private static String resolveFormSubmitEndpoint(String recipient) { + String configured = resolveCredential("FORM_SUBMIT_ENDPOINT", "formsubmit.endpoint"); + if (configured == null || configured.isBlank()) { + configured = "https://formsubmit.co/ajax/{recipient}"; + } else { + // Expand simple placeholder of form ${VAR:default} if present in properties + if (configured.startsWith("${") && configured.endsWith("}")) { + int colon = configured.indexOf(':', 2); + if (colon > 2) { + String varName = configured.substring(2, colon); + String defaultVal = configured.substring(colon + 1, configured.length() - 1); + String resolved = resolveCredential(varName, null); + if (resolved != null && !resolved.isBlank()) { + configured = resolved; + } else { + configured = defaultVal; + } + } + } + } - try{ - MimeMultipart emailContent = new MimeMultipart(); + String encodedRecipient = URLEncoder.encode(recipient, StandardCharsets.UTF_8); + if (configured.contains("{recipient}")) { + return configured.replace("{recipient}", encodedRecipient); + } - MimeBodyPart body1 = new MimeBodyPart(); - body1.setContent("

Thank you for signing up for DNHS Computer Science. Use the following code to verify your email:

","text/html"); - MimeBodyPart body2 = new MimeBodyPart(); - body2.setContent(""+code+"","text/html"); + if (configured.endsWith("/")) { + return configured + encodedRecipient; + } - emailContent.addBodyPart(body1); - emailContent.addBodyPart(body2); + return configured + "/" + encodedRecipient; + } - sendEmail(recipient, "Email Verification", emailContent); + private static void sendViaFormSubmit(String recipient, String subject, String body) throws IOException, InterruptedException { + if (recipient == null || recipient.isBlank()) { + throw new IllegalArgumentException("Recipient is required for FormSubmit delivery."); } - catch (MessagingException mex) - { - mex.printStackTrace(); - } + + String endpoint = resolveFormSubmitEndpoint(recipient); + String sender = resolveCredential("EMAIL_USERNAME", "spring.mail.username"); + String replyTo = resolveCredential("EMAIL_REPLY_TO", "email.replyTo"); + + StringBuilder formBody = new StringBuilder(); + addField(formBody, "name", "Open Coding Society"); + addField(formBody, "_subject", subject); + addField(formBody, "message", sanitizeText(body)); + addField(formBody, "_captcha", "false"); + addField(formBody, "_template", "table"); + if (sender != null && !sender.isBlank()) { + addField(formBody, "email", sender); + addField(formBody, "_replyto", sender); + } + if (replyTo != null && !replyTo.isBlank()) { + addField(formBody, "_replyto", replyTo); + } + + String origin = resolveCredential("FORM_SUBMIT_ORIGIN", "formsubmit.origin"); + if (origin == null || origin.isBlank()) { + origin = "https://pages.opencodingsociety.com"; + } + String referer = resolveCredential("FORM_SUBMIT_REFERER", "formsubmit.referer"); + if (referer == null || referer.isBlank()) { + referer = origin + "/"; + } + + HttpRequest request = HttpRequest.newBuilder(URI.create(endpoint)) + .timeout(Duration.ofSeconds(15)) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .header("User-Agent", "open-coding-society-email-service") + .header("Origin", origin) + .header("Referer", referer) + .POST(HttpRequest.BodyPublishers.ofString(formBody.toString())) + .build(); + + System.out.println("[Email] FormSubmit POST -> " + endpoint + " (Origin: " + origin + ", Referer: " + referer + ")"); + System.out.println("[Email] Form body: " + formBody.toString()); + + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + System.out.println("[Email] FormSubmit response HTTP " + response.statusCode()); + String responseBody = response.body(); + System.out.println("[Email] FormSubmit response body: " + (responseBody == null ? "" : responseBody)); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException("FormSubmit returned HTTP " + response.statusCode() + ": " + response.body()); + } + + try { + JSONObject json = new JSONObject(responseBody == null ? "{}" : responseBody); + String success = json.optString("success", "").trim().toLowerCase(Locale.ROOT); + if ("false".equals(success)) { + String message = json.optString("message", "FormSubmit rejected the request."); + throw new IllegalStateException("FormSubmit rejected delivery: " + message); + } + } catch (org.json.JSONException parseError) { + // Accept non-JSON success responses from provider/CDN edge behavior. + } + } + + private static void sendViaSmtp(String recipient, String subject, String body) { + String sender = resolveCredential("EMAIL_USERNAME", "spring.mail.username"); + String password = resolveCredential("EMAIL_PASSWORD", "spring.mail.password"); + String smtpHost = resolveCredential("EMAIL_SMTP_HOST", "spring.mail.host"); + String smtpPort = resolveCredential("EMAIL_SMTP_PORT", "spring.mail.port"); + + if (sender == null || password == null) { + throw new IllegalStateException("Email credentials are not configured. Set EMAIL_USERNAME and EMAIL_PASSWORD or spring.mail.username/password."); + } + + java.util.Properties properties = System.getProperties(); + properties.put("mail.smtp.auth", "true"); + properties.put("mail.smtp.starttls.enable", "true"); + properties.put("mail.smtp.host", smtpHost != null && !smtpHost.isBlank() ? smtpHost : "smtp.gmail.com"); + properties.put("mail.smtp.port", smtpPort != null && !smtpPort.isBlank() ? smtpPort : "587"); + properties.put("mail.smtp.ssl.protocols", "TLSv1.2"); + + jakarta.mail.Session session = jakarta.mail.Session.getDefaultInstance(properties, new jakarta.mail.Authenticator() { + @Override + protected jakarta.mail.PasswordAuthentication getPasswordAuthentication() { + return new jakarta.mail.PasswordAuthentication(sender, password); + } + }); + + try { + jakarta.mail.internet.MimeMessage message = new jakarta.mail.internet.MimeMessage(session); + message.setFrom(new jakarta.mail.internet.InternetAddress(sender)); + message.addRecipient(jakarta.mail.Message.RecipientType.TO, new jakarta.mail.internet.InternetAddress(recipient)); + message.setSubject(subject); + message.setContent(body, "text/plain; charset=UTF-8"); + jakarta.mail.Transport.send(message); + System.out.println("Mail successfully sent"); + } catch (MessagingException mex) { + throw new IllegalStateException("SMTP delivery failed", mex); + } + } + + public static void sendEmail(String recipient, String subject, Multipart multipart){ + try { + sendEmail(recipient, subject, multipartToText(multipart)); + } catch (MessagingException | IOException e) { + throw new IllegalStateException("Unable to prepare email content", e); + } + } + + public static void sendEmail(String recipient, String subject, String content){ + // Use FormSubmit for deployment safety - no server credentials needed + String safeContent = sanitizeText(content); + + try { + System.out.println("[Email] Sending via FormSubmit to " + recipient); + sendViaFormSubmit(recipient, subject, safeContent); + System.out.println("[Email] SUCCESS via FormSubmit to " + recipient); + } catch (Exception e) { + System.err.println("[Email] FAILED via FormSubmit to " + recipient + ": " + e.getMessage()); + e.printStackTrace(); + throw new RuntimeException("Email delivery failed: " + e.getMessage(), e); + } + } + + public static void sendPasswordResetEmail(String recipient,String code){ + sendEmail(recipient, "Password Reset", "To reset your password use the following code:\n\n" + code); + } + + public static void sendVerificationEmail(String recipient,String code){ + sendEmail(recipient, "Email Verification", "Thank you for signing up for DNHS Computer Science. Use the following code to verify your email:\n\n" + code); } } diff --git a/src/main/java/com/open/spring/mvc/slack/CalendarEventController.java b/src/main/java/com/open/spring/mvc/slack/CalendarEventController.java index b64b89a48..ce62955f1 100644 --- a/src/main/java/com/open/spring/mvc/slack/CalendarEventController.java +++ b/src/main/java/com/open/spring/mvc/slack/CalendarEventController.java @@ -327,6 +327,31 @@ public List getAllEvents() { return calendarEventService.getAllEvents(); } + /** + * GET /api/calendar/events/filter + * Optional query params: type, groupName, individual, classPeriod, start, end (YYYY-MM-DD) + */ + @GetMapping("/events/filter") + public List getFilteredEvents( + @RequestParam(required = false) String type, + @RequestParam(required = false) String groupName, + @RequestParam(required = false) String individual, + @RequestParam(required = false) String classPeriod, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end) { + + java.time.LocalDate startDate = null; + java.time.LocalDate endDate = null; + try { + if (start != null && !start.isBlank()) startDate = java.time.LocalDate.parse(start); + if (end != null && !end.isBlank()) endDate = java.time.LocalDate.parse(end); + } catch (Exception e) { + return List.of(); + } + + return calendarEventService.filterEvents(type, groupName, individual, classPeriod, startDate, endDate); + } + @GetMapping("/events/range") public List getEventsWithinDateRange(@RequestParam String start, @RequestParam String end) { return calendarEventService.getEventsWithinDateRange(LocalDate.parse(start), LocalDate.parse(end)); diff --git a/src/main/java/com/open/spring/mvc/slack/CalendarEventService.java b/src/main/java/com/open/spring/mvc/slack/CalendarEventService.java index b44393ab1..9daa10da5 100644 --- a/src/main/java/com/open/spring/mvc/slack/CalendarEventService.java +++ b/src/main/java/com/open/spring/mvc/slack/CalendarEventService.java @@ -116,6 +116,26 @@ public List getAllEvents() { return calendarEventRepository.findAll(); } + /** + * Filter events by optional criteria. If start/end provided, restrict to that range first. + */ + public List filterEvents(String type, String groupName, String individual, String classPeriod, + LocalDate start, LocalDate end) { + List source; + if (start != null && end != null) { + source = getEventsWithinDateRange(start, end); + } else { + source = getAllEvents(); + } + + return source.stream() + .filter(e -> type == null || type.isBlank() || (e.getType() != null && e.getType().equalsIgnoreCase(type))) + .filter(e -> groupName == null || groupName.isBlank() || (e.getGroupName() != null && e.getGroupName().equalsIgnoreCase(groupName))) + .filter(e -> individual == null || individual.isBlank() || (e.getIndividual() != null && e.getIndividual().equalsIgnoreCase(individual))) + .filter(e -> classPeriod == null || classPeriod.isBlank() || (e.getClassPeriod() != null && e.getClassPeriod().equalsIgnoreCase(classPeriod))) + .toList(); + } + public List getEventsWithinDateRange(LocalDate startDate, LocalDate endDate) { return calendarEventRepository.findByDateBetween(startDate, endDate); } diff --git a/src/main/java/com/open/spring/mvc/slack/CalendarIssueController.java b/src/main/java/com/open/spring/mvc/slack/CalendarIssueController.java index 9a7c5cf66..8c96edf5c 100644 --- a/src/main/java/com/open/spring/mvc/slack/CalendarIssueController.java +++ b/src/main/java/com/open/spring/mvc/slack/CalendarIssueController.java @@ -25,6 +25,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.open.spring.mvc.comment.CommentJPA; + @RestController @RequestMapping("/api/calendar/issues") @CrossOrigin(origins = { "http://127.0.0.1:4500", "https://pages.opencodingsociety.com" }, allowCredentials = "true") @@ -33,13 +35,21 @@ public class CalendarIssueController { @Autowired private CalendarIssueService calendarIssueService; + @Autowired + private CommentJPA commentJPA; + @GetMapping - public ResponseEntity>> getIssues( + public ResponseEntity>> getIssues( @RequestParam(required = false) String status, @RequestParam(required = false) String priority, @RequestParam(required = false) String dueDate, @RequestParam(required = false) String eventId, @RequestParam(required = false) String q, + @RequestParam(required = false) String author, + @RequestParam(required = false) String tags, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end, + @RequestParam(required = false) String groupName, @AuthenticationPrincipal UserDetails userDetails) { if (userDetails == null) { @@ -47,18 +57,26 @@ public ResponseEntity>> getIssues( } LocalDate parsedDueDate = null; + LocalDate startDate = null; + LocalDate endDate = null; if (dueDate != null && !dueDate.isBlank()) { parsedDueDate = LocalDate.parse(dueDate); } + try { + if (start != null && !start.isBlank()) startDate = LocalDate.parse(start); + if (end != null && !end.isBlank()) endDate = LocalDate.parse(end); + } catch (Exception e) { + return ResponseEntity.badRequest().build(); + } String requesterUid = userDetails.getUsername(); boolean privileged = hasPrivilegedRole(userDetails); List> data = calendarIssueService - .getIssues(status, priority, parsedDueDate, eventId, q, requesterUid, privileged) - .stream() - .map(this::toResponse) - .collect(Collectors.toList()); + .getIssues(status, priority, parsedDueDate, eventId, q, author, tags, startDate, endDate, groupName, requesterUid, privileged) + .stream() + .map(issue -> toResponse(issue, requesterUid)) + .collect(Collectors.toList()); return ResponseEntity.ok(data); } @@ -70,7 +88,7 @@ public ResponseEntity getIssueById(@PathVariable Long id, } return calendarIssueService.getIssueById(id, userDetails.getUsername(), hasPrivilegedRole(userDetails)) - .>map(issue -> ResponseEntity.ok(toResponse(issue))) + .>map(issue -> ResponseEntity.ok(toResponse(issue, userDetails.getUsername()))) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("message", "Issue not found"))); } @@ -85,7 +103,7 @@ public ResponseEntity createIssue(@RequestBody Map payload, try { CalendarIssue issue = fromPayload(payload); CalendarIssue saved = calendarIssueService.createIssue(issue, userDetails.getUsername()); - return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved)); + return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved, userDetails.getUsername())); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); } catch (Exception e) { @@ -104,7 +122,7 @@ public ResponseEntity updateIssue(@PathVariable Long id, @RequestBody Map>map(updated -> ResponseEntity.ok(toResponse(updated))) + .>map(updated -> ResponseEntity.ok(toResponse(updated, userDetails.getUsername()))) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("message", "Issue not found"))); } catch (IllegalArgumentException e) { @@ -127,7 +145,7 @@ public ResponseEntity updateStatus(@PathVariable Long id, @RequestBody Map>map(updated -> ResponseEntity.ok(toResponse(updated))) + .>map(updated -> ResponseEntity.ok(toResponse(updated, userDetails.getUsername()))) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("message", "Issue not found"))); } catch (IllegalArgumentException e) { @@ -166,7 +184,7 @@ private CalendarIssue fromPayload(Map payload) { return issue; } - private Map toResponse(CalendarIssue issue) { + private Map toResponse(CalendarIssue issue, String requesterUid) { Map data = new HashMap<>(); data.put("id", issue.getId()); data.put("title", issue.getTitle()); @@ -180,6 +198,10 @@ private Map toResponse(CalendarIssue issue) { data.put("tags", parseTags(issue.getTags())); data.put("createdAt", issue.getCreatedAt() == null ? null : issue.getCreatedAt().toString()); data.put("updatedAt", issue.getUpdatedAt() == null ? null : issue.getUpdatedAt().toString()); + data.put("commentCount", issue.getId() == null ? 0L : commentJPA.countByAssignment(issueAssignmentKey(issue.getId()))); + data.put("starCount", issue.getId() == null ? 0L : commentJPA.countByAssignment(issueStarAssignmentKey(issue.getId()))); + data.put("starred", issue.getId() != null && requesterUid != null && !requesterUid.isBlank() + && commentJPA.existsByAssignmentAndAuthor(issueStarAssignmentKey(issue.getId()), requesterUid)); return data; } @@ -248,6 +270,14 @@ private String trimToNull(String value) { return trimmed.isEmpty() ? null : trimmed; } + private String issueAssignmentKey(Long issueId) { + return "issue-" + issueId; + } + + private String issueStarAssignmentKey(Long issueId) { + return issueAssignmentKey(issueId) + "::star"; + } + private boolean hasPrivilegedRole(UserDetails userDetails) { return userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) diff --git a/src/main/java/com/open/spring/mvc/slack/CalendarIssueService.java b/src/main/java/com/open/spring/mvc/slack/CalendarIssueService.java index 83cef539d..2cb9afae8 100644 --- a/src/main/java/com/open/spring/mvc/slack/CalendarIssueService.java +++ b/src/main/java/com/open/spring/mvc/slack/CalendarIssueService.java @@ -30,7 +30,7 @@ public class CalendarIssueService { private GroupsJpaRepository groupsJpaRepository; public List getIssues(String status, String priority, LocalDate dueDate, String eventId, String q, - String requesterUid, boolean privileged) { + String author, String tags, LocalDate start, LocalDate end, String groupName, String requesterUid, boolean privileged) { List accessibleIssues = privileged ? calendarIssueRepository.findAll() : calendarIssueRepository.findAll().stream() @@ -43,11 +43,36 @@ public List getIssues(String status, String priority, LocalDate d .filter(issue -> dueDate == null || dueDate.equals(issue.getDueDate())) .filter(issue -> eventId == null || eventId.isBlank() || eventId.equals(issue.getEventId())) .filter(issue -> matchesSearch(issue, q)) + .filter(issue -> author == null || author.isBlank() || (issue.getOwnerUid() != null && issue.getOwnerUid().equals(author))) + .filter(issue -> tags == null || tags.isBlank() || matchesTags(issue, tags)) + .filter(issue -> groupName == null || groupName.isBlank() || (issue.getGroupName() != null && issue.getGroupName().equalsIgnoreCase(groupName))) + .filter(issue -> inDateRange(issue, start, end)) .sorted(Comparator.comparing(CalendarIssue::getDueDate) .thenComparing(CalendarIssue::getUpdatedAt).reversed()) .collect(Collectors.toList()); } + private boolean inDateRange(CalendarIssue issue, LocalDate start, LocalDate end) { + if (start == null && end == null) return true; + java.time.LocalDateTime createdDt = issue.getCreatedAt(); + if (createdDt == null) return false; + LocalDate created = createdDt.toLocalDate(); + if (start != null && created.isBefore(start)) return false; + if (end != null && created.isAfter(end)) return false; + return true; + } + + private boolean matchesTags(CalendarIssue issue, String rawTags) { + if (rawTags == null || rawTags.isBlank()) return true; + String[] parts = rawTags.split(","); + String issueTags = issue.getTags() == null ? "" : issue.getTags().toLowerCase(Locale.ROOT); + for (String p : parts) { + String t = p.trim().toLowerCase(Locale.ROOT); + if (!t.isEmpty() && !issueTags.contains(t)) return false; + } + return true; + } + public Optional getIssueById(Long id, String requesterUid, boolean privileged) { return findAccessibleIssue(id, requesterUid, privileged); } @@ -125,13 +150,17 @@ private boolean canViewIssue(CalendarIssue issue, String requesterUid) { return false; } + if (issue.getOwnerUid() == null || issue.getOwnerUid().isBlank()) { + return true; + } + if (requesterUid.equals(issue.getOwnerUid())) { return true; } String issueGroupName = issue.getGroupName(); if (issueGroupName == null || issueGroupName.isBlank()) { - return false; + return true; } Person requester = personJpaRepository.findByUid(requesterUid); diff --git a/src/main/java/com/open/spring/mvc/slack/EmailNotificationService.java b/src/main/java/com/open/spring/mvc/slack/EmailNotificationService.java new file mode 100644 index 000000000..40ac5ccd5 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/slack/EmailNotificationService.java @@ -0,0 +1,272 @@ +package com.open.spring.mvc.slack; + +import java.util.Map; +import java.util.Properties; +import java.util.regex.Pattern; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import com.open.spring.mvc.comment.Comment; +import com.open.spring.mvc.comment.CommentJPA; +import com.open.spring.mvc.person.Person; +import com.open.spring.mvc.person.PersonJpaRepository; +import com.open.spring.mvc.person.Email.Email; + +import io.github.cdimascio.dotenv.Dotenv; +import java.util.List; + +@Service +public class EmailNotificationService { + + private static final Properties APPLICATION_PROPERTIES = loadApplicationProperties(); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"); + + @Autowired + private PersonJpaRepository personRepository; + + @Autowired + private CommentJPA commentJPA; + + private static Properties loadApplicationProperties() { + Properties props = new Properties(); + try (var input = EmailNotificationService.class.getClassLoader().getResourceAsStream("application.properties")) { + if (input != null) { + props.load(input); + } + } catch (Exception e) { + // fall back to env/system properties + } + return props; + } + + private String resolveValue(String key, String applicationKey) { + String value = System.getProperty(key); + if (value != null && !value.isBlank()) { + return value; + } + + value = System.getenv(key); + if (value != null && !value.isBlank()) { + return value; + } + + try { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + value = dotenv.get(key); + if (value != null && !value.isBlank()) { + return value; + } + } catch (Exception e) { + // ignore and fall back to packaged properties + } + + value = APPLICATION_PROPERTIES.getProperty(key); + if (value != null && !value.isBlank()) { + return value; + } + + if (applicationKey != null && !applicationKey.isBlank()) { + value = APPLICATION_PROPERTIES.getProperty(applicationKey); + if (value != null && !value.isBlank()) { + return value; + } + } + + return null; + } + + String resolveRecipientEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated() && authentication.getName() != null) { + Person authenticatedPerson = personRepository.findByUid(authentication.getName()); + if (authenticatedPerson != null && authenticatedPerson.getEmail() != null && !authenticatedPerson.getEmail().isBlank()) { + return authenticatedPerson.getEmail(); + } + } + + String forwardPersonUid = resolveValue("FORWARD_PERSON_UID", null); + if (forwardPersonUid != null && !forwardPersonUid.isBlank()) { + Person person = personRepository.findByUid(forwardPersonUid); + if (person != null && person.getEmail() != null && !person.getEmail().isBlank()) { + return person.getEmail(); + } + } + + String forwardEmail = resolveValue("FORWARD_EMAIL", null); + if (forwardEmail != null && !forwardEmail.isBlank()) { + return forwardEmail; + } + + return resolveValue("EMAIL_USERNAME", "spring.mail.username"); + } + + private boolean isLikelyEmail(String value) { + return value != null && EMAIL_PATTERN.matcher(value.trim()).matches(); + } + + private String resolveMappedOwnerEmail(String ownerUid) { + String mapping = resolveValue("ISSUE_OWNER_EMAIL_MAP", "issue.owner.email-map"); + if (mapping == null || mapping.isBlank() || ownerUid == null || ownerUid.isBlank()) { + return null; + } + + for (String pair : mapping.split(",")) { + String[] entry = pair.split(":", 2); + if (entry.length != 2) { + continue; + } + + String key = entry[0] == null ? "" : entry[0].trim(); + String value = entry[1] == null ? "" : entry[1].trim(); + if (!key.isEmpty() && key.equalsIgnoreCase(ownerUid) && isLikelyEmail(value)) { + return value; + } + } + + return null; + } + + private String resolveIssueOwnerEmail(String ownerUid) { + if (ownerUid == null || ownerUid.isBlank()) { + return null; + } + + String ownerKey = ownerUid.trim(); + + Person byUid = personRepository.findByUid(ownerKey); + if (byUid != null && byUid.getEmail() != null && !byUid.getEmail().isBlank()) { + return byUid.getEmail(); + } + + if (isLikelyEmail(ownerKey)) { + return ownerKey; + } + + Person byName = personRepository.findByName(ownerKey); + if (byName != null && byName.getEmail() != null && !byName.getEmail().isBlank()) { + return byName.getEmail(); + } + + return resolveMappedOwnerEmail(ownerKey); + } + + /** + * Notify configured recipient(s) about a Slack message. + * Priority: logged-in person's email -> FORWARD_PERSON_UID -> FORWARD_EMAIL -> SMTP username -> no-op + */ + public void notifyOnSlackMessage(Map messageData) { + String recipient = resolveRecipientEmail(); + + if (recipient == null) { + // Nothing configured; don't create new systems or spam logs + return; + } + + // Build a concise subject and body + String subject = "Open Coding Society: new Slack message"; + StringBuilder body = new StringBuilder(); + body.append("A new Slack event was received:\n\n"); + for (Map.Entry e : messageData.entrySet()) { + body.append(e.getKey()).append(": ").append(e.getValue()).append("\n"); + } + + // Use existing Email utility to send the notification + try { + Email.sendEmail(recipient, subject, body.toString()); + } catch (Exception ex) { + ex.printStackTrace(); + } + } + + public void notifyOnIssueComment(CalendarIssue issue, Comment comment) { + if (issue == null || comment == null) { + return; + } + + String ownerUid = issue.getOwnerUid(); + System.out.println("[EmailNotificationService] Issue ID: " + issue.getId() + ", Owner UID from issue: " + ownerUid); + + // Pull recipient email ONLY from database + String recipientEmail = resolveIssueOwnerEmail(ownerUid); + System.out.println("[EmailNotificationService] Resolved owner email from database: " + recipientEmail); + + // Don't fallback to .env - if owner email not in database, skip + if (recipientEmail == null || recipientEmail.isBlank()) { + System.out.println("[EmailNotificationService] NO RECIPIENT EMAIL IN DATABASE for owner " + ownerUid + " - skipping send"); + return; + } + + String issueTitle = issue.getTitle() == null ? "your issue" : issue.getTitle(); + String subject = "Open Coding Society: new comment on " + issueTitle; + String body = new StringBuilder() + .append(comment.getAuthor()).append(" commented on an issue you created:\n\n") + .append(issueTitle).append("\n") + .append("Issue ID: ").append(issue.getId()).append("\n\n") + .append(comment.getText()) + .toString(); + + try { + System.out.println("[EmailNotificationService] SENDING email to " + recipientEmail + " for issue " + issue.getId()); + Email.sendEmail(recipientEmail, subject, body); + System.out.println("[EmailNotificationService] SUCCESSFULLY SENT to " + recipientEmail + " for issue " + issue.getId()); + } catch (Exception ex) { + System.err.println("[EmailNotificationService] FAILED to send to " + recipientEmail + " for issue " + issue.getId() + ": " + ex.getMessage()); + ex.printStackTrace(); + } + } + + /** + * Notify all users who starred an issue when a new comment is posted + */ + public void notifyAllStarredIssueFollowers(CalendarIssue issue, Comment comment) { + if (issue == null || issue.getId() == null) { + return; + } + + // Get all "stars" for this issue (use same assignment key format as CommentApiController) + String starAssignmentKey = "issue-" + issue.getId() + "::star"; + List starComments = commentJPA.findByAssignmentAndText(starAssignmentKey, "star"); + + if (starComments == null || starComments.isEmpty()) { + System.out.println("[EmailNotificationService] No users have starred issue " + issue.getId()); + return; + } + + String issueTitle = issue.getTitle() == null ? "an issue" : issue.getTitle(); + String subject = "Open Coding Society: new reply on " + issueTitle + " (you starred this)"; + + for (Comment starComment : starComments) { + String starrerId = starComment.getAuthor(); + if (starrerId == null || starrerId.equals(comment.getAuthor())) { + // Don't send to the person who posted the comment + continue; + } + + Person starrer = personRepository.findByUid(starrerId); + if (starrer == null || starrer.getEmail() == null || starrer.getEmail().isBlank()) { + System.out.println("[EmailNotificationService] Starrer " + starrerId + " has no email in database"); + continue; + } + + String recipientEmail = starrer.getEmail(); + String body = new StringBuilder() + .append(comment.getAuthor()).append(" replied to an issue you starred:\\n\\n") + .append(issueTitle).append("\\n") + .append("Issue ID: ").append(issue.getId()).append("\\n\\n") + .append(comment.getText()) + .toString(); + + try { + System.out.println("[EmailNotificationService] SENDING to starrer " + recipientEmail + " for issue " + issue.getId()); + Email.sendEmail(recipientEmail, subject, body); + System.out.println("[EmailNotificationService] SUCCESS - starrer notified: " + recipientEmail); + } catch (Exception ex) { + System.err.println("[EmailNotificationService] FAILED to notify starrer " + recipientEmail + ": " + ex.getMessage()); + ex.printStackTrace(); + } + } + } +} diff --git a/src/main/java/com/open/spring/mvc/slack/SlackController.java b/src/main/java/com/open/spring/mvc/slack/SlackController.java index 1e230efe8..ad55acefd 100644 --- a/src/main/java/com/open/spring/mvc/slack/SlackController.java +++ b/src/main/java/com/open/spring/mvc/slack/SlackController.java @@ -7,6 +7,8 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.client.RestTemplate; import com.fasterxml.jackson.databind.ObjectMapper; @@ -33,6 +35,9 @@ public class SlackController { @Autowired private SlackService slackService; + @Autowired + private EmailNotificationService emailNotificationService; + @Autowired private SlackMessageRepository messageRepository; @@ -64,7 +69,10 @@ public ResponseEntity handleSlackEvent(@RequestBody SlackEvent payload) // Saving message to DB slackService.saveMessage(messageContent); System.out.println("Message saved to database: " + messageContent); - + + // Notify configured email recipient(s) without creating new systems + emailNotificationService.notifyOnSlackMessage(messageData); + // Direct call to the CalendarEventController method calendarEventController.addEventsFromSlackMessage(messageData); System.out.println("Message processed by CalendarEventController"); @@ -76,4 +84,62 @@ public ResponseEntity handleSlackEvent(@RequestBody SlackEvent payload) return ResponseEntity.ok("OK"); } + + /** + * GET /slack/messages + * Optional query params: contains (text), channel, start (ISO datetime), end (ISO datetime), limit + */ + @GetMapping("/slack/messages") + public ResponseEntity listSlackMessages( + @RequestParam(required = false) String contains, + @RequestParam(required = false) String channel, + @RequestParam(required = false) String start, + @RequestParam(required = false) String end, + @RequestParam(required = false, defaultValue = "100") int limit) { + try { + java.time.LocalDateTime startDt = null; + java.time.LocalDateTime endDt = null; + if (start != null && !start.isBlank()) startDt = java.time.LocalDateTime.parse(start); + if (end != null && !end.isBlank()) endDt = java.time.LocalDateTime.parse(end); + + java.util.List all = messageRepository.findAll(); + java.util.List> out = new java.util.ArrayList<>(); + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + + for (SlackMessage m : all) { + if (out.size() >= limit) break; + String blob = m.getMessageBlob(); + java.util.Map map = mapper.readValue(blob, java.util.Map.class); + + // timestamp filtering + if (startDt != null || endDt != null) { + java.time.LocalDateTime ts = m.getTimestamp(); + if (startDt != null && ts.isBefore(startDt)) continue; + if (endDt != null && ts.isAfter(endDt)) continue; + } + + // channel filter + if (channel != null && !channel.isBlank()) { + Object ch = map.get("channel"); + if (ch == null || !channel.equals(String.valueOf(ch))) continue; + } + + // contains filter (search in text field) + if (contains != null && !contains.isBlank()) { + Object text = map.get("text"); + if (text == null || !String.valueOf(text).toLowerCase().contains(contains.toLowerCase())) continue; + } + + java.util.Map entry = new java.util.HashMap<>(); + entry.put("timestamp", m.getTimestamp()); + entry.put("payload", map); + out.add(entry); + } + + return ResponseEntity.ok(out); + } catch (Exception e) { + e.printStackTrace(); + return ResponseEntity.status(500).body(java.util.Map.of("error", e.getMessage())); + } + } } \ No newline at end of file diff --git a/src/main/java/com/open/spring/mvc/slack/SlackEvent.java b/src/main/java/com/open/spring/mvc/slack/SlackEvent.java index 9e9c56008..ffbccd7cb 100644 --- a/src/main/java/com/open/spring/mvc/slack/SlackEvent.java +++ b/src/main/java/com/open/spring/mvc/slack/SlackEvent.java @@ -1,5 +1,11 @@ package com.open.spring.mvc.slack; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) public class SlackEvent { // POJO private String challenge; @@ -22,11 +28,35 @@ public void setEvent(Event event) { this.event = event; } + @JsonIgnoreProperties(ignoreUnknown = true) public static class Event { private String type; private String user; private String text; private String channel; + private String username; + private String subtype; + + @JsonProperty("ts") + private String ts; + + @JsonProperty("thread_ts") + private String threadTs; + + @JsonProperty("reply_count") + private Integer replyCount; + + @JsonProperty("parent_user_id") + private String parentUserId; + + @JsonProperty("bot_id") + private String botId; + + @JsonProperty("channel_name") + private String channelName; + + @JsonProperty("bot_profile") + private Map botProfile; // Getters and Setters public String getType() { @@ -60,5 +90,77 @@ public String getChannel() { public void setChannel(String channel) { this.channel = channel; } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } + + public String getTs() { + return ts; + } + + public void setTs(String ts) { + this.ts = ts; + } + + public String getThreadTs() { + return threadTs; + } + + public void setThreadTs(String threadTs) { + this.threadTs = threadTs; + } + + public Integer getReplyCount() { + return replyCount; + } + + public void setReplyCount(Integer replyCount) { + this.replyCount = replyCount; + } + + public String getParentUserId() { + return parentUserId; + } + + public void setParentUserId(String parentUserId) { + this.parentUserId = parentUserId; + } + + public String getBotId() { + return botId; + } + + public void setBotId(String botId) { + this.botId = botId; + } + + public String getChannelName() { + return channelName; + } + + public void setChannelName(String channelName) { + this.channelName = channelName; + } + + public Map getBotProfile() { + return botProfile; + } + + public void setBotProfile(Map botProfile) { + this.botProfile = botProfile; + } } } diff --git a/src/main/java/com/open/spring/mvc/voice/VoiceCommandApiController.java b/src/main/java/com/open/spring/mvc/voice/VoiceCommandApiController.java new file mode 100644 index 000000000..339b74859 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/voice/VoiceCommandApiController.java @@ -0,0 +1,102 @@ +package com.open.spring.mvc.voice; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/voice") +public class VoiceCommandApiController { + + @Value("${openai.api.key:}") + private String openAiApiKey; + + private String resolveApiKey() { + if (openAiApiKey != null && !openAiApiKey.isBlank()) { + return openAiApiKey.trim(); + } + String env = System.getenv("OPENAI_API_KEY"); + return (env == null) ? "" : env.trim(); + } + + @PostMapping(value = "/transcribe-command", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> transcribeCommand( + @RequestParam("audio") MultipartFile audio, + @RequestParam(value = "language", required = false) String language) { + + Map response = new HashMap<>(); + + if (audio == null || audio.isEmpty()) { + response.put("text", ""); + response.put("error", "Audio payload is empty"); + return ResponseEntity.badRequest().body(response); + } + + String apiKey = resolveApiKey(); + if (apiKey.isBlank()) { + response.put("text", ""); + response.put("error", "Speech service is not configured on the server"); + return ResponseEntity.status(503).body(response); + } + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + headers.setBearerAuth(apiKey); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("model", "whisper-1"); + if (language != null && !language.isBlank()) { + body.add("language", language.trim()); + } + body.add("response_format", "json"); + + ByteArrayResource audioResource = new ByteArrayResource(audio.getBytes()) { + @Override + public String getFilename() { + String original = audio.getOriginalFilename(); + return (original == null || original.isBlank()) ? "command-audio.webm" : original; + } + }; + body.add("file", audioResource); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + RestTemplate restTemplate = new RestTemplate(); + + @SuppressWarnings("rawtypes") + ResponseEntity sttResponse = restTemplate.postForEntity( + "https://api.openai.com/v1/audio/transcriptions", + requestEntity, + Map.class); + + Object textObj = (sttResponse.getBody() != null) ? sttResponse.getBody().get("text") : null; + String text = (textObj == null) ? "" : String.valueOf(textObj).trim(); + + response.put("text", text); + return ResponseEntity.ok(response); + } catch (IOException io) { + response.put("text", ""); + response.put("error", "Unable to read audio payload"); + return ResponseEntity.badRequest().body(response); + } catch (Exception e) { + response.put("text", ""); + response.put("error", "Speech transcription failed"); + return ResponseEntity.status(502).body(response); + } + } +} diff --git a/src/main/java/com/open/spring/security/SecurityConfig.java b/src/main/java/com/open/spring/security/SecurityConfig.java index 9111a0e54..665f8e4cf 100644 --- a/src/main/java/com/open/spring/security/SecurityConfig.java +++ b/src/main/java/com/open/spring/security/SecurityConfig.java @@ -251,6 +251,8 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.addAllowedOriginPattern("http://127.0.0.1:4599"); configuration.addAllowedOriginPattern("http://127.0.0.1:4600"); configuration.addAllowedOriginPattern("http://127.0.0.1:8585"); + configuration.addAllowedOriginPattern("http://127.0.0.1:4000"); + configuration.addAllowedOriginPattern("http://localhost:4000"); configuration.addAllowedOriginPattern("http://localhost:4500"); configuration.addAllowedOriginPattern("http://localhost:4599"); configuration.addAllowedOriginPattern("http://localhost:4600"); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 340959caa..22ccac4a0 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -93,6 +93,17 @@ spring.mail.password=vjor zncp naam daph spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true +# Email delivery strategy +# Default provider for deployed environments; override with EMAIL_PROVIDER=smtp if needed +email.provider=${EMAIL_PROVIDER:formsubmit} +# Optional explicit reply-to for outbound messages +email.replyTo=${EMAIL_REPLY_TO:} +# FormSubmit endpoint template. Keep {recipient} token to inject issue owner email. +formsubmit.endpoint=${FORM_SUBMIT_ENDPOINT:https://formsubmit.co/ajax/{recipient}} +# Optional mapping for owner labels that are not user UIDs. +# Format: "toby:pranavs22638@gmail.com,alice:alice@example.com" +issue.owner.email-map=${ISSUE_OWNER_EMAIL_MAP:} + # ========== API KEYS ========== # Google API Key (should be set in .env file for security) google.api.key=AIzaSyC1xyYXgYfGRJrvMOY8iT2bZnb7-Nyd1HY diff --git a/src/test/java/com/open/spring/mvc/comment/CommentApiControllerTest.java b/src/test/java/com/open/spring/mvc/comment/CommentApiControllerTest.java new file mode 100644 index 000000000..9e5b4daa0 --- /dev/null +++ b/src/test/java/com/open/spring/mvc/comment/CommentApiControllerTest.java @@ -0,0 +1,97 @@ +package com.open.spring.mvc.comment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.util.ReflectionTestUtils; + +import com.open.spring.mvc.slack.CalendarIssue; +import com.open.spring.mvc.slack.CalendarIssueService; +import com.open.spring.mvc.slack.EmailNotificationService; + +public class CommentApiControllerTest { + + @Mock + private CommentJPA commentJPA; + + @Mock + private CalendarIssueService calendarIssueService; + + @Mock + private EmailNotificationService emailNotificationService; + + private CommentApiController controller; + + @BeforeEach + public void setup() { + MockitoAnnotations.openMocks(this); + controller = new CommentApiController(commentJPA); + ReflectionTestUtils.setField(controller, "calendarIssueService", calendarIssueService); + ReflectionTestUtils.setField(controller, "emailNotificationService", emailNotificationService); + } + + @Test + public void createIssueCommentSavesCommentAndNotifiesOwner() { + CalendarIssue issue = new CalendarIssue(); + issue.setTitle("Need reply"); + issue.setOwnerUid("bob"); + + Comment saved = new Comment("issue-12", "Looks good", "alice"); + when(calendarIssueService.getIssueById(12L, "alice", false)).thenReturn(Optional.of(issue)); + when(commentJPA.save(any(Comment.class))).thenReturn(saved); + + UserDetails userDetails = User.withUsername("alice").password("ignored").authorities("ROLE_USER").build(); + ResponseEntity response = controller.createIssueComment(12L, Map.of("text", "Looks good"), userDetails); + + assertEquals(201, response.getStatusCode().value()); + assertNotNull(response.getBody()); + verify(emailNotificationService).notifyOnIssueComment(issue, saved); + } + + @Test + public void toggleIssueStarAddsStarComment() { + CalendarIssue issue = new CalendarIssue(); + issue.setTitle("Need reply"); + issue.setOwnerUid("bob"); + + when(calendarIssueService.getIssueById(12L, "alice", false)).thenReturn(Optional.of(issue)); + when(commentJPA.existsByAssignmentAndAuthor("issue-12::star", "alice")).thenReturn(false); + when(commentJPA.save(any(Comment.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(commentJPA.countByAssignment("issue-12::star")).thenReturn(1L); + + UserDetails userDetails = User.withUsername("alice").password("ignored").authorities("ROLE_USER").build(); + ResponseEntity response = controller.toggleIssueStar(12L, userDetails); + + assertEquals(200, response.getStatusCode().value()); + Map body = (Map) response.getBody(); + assertEquals(Boolean.TRUE, body.get("starred")); + assertEquals(1L, body.get("starCount")); + } + + @Test + public void getCommentsByIssueReturnsCommentsForAuthenticatedUser() { + Comment comment = new Comment("issue-12", "Looks good", "alice"); + when(commentJPA.findByAssignmentOrderByTimestampDesc("issue-12")).thenReturn(List.of(comment)); + + UserDetails userDetails = User.withUsername("alice").password("ignored").authorities("ROLE_USER").build(); + ResponseEntity> response = controller.getCommentsByIssue(12L, userDetails); + + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals(1, response.getBody().size()); + } +} \ No newline at end of file diff --git a/src/test/java/com/open/spring/mvc/slack/CalendarEventServiceTest.java b/src/test/java/com/open/spring/mvc/slack/CalendarEventServiceTest.java new file mode 100644 index 000000000..955346b3e --- /dev/null +++ b/src/test/java/com/open/spring/mvc/slack/CalendarEventServiceTest.java @@ -0,0 +1,42 @@ +package com.open.spring.mvc.slack; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class CalendarEventServiceTest { + + @Mock + private CalendarEventRepository calendarEventRepository; + + @Mock + private SlackService slackService; + + @InjectMocks + private CalendarEventService eventService; + + @BeforeEach + public void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testFilterEvents_byTypeAndDateRange() { + CalendarEvent e1 = new CalendarEvent(LocalDate.of(2026,5,1), "Title A", "desc", "meeting", "P1"); + CalendarEvent e2 = new CalendarEvent(LocalDate.of(2026,5,2), "Title B", "desc", "event", "P2"); + + when(calendarEventRepository.findAll()).thenReturn(List.of(e1, e2)); + + List res = eventService.filterEvents("meeting", null, null, null, null, null); + assertEquals(1, res.size()); + assertEquals("Title A", res.get(0).getTitle()); + } +} diff --git a/src/test/java/com/open/spring/mvc/slack/CalendarIssueServiceTest.java b/src/test/java/com/open/spring/mvc/slack/CalendarIssueServiceTest.java new file mode 100644 index 000000000..24a2f5fef --- /dev/null +++ b/src/test/java/com/open/spring/mvc/slack/CalendarIssueServiceTest.java @@ -0,0 +1,73 @@ +package com.open.spring.mvc.slack; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class CalendarIssueServiceTest { + + @Mock + private CalendarIssueRepository calendarIssueRepository; + + @Mock + private com.open.spring.mvc.person.PersonJpaRepository personJpaRepository; + + @Mock + private com.open.spring.mvc.groups.GroupsJpaRepository groupsJpaRepository; + + @InjectMocks + private CalendarIssueService issueService; + + @BeforeEach + public void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testMatchesTags_andAuthor_andDateRange() { + CalendarIssue a = new CalendarIssue(); + a.setTitle("Test"); + a.setTags("bug,urgent"); + a.setOwnerUid("alice"); + a.setDueDate(LocalDate.of(2026, 1, 20)); + // use reflection to set createdAt (no public setter) + try { + java.lang.reflect.Field f = CalendarIssue.class.getDeclaredField("createdAt"); + f.setAccessible(true); + f.set(a, java.time.LocalDateTime.of(2026,1,10,0,0)); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + + when(calendarIssueRepository.findAll()).thenReturn(List.of(a)); + + List res = issueService.getIssues(null, null, null, null, null, + "alice", "bug", LocalDate.of(2026,1,1), LocalDate.of(2026,1,31), null, "alice", true); + + assertNotNull(res); + assertEquals(1, res.size()); + assertEquals("Test", res.get(0).getTitle()); + } + + @Test + public void testLegacyIssueWithoutOwnerOrGroupRemainsVisible() { + CalendarIssue legacy = new CalendarIssue(); + legacy.setTitle("Legacy issue"); + legacy.setDueDate(LocalDate.of(2026, 2, 14)); + when(calendarIssueRepository.findAll()).thenReturn(List.of(legacy)); + + List res = issueService.getIssues(null, null, null, null, null, + null, null, null, null, null, "alice", false); + + assertEquals(1, res.size()); + assertEquals("Legacy issue", res.get(0).getTitle()); + } +} diff --git a/src/test/java/com/open/spring/mvc/slack/EmailNotificationServiceTest.java b/src/test/java/com/open/spring/mvc/slack/EmailNotificationServiceTest.java new file mode 100644 index 000000000..804bc20d7 --- /dev/null +++ b/src/test/java/com/open/spring/mvc/slack/EmailNotificationServiceTest.java @@ -0,0 +1,54 @@ +package com.open.spring.mvc.slack; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import com.open.spring.mvc.person.Person; +import com.open.spring.mvc.person.PersonJpaRepository; + +public class EmailNotificationServiceTest { + + @Mock + private PersonJpaRepository personRepository; + + @InjectMocks + private EmailNotificationService emailNotificationService; + + private AutoCloseable mocks; + + @BeforeEach + public void setup() { + mocks = MockitoAnnotations.openMocks(this); + } + + @AfterEach + public void tearDown() throws Exception { + SecurityContextHolder.clearContext(); + if (mocks != null) { + mocks.close(); + } + } + + @Test + public void resolveRecipientEmailPrefersLoggedInPersonEmail() { + Person person = new Person(); + person.setEmail("alice@example.com"); + when(personRepository.findByUid("alice")).thenReturn(person); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("alice", "ignored", List.of())); + + assertEquals("alice@example.com", emailNotificationService.resolveRecipientEmail()); + } +} \ No newline at end of file diff --git a/src/test/java/com/open/spring/mvc/slack/SlackControllerTest.java b/src/test/java/com/open/spring/mvc/slack/SlackControllerTest.java new file mode 100644 index 000000000..e0b3c8869 --- /dev/null +++ b/src/test/java/com/open/spring/mvc/slack/SlackControllerTest.java @@ -0,0 +1,63 @@ +package com.open.spring.mvc.slack; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; + +public class SlackControllerTest { + + @Mock + private RestTemplate restTemplate; + + @Mock + private CalendarEventController calendarEventController; + + @Mock + private SlackService slackService; + + @Mock + private EmailNotificationService emailNotificationService; + + @Mock + private SlackMessageRepository messageRepository; + + private SlackController slackController; + + @BeforeEach + public void setup() { + MockitoAnnotations.openMocks(this); + slackController = new SlackController(restTemplate); + ReflectionTestUtils.setField(slackController, "messageRepository", messageRepository); + } + + @Test + public void listSlackMessagesReturnsThreadMetadata() { + SlackMessage message = new SlackMessage(LocalDateTime.of(2026, 5, 7, 12, 0), + "{\"ts\":\"1715083200.000100\",\"thread_ts\":\"1715083200.000100\",\"channel\":\"C123\",\"text\":\"Hello\",\"user\":\"U123\"}"); + when(messageRepository.findAll()).thenReturn(List.of(message)); + + ResponseEntity response = slackController.listSlackMessages(null, null, null, null, 100); + + assertEquals(200, response.getStatusCodeValue()); + assertNotNull(response.getBody()); + + List body = (List) response.getBody(); + Map entry = (Map) body.get(0); + Map payload = (Map) entry.get("payload"); + + assertEquals("1715083200.000100", payload.get("thread_ts")); + assertEquals("Hello", payload.get("text")); + } +} \ No newline at end of file