diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index e99e435..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: CI - -on: - push: - branches: [ "main", "dev" ] - pull_request: - branches: [ "main", "dev" ] - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Create dummy .env file - run: touch .env - - - name: Build services - run: docker compose build test - - - name: Run tests with coverage - run: docker compose up --abort-on-container-exit --exit-code-from test test - - - name: Show coverage summary - run: | - COVERED=$(grep -o 'covered="[0-9]*"' build/reports/jacoco/test/jacocoTestReport.xml | awk -F\" '{sum+=$2} END {print sum}') - MISSED=$(grep -o 'missed="[0-9]*"' build/reports/jacoco/test/jacocoTestReport.xml | awk -F\" '{sum+=$2} END {print sum}') - TOTAL=$((COVERED + MISSED)) - PERCENT=$((100 * COVERED / TOTAL)) - echo "==== COVERAGE SUMMARY ====" - echo "Lines covered: $COVERED / $TOTAL ($PERCENT%)" - echo "==========================" - - - name: Upload coverage report - uses: actions/upload-artifact@v4 - with: - name: jacoco-report - path: build/reports/jacoco/test - diff --git a/build.gradle b/build.gradle index a2b5c42..2589a42 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.mapstruct:mapstruct:1.6.3' implementation "org.projectlombok:lombok-mapstruct-binding:0.2.0" implementation 'org.flywaydb:flyway-database-postgresql' + implementation 'io.nats:jnats:2.24.1' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/compose.dev.yml b/compose.dev.yml index 5035f87..055b859 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -16,6 +16,7 @@ services: - "${SERVER_PORT:-9090}:${SERVER_PORT:-9090}" depends_on: - db-dev + - nats-dev healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:${SERVER_PORT:-9090}/actuator/health"] interval: 10s @@ -39,6 +40,15 @@ services: networks: - heapdog-network-dev + nats-dev: + container_name: nats-dev + image: nats:2.12.2-alpine3.22 + ports: + - "4223:4222" + - "8223:8222" + networks: + - heapdog-network-dev + volumes: db_data_dev: diff --git a/compose.yml b/compose.yml index 4340344..1b2dccb 100644 --- a/compose.yml +++ b/compose.yml @@ -63,18 +63,14 @@ services: networks: - heapdog-network - - test: - build: - context: . - dockerfile: Dockerfile.test - working_dir: /home/gradle/app - environment: - SPRING_PROFILES_ACTIVE: test - volumes: - - .:/home/gradle/app - - ./build/reports:/home/gradle/app/build/reports - command: [ "./gradlew", "test", "jacocoTestReport", "--info" ] + nats: + container_name: nats + image: nats:2.12.2-alpine3.22 + ports: + - "4222:4222" + - "8222:8222" + networks: + - heapdog-network volumes: db_data_prod: diff --git a/src/main/java/io/heapdog/core/config/NatsConfig.java b/src/main/java/io/heapdog/core/config/NatsConfig.java new file mode 100644 index 0000000..fc4a4d6 --- /dev/null +++ b/src/main/java/io/heapdog/core/config/NatsConfig.java @@ -0,0 +1,23 @@ +package io.heapdog.core.config; + + +import io.nats.client.Connection; +import io.nats.client.Nats; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.IOException; + +@Configuration +public class NatsConfig { + + @Value("${nats.url}") + private String natsUrl; + + @Bean(destroyMethod = "close") + public Connection natsConnection() throws IOException, InterruptedException { + return Nats.connect(natsUrl); + } + +} diff --git a/src/main/java/io/heapdog/core/feature/notification/InternalNotificationController.java b/src/main/java/io/heapdog/core/feature/notification/InternalNotificationController.java new file mode 100644 index 0000000..f438447 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/notification/InternalNotificationController.java @@ -0,0 +1,23 @@ +package io.heapdog.core.feature.notification; + + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/notifications") +@RequiredArgsConstructor +public class InternalNotificationController { + + private final NotificationService notificationService; + + @GetMapping("/{id}") + InternalNotificationResponseDto + getNotifications(@PathVariable Long id) { + return notificationService.getNotificationById(id); + } + +} diff --git a/src/main/java/io/heapdog/core/feature/notification/InternalNotificationResponseDto.java b/src/main/java/io/heapdog/core/feature/notification/InternalNotificationResponseDto.java new file mode 100644 index 0000000..bdc1b17 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/notification/InternalNotificationResponseDto.java @@ -0,0 +1,22 @@ +package io.heapdog.core.feature.notification; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; + + +@Getter +@Setter +@Builder +public class InternalNotificationResponseDto { + private Long id; + private String message; + private String link; + private boolean read; + private boolean clicked; + private NotificationType type; + private Instant createdAt; + private Long userId; +} diff --git a/src/main/java/io/heapdog/core/feature/notification/NotificationService.java b/src/main/java/io/heapdog/core/feature/notification/NotificationService.java index dc0b0e3..6278457 100644 --- a/src/main/java/io/heapdog/core/feature/notification/NotificationService.java +++ b/src/main/java/io/heapdog/core/feature/notification/NotificationService.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import java.util.List; @@ -26,6 +27,22 @@ public Notification createNotification(String message, String link) { return notificationRepository.save(notification); } + @PreAuthorize("hasAuthority('read:notification')") + InternalNotificationResponseDto getNotificationById(Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new ResourceNotFoundException("Notification", "id", notificationId.toString())); + return InternalNotificationResponseDto.builder() + .id(notification.getId()) + .message(notification.getMessage()) + .link(notification.getLink()) + .read(notification.isRead()) + .clicked(notification.isClicked()) + .type(notification.getType()) + .createdAt(notification.getCreatedAt()) + .userId(notification.getRecipient().getId()) + .build(); + } + Page getNotifications(Long userId, Long page, Long size) { return notificationRepository.findByRecipientId( diff --git a/src/main/java/io/heapdog/core/feature/notification/NotificationType.java b/src/main/java/io/heapdog/core/feature/notification/NotificationType.java index 5d78240..6766b83 100644 --- a/src/main/java/io/heapdog/core/feature/notification/NotificationType.java +++ b/src/main/java/io/heapdog/core/feature/notification/NotificationType.java @@ -2,6 +2,8 @@ public enum NotificationType { - INVITATION + INVITATION_SENT, + INVITATION_ACCEPTED, + ORGANIZATION_MEMBER_ROLE_UPDATED, ORGANIZATION_UPDATED, } diff --git a/src/main/java/io/heapdog/core/feature/organization/MembershipRepository.java b/src/main/java/io/heapdog/core/feature/organization/MembershipRepository.java index 74a311a..7f6b4a1 100644 --- a/src/main/java/io/heapdog/core/feature/organization/MembershipRepository.java +++ b/src/main/java/io/heapdog/core/feature/organization/MembershipRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import java.util.List; import java.util.Optional; public interface MembershipRepository extends JpaRepository { @@ -24,7 +25,8 @@ public interface MembershipRepository extends JpaRepository { m.user.id, m.user.username, m.user.email, - m.role + m.role, + m.id ) FROM Membership m WHERE m.organization.id = :organizationId @@ -33,4 +35,11 @@ SELECT COUNT(m) FROM Membership m WHERE m.organization.id = :organizationId """) Page findByOrganizationId(Long organizationId, Pageable pageable); + + @Query(""" + SELECT m FROM Membership m + JOIN FETCH m.organization + WHERE m.organization.id = :organizationId +""") + List findByOrganizationId(Long organizationId); } diff --git a/src/main/java/io/heapdog/core/feature/organization/OrganizationBasicInfoUpdateRequestDto.java b/src/main/java/io/heapdog/core/feature/organization/OrganizationBasicInfoUpdateRequestDto.java index bed8ee5..2e9e368 100644 --- a/src/main/java/io/heapdog/core/feature/organization/OrganizationBasicInfoUpdateRequestDto.java +++ b/src/main/java/io/heapdog/core/feature/organization/OrganizationBasicInfoUpdateRequestDto.java @@ -12,8 +12,6 @@ public class OrganizationBasicInfoUpdateRequestDto { private String name; - private String slug; - @Size(max = 100, message = "Description can be at most 100 characters") private String description; diff --git a/src/main/java/io/heapdog/core/feature/organization/OrganizationController.java b/src/main/java/io/heapdog/core/feature/organization/OrganizationController.java index d6a97ac..ff0e7c9 100644 --- a/src/main/java/io/heapdog/core/feature/organization/OrganizationController.java +++ b/src/main/java/io/heapdog/core/feature/organization/OrganizationController.java @@ -107,4 +107,12 @@ OrganizationInvitationAcceptResponseDto acceptInvitation(@PathVariable String sl return organizationService.acceptInvitation(slug, dto, user.getId()); } + @PreAuthorize("@organizationSecurity.isAdmin(#slug, authentication)") + @PatchMapping("/{slug}/membership/{membershipId}/role") + OrganizationMemberResponseDto updateOrganizationMemberRole(@PathVariable String slug, + @PathVariable Long membershipId, + @Valid @RequestBody OrganizationMemberRoleUpdateRequestDto dto) { + return organizationService.updateOrganizationMemberRole(slug, membershipId, dto); + } + } diff --git a/src/main/java/io/heapdog/core/feature/organization/OrganizationMemberResponseDto.java b/src/main/java/io/heapdog/core/feature/organization/OrganizationMemberResponseDto.java index 16a8c36..db540a9 100644 --- a/src/main/java/io/heapdog/core/feature/organization/OrganizationMemberResponseDto.java +++ b/src/main/java/io/heapdog/core/feature/organization/OrganizationMemberResponseDto.java @@ -10,4 +10,5 @@ public class OrganizationMemberResponseDto { private String username; private String email; private OrganizationRole role; + private Long membershipId; } diff --git a/src/main/java/io/heapdog/core/feature/organization/OrganizationMemberRoleUpdateRequestDto.java b/src/main/java/io/heapdog/core/feature/organization/OrganizationMemberRoleUpdateRequestDto.java new file mode 100644 index 0000000..d64eca0 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/organization/OrganizationMemberRoleUpdateRequestDto.java @@ -0,0 +1,13 @@ +package io.heapdog.core.feature.organization; + + +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +public class OrganizationMemberRoleUpdateRequestDto { + + private OrganizationRole role; + +} diff --git a/src/main/java/io/heapdog/core/feature/organization/OrganizationService.java b/src/main/java/io/heapdog/core/feature/organization/OrganizationService.java index 2ea0c5c..10f88f1 100644 --- a/src/main/java/io/heapdog/core/feature/organization/OrganizationService.java +++ b/src/main/java/io/heapdog/core/feature/organization/OrganizationService.java @@ -8,9 +8,11 @@ import io.heapdog.core.feature.user.HeapDogUserRepository; import io.heapdog.core.shared.ConstraintToFieldMapper; import io.heapdog.core.shared.DuplicateResourceException; +import io.heapdog.core.shared.NatsPublisher; import io.heapdog.core.shared.ResourceNotFoundException; import io.heapdog.core.shared.util.OtpGenerator; import jakarta.transaction.Transactional; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; @@ -37,6 +39,7 @@ public class OrganizationService { private final OrganizationInvitationMapper organizationInvitationMapper; private final NotificationRepository notificationRepository; + private final NatsPublisher natsPublisher; OrganizationBasicInfoResponseDto getBasicInfo(String slug) { Organization organization = repository.findBySlug(slug) @@ -92,6 +95,7 @@ OrganizationBasicInfoWithMembershipIdResponseDto createOrganization(Organization .username(user.getUsername()) .email(user.getEmail()) .role(m.getRole()) + .membershipId(m.getId()) .build(); }); } @@ -116,7 +120,6 @@ OrganizationBasicInfoResponseDto updateOrganization(String slug, OrganizationBas Organization organization = repository.findBySlug(slug) .orElseThrow(() -> new ResourceNotFoundException("Organization", "slug", slug)); organization.setOrgName(dto.getName() != null ? dto.getName() : organization.getOrgName()); - organization.setSlug(dto.getSlug() != null ? dto.getSlug() : organization.getSlug()); organization.setDescription(dto.getDescription() != null ? dto.getDescription() : organization.getDescription()); organization.setEmail(dto.getEmail() != null ? dto.getEmail() : organization.getEmail()); organization.setWebsite(dto.getWebsite() != null ? dto.getWebsite() : organization.getWebsite()); @@ -124,6 +127,18 @@ OrganizationBasicInfoResponseDto updateOrganization(String slug, OrganizationBas organization.setPhone(dto.getPhone() != null ? dto.getPhone() : organization.getPhone()); try { Organization updatedOrganization = repository.save(organization); + // send notification to all members about the update + var memberships = membershipRepository.findByOrganizationId(organization.getId()); + for (var membership : memberships) { + Notification notification = Notification.builder() + .message("The organization " + organization.getOrgName() + " has been updated.") + .link("/organizations/" + organization.getSlug() + "/basic-info") + .recipient(membership.getUser()) + .type(NotificationType.ORGANIZATION_UPDATED) + .build(); + Notification savedNotification = notificationRepository.save(notification); + natsPublisher.publishNotificationEvent(savedNotification.getId()); + } return mapper.toBasicInfoDto(updatedOrganization); } catch (DataIntegrityViolationException ex) { Throwable root = ex.getCause(); @@ -171,9 +186,10 @@ OrganizationSlugCheckResponseDto checkSlugAvailability(String slug) { .message("You have been invited to join the organization: " + organization.getOrgName()) .link("/invitations/accept?code=" + savedInvitation.getCode() + "&org=" + organization.getSlug()) .recipient(user) - .type(NotificationType.INVITATION) + .type(NotificationType.INVITATION_SENT) .build(); - notificationRepository.save(notification); + Notification saved = notificationRepository.save(notification); + natsPublisher.publishNotificationEvent(saved.getId()); return organizationInvitationMapper.toDto(savedInvitation); } @@ -213,13 +229,6 @@ Page getOrganizationInvitations(String slug, .orElseThrow(() -> new ResourceNotFoundException("Organization", "slug", slug)); Pageable pageable = PageRequest.of(page.intValue() - 1, size.intValue(), Sort.by("createdAt").ascending()); log.info("Getting organization invitations for slug {}", slug); -// var invitations = organizationInvitationRepository.findOrganizationInvitationByOrganizationId(organization.getId(), pageable); -// var i = invitations.stream().map(organizationInvitationMapper::toOrganizationInvitationResponseDto) -// .toList(); -// return OrganizationInvitationsResponseDto.builder() -// .invitations(i) -// .build(); -// return invitations.map(organizationInvitationMapper::toOrganizationInvitationResponseDto); return organizationInvitationRepository.findOrganizationInvitationsByOrganizationId(organization.getId(), pageable); } @@ -272,6 +281,16 @@ OrganizationInvitationAcceptResponseDto acceptInvitation(String slug, user.setCurrentMembership(membership); userRepository.save(user); } + // Create a notification and send it to the creator of the invitation + var creator = invitation.getCreatedBy(); + Notification notification = Notification.builder() + .message(user.getUsername() + " has accepted the invitation to join the organization: " + invitation.getOrganization().getOrgName()) + .link("/organizations/" + invitation.getOrganization().getSlug() + "/members") + .recipient(creator) + .type(NotificationType.INVITATION_ACCEPTED) + .build(); + Notification savedNotification = notificationRepository.save(notification); + natsPublisher.publishNotificationEvent(savedNotification.getId()); return organizationInvitationMapper.toOrganizationInvitationAcceptResponseDto(membership); } @@ -281,15 +300,46 @@ public OrganizationInvitationAcceptInfoResponseDto getInvitationAcceptInfo(Strin OrganizationInvitation invitation = organizationInvitationRepository .findByOrganizationSlugAndCode(slug, code) .orElseThrow(() -> new ResourceNotFoundException("Invitation", "code", code)); + if (!invitation.getUser().getId().equals(userId)) { + throw new ResourceNotFoundException("Invitation", "code", code); + } if (invitation.getRevoked()) { throw new IllegalArgumentException("This invitation has been revoked."); } if (invitation.isAccepted()) { throw new IllegalArgumentException("This invitation has already been accepted."); } - if (!invitation.getUser().getId().equals(userId)) { - throw new IllegalArgumentException("This invitation is not for the current user."); - } return organizationInvitationMapper.toOrganizationInvitationAcceptInfoResponseDto(invitation); } + + public OrganizationMemberResponseDto updateOrganizationMemberRole(String slug, Long membershipId, @Valid OrganizationMemberRoleUpdateRequestDto dto) { + Organization organization = repository.findBySlug(slug) + .orElseThrow(() -> new ResourceNotFoundException("Organization", "slug", slug)); + Membership membership = membershipRepository.findById(membershipId) + .orElseThrow(() -> new ResourceNotFoundException("Membership", "id", membershipId.toString())); + if (!membership.getOrganization().getId().equals(organization.getId())) { + throw new ResourceNotFoundException("Membership", "id", membershipId.toString()); + } + membership.setRole(dto.getRole()); + Membership savedMembership = membershipRepository.save(membership); + + // create notification to the user about role change + Notification notification = Notification.builder() + .message("Your role in the organization " + organization.getOrgName() + " has been changed to " + dto.getRole()) + .link("/organizations/" + organization.getSlug() + "/members") + .recipient(savedMembership.getUser()) + .type(NotificationType.ORGANIZATION_MEMBER_ROLE_UPDATED) + .build(); + Notification savedNotification = notificationRepository.save(notification); + natsPublisher.publishNotificationEvent(savedNotification.getId()); + + var user = savedMembership.getUser(); + return OrganizationMemberResponseDto.builder() + .id(user.getId()) + .username(user.getUsername()) + .email(user.getEmail()) + .role(savedMembership.getRole()) + .membershipId(savedMembership.getId()) + .build(); + } } diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUser.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUser.java index d3ac9ea..3783124 100644 --- a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUser.java +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUser.java @@ -5,6 +5,7 @@ import jakarta.persistence.*; import lombok.*; +import java.time.Instant; import java.util.Set; @Builder @@ -29,6 +30,6 @@ public class ServiceUser extends BaseEntity { @Enumerated(EnumType.STRING) private Set permissions; - - + @Column(name = "last_accessed_at") + private Instant lastAccessedAt; } diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserController.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserController.java index 3ddeb11..6537473 100644 --- a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserController.java +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserController.java @@ -14,6 +14,12 @@ public class ServiceUserController { private final ServiceUserService serviceUserService; + @GetMapping("/{id}") + ResponseEntity getServiceUser(@PathVariable Long id) { + var resp = serviceUserService.getServiceUser(id); + return ResponseEntity.ok(resp); + } + @PostMapping ResponseEntity createServiceUser(@Valid @RequestBody ServiceUserCreateRequestDto request) { @@ -21,4 +27,10 @@ public class ServiceUserController { return ResponseEntity.ok(resp); } + @GetMapping("/permissions") + ResponseEntity getAvailablePermissions() { + var resp = serviceUserService.getServiceUserPermissions(); + return ResponseEntity.ok(resp); + } + } diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateRequestDto.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateRequestDto.java index 5dc3038..5a4da17 100644 --- a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateRequestDto.java +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateRequestDto.java @@ -4,11 +4,14 @@ import lombok.Builder; import lombok.Data; +import java.util.Set; + @Data @Builder public class ServiceUserCreateRequestDto { @NotEmpty private String name; + Set permissions; } diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateResponseDto.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateResponseDto.java index 1ef85bd..c1e9049 100644 --- a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateResponseDto.java +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserCreateResponseDto.java @@ -4,12 +4,14 @@ import lombok.Builder; import lombok.Data; +import java.util.Set; + @Data @Builder public class ServiceUserCreateResponseDto { - private Long id; private String name; private String apiKey; private Boolean enabled; + private Set permissions; } diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserPermission.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserPermission.java index 386eabe..8d4075f 100644 --- a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserPermission.java +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserPermission.java @@ -4,15 +4,26 @@ public enum ServiceUserPermission { READ_HEAPDOG_USER("read:heapdog_user"), WRITE_HEAPDOG_USER("write:heapdog_user"), - READ_NOTIFICATION("read:notification"); + READ_NOTIFICATION("read:notification"), + READ_SSE_TOKEN("read:sse_token"); - private final String permission; + private final String label; + private final String description = switch (this) { + case READ_HEAPDOG_USER -> "Permission to read Heapdog user data."; + case WRITE_HEAPDOG_USER -> "Permission to modify Heapdog user data."; + case READ_NOTIFICATION -> "Permission to read notifications."; + case READ_SSE_TOKEN -> "Permission to read SSE tokens."; + }; - ServiceUserPermission(String permission) { - this.permission = permission; + ServiceUserPermission(String description) { + this.label = description; } - public String getPermission() { - return permission; + public String getLabel() { + return label; + } + + public String getDescription() { + return description; } } diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserPermissionResponseDto.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserPermissionResponseDto.java new file mode 100644 index 0000000..5b794d1 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserPermissionResponseDto.java @@ -0,0 +1,22 @@ +package io.heapdog.core.feature.serviceuser; + + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Builder +@Data +public class ServiceUserPermissionResponseDto { + + private List permissions; + + @Builder + @Data + static class Permission { + private String name; + private String label; + private String description; + } +} diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserReadResponseDto.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserReadResponseDto.java new file mode 100644 index 0000000..3cd387a --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserReadResponseDto.java @@ -0,0 +1,31 @@ +package io.heapdog.core.feature.serviceuser; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +import java.time.Instant; +import java.util.Set; + +@Builder +@Data +@AllArgsConstructor +public class ServiceUserReadResponseDto { + private Long id; + private String name; + private String apiKey; + private Boolean enabled; + private Instant createdAt; + private Instant updatedAt; + private User createdBy; + private Set permissions; + private Instant lastAccessedAt; + + @Data + @Builder + public static class User { + private Long id; + private String username; + } +} diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserRepository.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserRepository.java index 5586bb9..3c15d6c 100644 --- a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserRepository.java +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserRepository.java @@ -1,6 +1,7 @@ package io.heapdog.core.feature.serviceuser; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.Optional; @@ -8,4 +9,13 @@ public interface ServiceUserRepository extends JpaRepository Optional findByApiKey(String apiKey); + @Query(""" + SELECT DISTINCT s + FROM ServiceUser s + LEFT JOIN FETCH s.permissions + LEFT JOIN FETCH s.createdBy + WHERE s.id = :id + """) + Optional findByIdWithPermissionsAndCreator(Long id); + } diff --git a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserService.java b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserService.java index d608e19..de5f1da 100644 --- a/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserService.java +++ b/src/main/java/io/heapdog/core/feature/serviceuser/ServiceUserService.java @@ -1,10 +1,13 @@ package io.heapdog.core.feature.serviceuser; +import io.heapdog.core.shared.ResourceNotFoundException; import io.heapdog.core.shared.util.OtpGenerator; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; +import java.util.Arrays; + @Service @RequiredArgsConstructor public class ServiceUserService { @@ -17,6 +20,7 @@ ServiceUserCreateResponseDto createServiceUser(ServiceUserCreateRequestDto reque .name(request.getName()) .apiKey(String.format("svc-%s", OtpGenerator.generateOtp(32))) .enabled(true) + .permissions(request.getPermissions()) .build(); var saved = serviceUserRepository.save(serviceUser); return ServiceUserCreateResponseDto.builder() @@ -24,6 +28,43 @@ ServiceUserCreateResponseDto createServiceUser(ServiceUserCreateRequestDto reque .name(saved.getName()) .apiKey(saved.getApiKey()) .enabled(saved.isEnabled()) + .permissions(saved.getPermissions()) + .build(); + } + + + @PreAuthorize("hasRole('ADMIN')") + ServiceUserReadResponseDto getServiceUser(Long id) { + var serviceUser = serviceUserRepository.findByIdWithPermissionsAndCreator(id) + .orElseThrow(() -> new ResourceNotFoundException("ServiceUser", "id", id.toString())); + return ServiceUserReadResponseDto.builder() + .id(serviceUser.getId()) + .name(serviceUser.getName()) + .apiKey(serviceUser.getApiKey()) + .enabled(serviceUser.isEnabled()) + .createdAt(serviceUser.getCreatedAt()) + .updatedAt(serviceUser.getUpdatedAt()) + .createdBy(ServiceUserReadResponseDto.User.builder() + .id(serviceUser.getCreatedBy().getId()) + .username(serviceUser.getCreatedBy().getUsername()) + .build()) + .permissions(serviceUser.getPermissions()) + .lastAccessedAt(serviceUser.getLastAccessedAt()) + .build(); + } + + + @PreAuthorize("hasRole('ADMIN')") + ServiceUserPermissionResponseDto getServiceUserPermissions() { + var permissions = Arrays.stream(ServiceUserPermission.values()) + .map(permission -> ServiceUserPermissionResponseDto.Permission.builder() + .name(permission.name()) + .label(permission.getLabel()) + .description(permission.getDescription()) + .build()) + .toList(); + return ServiceUserPermissionResponseDto.builder() + .permissions(permissions) .build(); } } diff --git a/src/main/java/io/heapdog/core/feature/sse/InternalSseController.java b/src/main/java/io/heapdog/core/feature/sse/InternalSseController.java new file mode 100644 index 0000000..0265814 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/sse/InternalSseController.java @@ -0,0 +1,30 @@ +package io.heapdog.core.feature.sse; + + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/internal/sse-token") +@RequiredArgsConstructor +public class InternalSseController { + + private final SseTokenRegistry sseTokenRegistry; + + @GetMapping("/validate") + InternalSseValidateTokenResponseDto validateSseToken(@RequestParam String token) { + + Long userId = sseTokenRegistry.getUserIdByToken(token); + if (userId == null) { + throw new IllegalArgumentException("Invalid SSE token"); + } + return InternalSseValidateTokenResponseDto.builder() + .userId(userId) + .build(); + } + + @DeleteMapping("/invalidate") + void invalidateSseToken(@RequestParam String token) { + sseTokenRegistry.invalidateToken(token); + } +} diff --git a/src/main/java/io/heapdog/core/feature/sse/InternalSseValidateTokenResponseDto.java b/src/main/java/io/heapdog/core/feature/sse/InternalSseValidateTokenResponseDto.java new file mode 100644 index 0000000..7f3785c --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/sse/InternalSseValidateTokenResponseDto.java @@ -0,0 +1,11 @@ +package io.heapdog.core.feature.sse; + + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class InternalSseValidateTokenResponseDto { + private Long userId; +} diff --git a/src/main/java/io/heapdog/core/feature/sse/SseController.java b/src/main/java/io/heapdog/core/feature/sse/SseController.java new file mode 100644 index 0000000..413b833 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/sse/SseController.java @@ -0,0 +1,28 @@ +package io.heapdog.core.feature.sse; + + +import io.heapdog.core.feature.user.HeapDogUser; +import io.heapdog.core.security.SecurityUser; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/sse-token") +@RequiredArgsConstructor +public class SseController { + + private final SseTokenRegistry sseTokenRegistry; + + @GetMapping("/obtain") + SseToken obtainToken(Authentication authentication) { + HeapDogUser user = ((SecurityUser) authentication.getPrincipal()).getUser(); + String token = sseTokenRegistry.createToken(user.getId()); + return SseToken.builder() + .token(token) + .build(); + } + +} diff --git a/src/main/java/io/heapdog/core/feature/sse/SseToken.java b/src/main/java/io/heapdog/core/feature/sse/SseToken.java new file mode 100644 index 0000000..9f8db66 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/sse/SseToken.java @@ -0,0 +1,11 @@ +package io.heapdog.core.feature.sse; + +import lombok.*; + +@Data +@Builder +public class SseToken { + + private String token; + +} diff --git a/src/main/java/io/heapdog/core/feature/sse/SseTokenRegistry.java b/src/main/java/io/heapdog/core/feature/sse/SseTokenRegistry.java new file mode 100644 index 0000000..bc9637a --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/sse/SseTokenRegistry.java @@ -0,0 +1,35 @@ +package io.heapdog.core.feature.sse; + + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@Slf4j +public class SseTokenRegistry { + + private final Map tokenToUserId = new ConcurrentHashMap<>(); + + + public String createToken(Long userId) { + String token = UUID.randomUUID().toString(); + tokenToUserId.put(token, userId); + return token; + } + + @PreAuthorize("hasAuthority('read:sse_token')") + public Long getUserIdByToken(String token) { + return tokenToUserId.get(token); + } + + public void invalidateToken(String token) { + log.debug("Invalidating SSE token: {}", token); + tokenToUserId.remove(token); + } + +} diff --git a/src/main/java/io/heapdog/core/feature/user/HeapDogUserService.java b/src/main/java/io/heapdog/core/feature/user/HeapDogUserService.java index ee761a0..283fa74 100644 --- a/src/main/java/io/heapdog/core/feature/user/HeapDogUserService.java +++ b/src/main/java/io/heapdog/core/feature/user/HeapDogUserService.java @@ -111,4 +111,17 @@ public EmailVerificationResponseDto verifyEmailOtp(EmailVerificationRequestDto d .message("Email verified successfully.") .build(); } + + InternalUserResponseDto getUserById(Long userId) { + HeapDogUser user = repository.findById(userId) + .orElseThrow(() -> new UserNotFoundException("User not found with ID: " + userId)); + return InternalUserResponseDto.builder() + .id(user.getId()) + .username(user.getUsername()) + .role(user.getRole().name()) + .enabled(user.getEnabled()) + .build(); + } + + } diff --git a/src/main/java/io/heapdog/core/feature/user/InternalUserController.java b/src/main/java/io/heapdog/core/feature/user/InternalUserController.java new file mode 100644 index 0000000..6e10603 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/user/InternalUserController.java @@ -0,0 +1,19 @@ +package io.heapdog.core.feature.user; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/internal/users") +@RequiredArgsConstructor +public class InternalUserController { + + private final HeapDogUserService userService; + + @GetMapping("/{userId}") + InternalUserResponseDto getUser(@PathVariable Long userId) { + InternalUserResponseDto user = userService.getUserById(userId); + return user; + } + +} diff --git a/src/main/java/io/heapdog/core/feature/user/InternalUserResponseDto.java b/src/main/java/io/heapdog/core/feature/user/InternalUserResponseDto.java new file mode 100644 index 0000000..aa35e51 --- /dev/null +++ b/src/main/java/io/heapdog/core/feature/user/InternalUserResponseDto.java @@ -0,0 +1,17 @@ +package io.heapdog.core.feature.user; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Builder +@Getter +@Setter +public class InternalUserResponseDto { + + private Long id; + private String username; + private String role; + private Boolean enabled; + +} diff --git a/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationProvider.java b/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationProvider.java index e2699ef..7f87d3f 100644 --- a/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationProvider.java +++ b/src/main/java/io/heapdog/core/security/ApiKeyAuthenticationProvider.java @@ -25,13 +25,18 @@ public Authentication authenticate(Authentication authentication) throws Authent Optional user = repository.findByApiKey(apiKey); if (user.isPresent()) { ServiceUser serviceUser = user.get(); + + // Update last accessed time + serviceUser.setLastAccessedAt(java.time.Instant.now()); + repository.save(serviceUser); + if (!serviceUser.isEnabled()) { throw new BadCredentialsException("API Key is disabled"); } return ApiKeyAuthenticationToken.authenticated( serviceUser, serviceUser.getPermissions().stream() - .map(permission -> (GrantedAuthority) permission::name) + .map(permission -> (GrantedAuthority) permission::getLabel) .toList() ); } else { diff --git a/src/main/java/io/heapdog/core/security/SecurityUser.java b/src/main/java/io/heapdog/core/security/SecurityUser.java index 94bef7d..a5aba61 100644 --- a/src/main/java/io/heapdog/core/security/SecurityUser.java +++ b/src/main/java/io/heapdog/core/security/SecurityUser.java @@ -34,5 +34,4 @@ public String getPassword() { public String getUsername() { return user.getUsername(); } - } diff --git a/src/main/java/io/heapdog/core/shared/GlobalControllerAdvice.java b/src/main/java/io/heapdog/core/shared/GlobalControllerAdvice.java index cf7c972..fe35e4a 100644 --- a/src/main/java/io/heapdog/core/shared/GlobalControllerAdvice.java +++ b/src/main/java/io/heapdog/core/shared/GlobalControllerAdvice.java @@ -1,6 +1,7 @@ package io.heapdog.core.shared; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import io.heapdog.core.feature.auth.InvalidOtpException; import io.heapdog.core.feature.user.UserAlreadyVerifiedException; import io.heapdog.core.feature.user.UserNotFoundException; @@ -259,6 +260,35 @@ ResponseEntity handleIllegalArgumentException(IllegalArgumentException @ExceptionHandler(exception = HttpMessageNotReadableException.class) ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex, HttpServletRequest request) { log.warn(ex.toString()); + + if (ex.getCause() instanceof InvalidFormatException ife && ife.getTargetType().isEnum()) { + String invalidValue = ife.getValue().toString(); + String enumType = ife.getTargetType().getSimpleName(); + String allowedValues = String.join(", ", + List.of(ife.getTargetType().getEnumConstants()).stream() + .map(Object::toString) + .toList() + ); + + ApiError errorResponse = ApiError + .builder() + .timestamp(Instant.now()) + .status(HttpStatus.BAD_REQUEST.value()) + .error(HttpStatus.BAD_REQUEST.getReasonPhrase()) + .code("INVALID_ENUM_VALUE") + .message("Invalid enum value in request body") + .details(List.of( + ApiError.FieldError.builder() + .field(ife.getPath().get(0).getFieldName()) + .message(String.format("Invalid value '%s' for enum '%s'. Allowed values are: %s", + invalidValue, enumType, allowedValues)) + .build() + )) + .path(request.getRequestURI()) + .build(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + ApiError errorResponse = ApiError .builder() .timestamp(Instant.now()) diff --git a/src/main/java/io/heapdog/core/shared/NatsPublisher.java b/src/main/java/io/heapdog/core/shared/NatsPublisher.java new file mode 100644 index 0000000..3f6ed8f --- /dev/null +++ b/src/main/java/io/heapdog/core/shared/NatsPublisher.java @@ -0,0 +1,31 @@ +package io.heapdog.core.shared; + + +import io.nats.client.Connection; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NatsPublisher { + + private final Connection natsConnection; + + @Value("${nats.notificationSubject}") + private String NOTIFICATION_SUBJECT; + + + private void publish(String subject, String message) { + natsConnection.publish(subject, message.getBytes(StandardCharsets.UTF_8)); + } + + public void publishNotificationEvent(Long notificationId) { + log.info("Publishing notification event for ID: {} in subject: {}", notificationId, NOTIFICATION_SUBJECT); + publish(NOTIFICATION_SUBJECT, notificationId.toString()); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 8e571e0..6527cf9 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -27,4 +27,8 @@ logging: server: servlet: context-path: /api/v1 - port: ${SERVER_PORT:8080} \ No newline at end of file + port: ${SERVER_PORT:8080} + +nats: + url: ${NATS_URL:nats://localhost:4222} + notificationSubject: ${NATS_NOTIFICATION_SUBJECT:notifications} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index c957d04..2ed20a6 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -24,3 +24,7 @@ server: servlet: context-path: /api/v1 port: ${SERVER_PORT:8080} + +nats: + url: ${NATS_URL:nats://localhost:4222} + notificationSubject: ${NATS_NOTIFICATION_SUBJECT:notifications} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 6d1410d..871cd74 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -23,4 +23,8 @@ jwt: logging: level: - org.springframework.security: trace \ No newline at end of file + org.springframework.security: trace + +nats: + url: ${NATS_URL:nats://localhost:4222} + notificationSubject: ${NATS_NOTIFICATION_SUBJECT:notifications} \ No newline at end of file diff --git a/src/main/resources/db/migration/V20251212044314__add_column_last_accessed_at_to_service_user_table.sql b/src/main/resources/db/migration/V20251212044314__add_column_last_accessed_at_to_service_user_table.sql new file mode 100644 index 0000000..d7fb457 --- /dev/null +++ b/src/main/resources/db/migration/V20251212044314__add_column_last_accessed_at_to_service_user_table.sql @@ -0,0 +1,2 @@ +ALTER TABLE heapdog_service_user + ADD COLUMN last_accessed_at TIMESTAMP WITHOUT TIME ZONE; \ No newline at end of file