diff --git a/booklore-api/build.gradle b/booklore-api/build.gradle index ed8e36e13..c2b2fe337 100644 --- a/booklore-api/build.gradle +++ b/booklore-api/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-configuration-processor' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-mail' // --- Database & Migration --- implementation 'org.mariadb.jdbc:mariadb-java-client:3.5.2' diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtAuthenticationFilter.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtAuthenticationFilter.java index 5f46101fe..a00ef5c5a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtAuthenticationFilter.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtAuthenticationFilter.java @@ -83,6 +83,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { permissions.setCanUpload(userEntity.getPermissions().isPermissionUpload()); permissions.setCanDownload(userEntity.getPermissions().isPermissionDownload()); permissions.setCanEditMetadata(userEntity.getPermissions().isPermissionEditMetadata()); + permissions.setCanEmailBook(userEntity.getPermissions().isPermissionEmailBook()); permissions.setCanManipulateLibrary(userEntity.getPermissions().isPermissionManipulateLibrary()); BookLoreUser bookLoreUser = new BookLoreUser(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java index e85c14907..8606a80b7 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java @@ -56,7 +56,7 @@ public class SecurityConfig { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns(List.of("*")); - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS" , "PATCH")); configuration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type")); configuration.setExposedHeaders(List.of("Content-Disposition")); configuration.setAllowCredentials(true); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java index c165cd785..c5434762c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java @@ -52,6 +52,14 @@ public class SecurityUtil { return false; } + public boolean canEmailBook() { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof BookLoreUser user) { + return user.getPermissions().isCanEmailBook(); + } + return false; + } + public boolean canViewUserProfile(Long userId) { var authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.getPrincipal() instanceof BookLoreUser user) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/EmailController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/EmailController.java new file mode 100644 index 000000000..d6b50cb6d --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/EmailController.java @@ -0,0 +1,31 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.model.dto.request.SendBookByEmailRequest; +import com.adityachandel.booklore.service.EmailService; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@AllArgsConstructor +@RestController +@RequestMapping("/api/v1/emails") +public class EmailController { + + private final EmailService emailService; + + @PreAuthorize("@securityUtil.canEmailBook() or @securityUtil.isAdmin()") + @PostMapping("/send-book") + public ResponseEntity sendEmail(@Validated @RequestBody SendBookByEmailRequest request) { + emailService.emailBook(request); + return ResponseEntity.noContent().build(); + } + + @PreAuthorize("@securityUtil.canEmailBook() or @securityUtil.isAdmin()") + @PostMapping("/send-book/{bookId}") + public ResponseEntity emailBookQuick(@PathVariable Long bookId) { + emailService.emailBookQuick(bookId); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/EmailProviderController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/EmailProviderController.java new file mode 100644 index 000000000..382e1ba60 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/EmailProviderController.java @@ -0,0 +1,57 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.model.dto.EmailProvider; +import com.adityachandel.booklore.model.dto.request.CreateEmailProviderRequest; +import com.adityachandel.booklore.service.EmailProviderService; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@AllArgsConstructor +@RestController +@RequestMapping("/api/v1/email/providers") +public class EmailProviderController { + + private final EmailProviderService emailProviderService; + + @PreAuthorize("@securityUtil.isAdmin()") + @GetMapping + public ResponseEntity> getEmailProviders() { + return ResponseEntity.ok(emailProviderService.getEmailProviders()); + } + + @PreAuthorize("@securityUtil.isAdmin()") + @GetMapping("/{id}") + public ResponseEntity getEmailProvider(@PathVariable Long id) { + return ResponseEntity.ok(emailProviderService.getEmailProvider(id)); + } + + @PreAuthorize("@securityUtil.isAdmin()") + @PostMapping + public ResponseEntity createEmailProvider(@RequestBody CreateEmailProviderRequest createEmailProviderRequest) { + return ResponseEntity.ok(emailProviderService.createEmailProvider(createEmailProviderRequest)); + } + + @PreAuthorize("@securityUtil.isAdmin()") + @PutMapping("/{id}") + public ResponseEntity updateEmailProvider(@PathVariable Long id, @RequestBody CreateEmailProviderRequest updateRequest) { + return ResponseEntity.ok(emailProviderService.updateEmailProvider(id, updateRequest)); + } + + @PreAuthorize("@securityUtil.isAdmin()") + @PatchMapping("/{id}/set-default") + public ResponseEntity setDefaultEmailProvider(@PathVariable Long id) { + emailProviderService.setDefaultEmailProvider(id); + return ResponseEntity.noContent().build(); + } + + @PreAuthorize("@securityUtil.isAdmin()") + @DeleteMapping("/{id}") + public ResponseEntity deleteEmailProvider(@PathVariable Long id) { + emailProviderService.deleteEmailProvider(id); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/EmailRecipientController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/EmailRecipientController.java new file mode 100644 index 000000000..480838315 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/EmailRecipientController.java @@ -0,0 +1,57 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.model.dto.EmailRecipient; +import com.adityachandel.booklore.model.dto.request.CreateEmailRecipientRequest; +import com.adityachandel.booklore.service.EmailRecipientService; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@AllArgsConstructor +@RestController +@RequestMapping("/api/v1/email/recipients") +public class EmailRecipientController { + + private final EmailRecipientService emailRecipientService; + + @PreAuthorize("@securityUtil.isAdmin()") + @GetMapping + public ResponseEntity> getEmailRecipients() { + return ResponseEntity.ok(emailRecipientService.getEmailRecipients()); + } + + @PreAuthorize("@securityUtil.isAdmin()") + @GetMapping("/{id}") + public ResponseEntity getEmailRecipient(@PathVariable Long id) { + return ResponseEntity.ok(emailRecipientService.getEmailRecipient(id)); + } + + @PreAuthorize("@securityUtil.isAdmin()") + @PostMapping + public ResponseEntity createEmailRecipient(@RequestBody CreateEmailRecipientRequest createEmailRecipientRequest) { + return ResponseEntity.ok(emailRecipientService.createEmailRecipient(createEmailRecipientRequest)); + } + + @PreAuthorize("@securityUtil.isAdmin()") + @PutMapping("/{id}") + public ResponseEntity updateEmailRecipient(@PathVariable Long id, @RequestBody CreateEmailRecipientRequest updateRequest) { + return ResponseEntity.ok(emailRecipientService.updateEmailRecipient(id, updateRequest)); + } + + @PreAuthorize("@securityUtil.isAdmin()") + @PatchMapping("/{id}/set-default") + public ResponseEntity setDefaultEmailRecipient(@PathVariable Long id) { + emailRecipientService.setDefaultRecipient(id); + return ResponseEntity.noContent().build(); + } + + @PreAuthorize("@securityUtil.isAdmin()") + @DeleteMapping("/{id}") + public ResponseEntity deleteEmailRecipient(@PathVariable Long id) { + emailRecipientService.deleteEmailRecipient(id); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java index ed1da77af..09ba08b8e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java @@ -7,7 +7,10 @@ import org.springframework.http.HttpStatus; @Getter public enum ApiError { BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "Book not found with ID: %d"), - USER_NOT_FOUNDD(HttpStatus.NOT_FOUND, "User with ID %s not found"), + EMAIL_PROVIDER_NOT_FOUND(HttpStatus.NOT_FOUND, "Email provider with ID %s not found"), + DEFAULT_EMAIL_PROVIDER_NOT_FOUND(HttpStatus.NOT_FOUND, "Default email provider not found"), + EMAIL_RECIPIENT_NOT_FOUND(HttpStatus.NOT_FOUND, "Email recipient with ID %s not found"), + DEFAULT_EMAIL_RECIPIENT_NOT_FOUND(HttpStatus.NOT_FOUND, " Default Email recipient not found"), UNSUPPORTED_BOOK_TYPE(HttpStatus.BAD_REQUEST, "Unsupported book type for viewer settings"), INVALID_VIEWER_SETTING(HttpStatus.BAD_REQUEST, "Invalid viewer setting for the book"), FILE_READ_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Error reading files from path"), @@ -31,6 +34,8 @@ public enum ApiError { USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "User not found: %s"), CANNOT_DELETE_ADMIN(HttpStatus.FORBIDDEN, "Admin user cannot be deleted"), UNAUTHORIZED(HttpStatus.FORBIDDEN, "%s"), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "%s"), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "%s"), PASSWORD_INCORRECT(HttpStatus.BAD_REQUEST, "Incorrect current password"), PASSWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "Password must be at least 6 characters long"), PASSWORD_SAME_AS_CURRENT(HttpStatus.BAD_REQUEST, "New password cannot be the same as the current password"), diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookLoreUserMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookLoreUserMapper.java index 5dad70f31..3ec0a6383 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookLoreUserMapper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookLoreUserMapper.java @@ -23,6 +23,7 @@ public interface BookLoreUserMapper { dto.setCanDownload(permissions.isPermissionDownload()); dto.setCanManipulateLibrary(permissions.isPermissionManipulateLibrary()); dto.setCanEditMetadata(permissions.isPermissionEditMetadata()); + dto.setCanEmailBook(permissions.isPermissionEmailBook()); return dto; } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/EmailProviderMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/EmailProviderMapper.java new file mode 100644 index 000000000..059fc3119 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/EmailProviderMapper.java @@ -0,0 +1,16 @@ +package com.adityachandel.booklore.mapper; + +import com.adityachandel.booklore.model.dto.EmailProvider; +import com.adityachandel.booklore.model.dto.request.CreateEmailProviderRequest; +import com.adityachandel.booklore.model.entity.EmailProviderEntity; +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; + +@Mapper(componentModel = "spring") +public interface EmailProviderMapper { + + EmailProvider toDTO(EmailProviderEntity emailProviderEntity); + EmailProviderEntity toEntity(EmailProvider emailProvider); + EmailProviderEntity toEntity(CreateEmailProviderRequest createRequest); + void updateEntityFromRequest(CreateEmailProviderRequest request, @MappingTarget EmailProviderEntity entity); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/EmailRecipientMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/EmailRecipientMapper.java new file mode 100644 index 000000000..8708b13d3 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/EmailRecipientMapper.java @@ -0,0 +1,19 @@ +package com.adityachandel.booklore.mapper; + +import com.adityachandel.booklore.model.dto.EmailRecipient; +import com.adityachandel.booklore.model.dto.request.CreateEmailRecipientRequest; +import com.adityachandel.booklore.model.entity.EmailRecipientEntity; +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; + +@Mapper(componentModel = "spring") +public interface EmailRecipientMapper { + + EmailRecipient toDTO(EmailRecipientEntity emailRecipientEntity); + + EmailRecipientEntity toEntity(EmailRecipient emailRecipient); + + EmailRecipientEntity toEntity(CreateEmailRecipientRequest createRequest); + + void updateEntityFromRequest(CreateEmailRecipientRequest request, @MappingTarget EmailRecipientEntity entity); +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java index 5e62cc739..e95ff1752 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java @@ -23,5 +23,6 @@ public class BookLoreUser { private boolean canDownload; private boolean canEditMetadata; private boolean canManipulateLibrary; + private boolean canEmailBook; } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EmailProvider.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EmailProvider.java new file mode 100644 index 000000000..4da8b0c71 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EmailProvider.java @@ -0,0 +1,22 @@ +package com.adityachandel.booklore.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EmailProvider { + private Long id; + private String name; + private String host; + private Integer port; + private String username; + private String password; + private Boolean auth; + private Boolean startTls; + private Boolean defaultProvider; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EmailRecipient.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EmailRecipient.java new file mode 100644 index 000000000..1fdd00c79 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EmailRecipient.java @@ -0,0 +1,17 @@ +package com.adityachandel.booklore.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EmailRecipient { + private Long id; + private String email; + private String name; + private boolean defaultRecipient; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java index 5c8e689ea..da4b391df 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java @@ -14,6 +14,7 @@ public class UserCreateRequest { private boolean permissionUpload; private boolean permissionDownload; private boolean permissionEditMetadata; + private boolean permissionEmailBook; private Set selectedLibraries; } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/CreateEmailProviderRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/CreateEmailProviderRequest.java new file mode 100644 index 000000000..6b482a4aa --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/CreateEmailProviderRequest.java @@ -0,0 +1,20 @@ +package com.adityachandel.booklore.model.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateEmailProviderRequest { + private String name; + private String host; + private Integer port; + private String username; + private String password; + private Boolean auth; + private Boolean startTls; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/CreateEmailRecipientRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/CreateEmailRecipientRequest.java new file mode 100644 index 000000000..c7480a6b2 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/CreateEmailRecipientRequest.java @@ -0,0 +1,27 @@ +package com.adityachandel.booklore.model.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateEmailRecipientRequest { + + @NotBlank(message = "Email is required") + @Email(message = "Invalid email format") + @Size(max = 255, message = "Email length must not exceed 255 characters") + private String email; + + @NotBlank(message = "Name is required") + @Size(max = 100, message = "Name length must not exceed 100 characters") + private String name; + + private boolean defaultRecipient; +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/SendBookByEmailRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/SendBookByEmailRequest.java new file mode 100644 index 000000000..9caf8882f --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/SendBookByEmailRequest.java @@ -0,0 +1,23 @@ +package com.adityachandel.booklore.model.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SendBookByEmailRequest { + + @NotNull(message = "Book ID cannot be null") + private Long bookId; + + @NotNull(message = "Provider ID cannot be null") + private Long providerId; + + @NotNull(message = "Recipient ID cannot be null") + private Long recipientId; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java index 71536a8c2..5e4736df9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java @@ -16,5 +16,6 @@ public class UserUpdateRequest { private boolean canUpload; private boolean canDownload; private boolean canEditMetadata; + private boolean canEmailBook; } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EmailProviderEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EmailProviderEntity.java new file mode 100644 index 000000000..7da49a403 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EmailProviderEntity.java @@ -0,0 +1,42 @@ +package com.adityachandel.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "email_provider") +public class EmailProviderEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false, unique = true) + private String name; + + @Column(name = "host", nullable = false) + private String host; + + @Column(name = "port", nullable = false) + private int port; + + @Column(name = "username", nullable = false) + private String username; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "auth", nullable = false) + private boolean auth; + + @Column(name = "start_tls", nullable = false) + private boolean startTls; + + @Column(name = "is_default", nullable = false) + private boolean defaultProvider; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EmailRecipientEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EmailRecipientEntity.java new file mode 100644 index 000000000..36c93b820 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EmailRecipientEntity.java @@ -0,0 +1,27 @@ +package com.adityachandel.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "email_recipient") +public class EmailRecipientEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "email", nullable = false, unique = true) + private String email; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "is_default", nullable = false) + private boolean defaultRecipient; +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java index f2e6fae53..712fe8d90 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java @@ -32,6 +32,9 @@ public class UserPermissionsEntity { @Column(name = "permission_manipulate_library", nullable = false) private boolean permissionManipulateLibrary = false; + @Column(name = "permission_email_book", nullable = false) + private boolean permissionEmailBook = false; + @Column(name = "permission_admin", nullable = false) private boolean permissionAdmin; } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/EmailProviderRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/EmailProviderRepository.java new file mode 100644 index 000000000..817853e13 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/EmailProviderRepository.java @@ -0,0 +1,20 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.entity.EmailProviderEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface EmailProviderRepository extends JpaRepository { + + @Modifying + @Query("UPDATE EmailProviderEntity e SET e.defaultProvider = false") + void updateAllProvidersToNonDefault(); + + @Query("SELECT e FROM EmailProviderEntity e WHERE e.defaultProvider = true") + Optional findDefaultEmailProvider(); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/EmailRecipientRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/EmailRecipientRepository.java new file mode 100644 index 000000000..7a9a9ae00 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/EmailRecipientRepository.java @@ -0,0 +1,22 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.entity.EmailRecipientEntity; +import jakarta.transaction.Transactional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface EmailRecipientRepository extends JpaRepository { + + @Modifying + @Transactional + @Query("UPDATE EmailRecipientEntity e SET e.defaultRecipient = false WHERE e.defaultRecipient = true") + void updateAllRecipientsToNonDefault(); + + @Query("SELECT e FROM EmailRecipientEntity e WHERE e.defaultRecipient = true") + Optional findDefaultEmailRecipient(); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/EmailProviderService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/EmailProviderService.java new file mode 100644 index 000000000..c2bba886e --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/EmailProviderService.java @@ -0,0 +1,72 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.mapper.EmailProviderMapper; +import com.adityachandel.booklore.model.dto.EmailProvider; +import com.adityachandel.booklore.model.dto.request.CreateEmailProviderRequest; +import com.adityachandel.booklore.model.entity.EmailProviderEntity; +import com.adityachandel.booklore.repository.EmailProviderRepository; +import jakarta.transaction.Transactional; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@AllArgsConstructor +public class EmailProviderService { + + private final EmailProviderRepository emailProviderRepository; + private final EmailProviderMapper emailProviderMapper; + + public EmailProvider getEmailProvider(Long id) { + EmailProviderEntity emailProvider = emailProviderRepository.findById(id).orElseThrow(() -> ApiError.EMAIL_PROVIDER_NOT_FOUND.createException(id)); + return emailProviderMapper.toDTO(emailProvider); + } + + public EmailProvider createEmailProvider(CreateEmailProviderRequest request) { + boolean isFirstProvider = emailProviderRepository.count() == 0; + EmailProviderEntity emailProviderEntity = emailProviderMapper.toEntity(request); + emailProviderEntity.setDefaultProvider(isFirstProvider); + EmailProviderEntity savedEntity = emailProviderRepository.save(emailProviderEntity); + return emailProviderMapper.toDTO(savedEntity); + } + + public EmailProvider updateEmailProvider(Long id, CreateEmailProviderRequest request) { + EmailProviderEntity existingProvider = emailProviderRepository.findById(id).orElseThrow(() -> ApiError.EMAIL_PROVIDER_NOT_FOUND.createException(id)); + emailProviderMapper.updateEntityFromRequest(request, existingProvider); + EmailProviderEntity updatedEntity = emailProviderRepository.save(existingProvider); + return emailProviderMapper.toDTO(updatedEntity); + } + + @Transactional + public void setDefaultEmailProvider(Long id) { + EmailProviderEntity emailProvider = emailProviderRepository.findById(id).orElseThrow(() -> ApiError.EMAIL_PROVIDER_NOT_FOUND.createException(id)); + emailProviderRepository.updateAllProvidersToNonDefault(); + emailProvider.setDefaultProvider(true); + emailProviderRepository.save(emailProvider); + } + + public void deleteEmailProvider(Long id) { + EmailProviderEntity emailProviderToDelete = emailProviderRepository.findById(id).orElseThrow(() -> ApiError.EMAIL_PROVIDER_NOT_FOUND.createException(id)); + boolean isDefaultProvider = emailProviderToDelete.isDefaultProvider(); + if (isDefaultProvider) { + List allProviders = emailProviderRepository.findAll(); + if (allProviders.size() > 1) { + allProviders.remove(emailProviderToDelete); + int randomIndex = (int) (Math.random() * allProviders.size()); + EmailProviderEntity newDefaultProvider = allProviders.get(randomIndex); + newDefaultProvider.setDefaultProvider(true); + emailProviderRepository.save(newDefaultProvider); + } + } + emailProviderRepository.deleteById(id); + } + + public List getEmailProviders() { + return emailProviderRepository.findAll().stream().map(emailProviderMapper::toDTO).collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/EmailRecipientService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/EmailRecipientService.java new file mode 100644 index 000000000..1ee92460c --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/EmailRecipientService.java @@ -0,0 +1,80 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.mapper.EmailRecipientMapper; +import com.adityachandel.booklore.model.dto.EmailRecipient; +import com.adityachandel.booklore.model.dto.request.CreateEmailRecipientRequest; +import com.adityachandel.booklore.model.entity.EmailRecipientEntity; +import com.adityachandel.booklore.repository.EmailRecipientRepository; +import jakarta.transaction.Transactional; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@AllArgsConstructor +public class EmailRecipientService { + + private final EmailRecipientRepository emailRecipientRepository; + private final EmailRecipientMapper emailRecipientMapper; + + public EmailRecipient getEmailRecipient(Long id) { + EmailRecipientEntity emailRecipient = emailRecipientRepository.findById(id).orElseThrow(() -> ApiError.EMAIL_RECIPIENT_NOT_FOUND.createException(id)); + return emailRecipientMapper.toDTO(emailRecipient); + } + + public EmailRecipient createEmailRecipient(CreateEmailRecipientRequest request) { + boolean isFirstRecipient = emailRecipientRepository.count() == 0; + if (request.isDefaultRecipient() || isFirstRecipient) { + emailRecipientRepository.updateAllRecipientsToNonDefault(); + } + EmailRecipientEntity emailRecipientEntity = emailRecipientMapper.toEntity(request); + emailRecipientEntity.setDefaultRecipient(request.isDefaultRecipient() || isFirstRecipient); + EmailRecipientEntity savedEntity = emailRecipientRepository.save(emailRecipientEntity); + return emailRecipientMapper.toDTO(savedEntity); + } + + public EmailRecipient updateEmailRecipient(Long id, CreateEmailRecipientRequest request) { + EmailRecipientEntity existingRecipient = emailRecipientRepository.findById(id).orElseThrow(() -> ApiError.EMAIL_RECIPIENT_NOT_FOUND.createException(id)); + if (request.isDefaultRecipient()) { + emailRecipientRepository.updateAllRecipientsToNonDefault(); + } + emailRecipientMapper.updateEntityFromRequest(request, existingRecipient); + EmailRecipientEntity updatedEntity = emailRecipientRepository.save(existingRecipient); + return emailRecipientMapper.toDTO(updatedEntity); + } + + @Transactional + public void setDefaultRecipient(Long id) { + EmailRecipientEntity emailRecipient = emailRecipientRepository.findById(id).orElseThrow(() -> ApiError.EMAIL_RECIPIENT_NOT_FOUND.createException(id)); + emailRecipientRepository.updateAllRecipientsToNonDefault(); + emailRecipient.setDefaultRecipient(true); + emailRecipientRepository.save(emailRecipient); + } + + public void deleteEmailRecipient(Long id) { + EmailRecipientEntity emailRecipientToDelete = emailRecipientRepository.findById(id).orElseThrow(() -> ApiError.EMAIL_RECIPIENT_NOT_FOUND.createException(id)); + boolean isDefaultRecipient = emailRecipientToDelete.isDefaultRecipient(); + if (isDefaultRecipient) { + List allRecipients = emailRecipientRepository.findAll(); + if (allRecipients.size() > 1) { + allRecipients.remove(emailRecipientToDelete); + int randomIndex = (int) (Math.random() * allRecipients.size()); + EmailRecipientEntity newDefaultRecipient = allRecipients.get(randomIndex); + newDefaultRecipient.setDefaultRecipient(true); + emailRecipientRepository.save(newDefaultRecipient); + } + } + emailRecipientRepository.deleteById(id); + } + + public List getEmailRecipients() { + return emailRecipientRepository.findAll().stream() + .map(emailRecipientMapper::toDTO) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/EmailService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/EmailService.java new file mode 100644 index 000000000..6dbe14edb --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/EmailService.java @@ -0,0 +1,111 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.model.dto.request.SendBookByEmailRequest; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.EmailProviderEntity; +import com.adityachandel.booklore.model.entity.EmailRecipientEntity; +import com.adityachandel.booklore.model.websocket.Topic; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.repository.EmailProviderRepository; +import com.adityachandel.booklore.repository.EmailRecipientRepository; +import com.adityachandel.booklore.util.FileUtils; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.util.Properties; + +import static com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification; + +@Slf4j +@Service +@AllArgsConstructor +public class EmailService { + + private final EmailProviderRepository emailProviderRepository; + private final BookRepository bookRepository; + private final EmailRecipientRepository emailRecipientRepository; + private final NotificationService notificationService; + + public void emailBookQuick(Long bookId) { + BookEntity book = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); + EmailProviderEntity defaultEmailProvider = emailProviderRepository.findDefaultEmailProvider().orElseThrow(ApiError.DEFAULT_EMAIL_PROVIDER_NOT_FOUND::createException); + EmailRecipientEntity defaultEmailRecipient = emailRecipientRepository.findDefaultEmailRecipient().orElseThrow(ApiError.DEFAULT_EMAIL_RECIPIENT_NOT_FOUND::createException); + + sendEmailInVirtualThread(defaultEmailProvider, defaultEmailRecipient.getEmail(), book); + } + + public void emailBook(SendBookByEmailRequest request) { + EmailProviderEntity emailProvider = emailProviderRepository.findById(request.getProviderId()).orElseThrow(() -> ApiError.EMAIL_PROVIDER_NOT_FOUND.createException(request.getProviderId())); + BookEntity book = bookRepository.findById(request.getBookId()).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(request.getBookId())); + EmailRecipientEntity defaultEmailRecipient = emailRecipientRepository.findDefaultEmailRecipient().orElseThrow(ApiError.DEFAULT_EMAIL_RECIPIENT_NOT_FOUND::createException); + + sendEmailInVirtualThread(emailProvider, defaultEmailRecipient.getEmail(), book); + } + + private void sendEmailInVirtualThread(EmailProviderEntity emailProvider, String recipientEmail, BookEntity book) { + String bookTitle = book.getMetadata().getTitle(); + String logMessage = "Email dispatch initiated for book: " + bookTitle + " to " + recipientEmail; + notificationService.sendMessage(Topic.LOG, createLogNotification(logMessage)); + log.info(logMessage); + + Thread.startVirtualThread(() -> { + try { + sendEmail(emailProvider, recipientEmail, book); + String successMessage = "The book: " + bookTitle + " has been successfully sent to " + recipientEmail; + notificationService.sendMessage(Topic.LOG, createLogNotification(successMessage)); + log.info(successMessage); + } catch (Exception e) { + String errorMessage = "An error occurred while sending the book: " + bookTitle + " to " + recipientEmail + ". Error: " + e.getMessage(); + notificationService.sendMessage(Topic.LOG, createLogNotification(errorMessage)); + log.error(errorMessage, e); + } + }); + } + + private void sendEmail(EmailProviderEntity emailProvider, String recipientEmail, BookEntity book) throws MessagingException { + JavaMailSenderImpl dynamicMailSender = setupMailSender(emailProvider); + MimeMessage message = dynamicMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true); + helper.setFrom(emailProvider.getUsername()); + helper.setTo(recipientEmail); + helper.setSubject("Your Book from Booklore: " + book.getMetadata().getTitle()); + helper.setText(generateEmailBody(book.getMetadata().getTitle())); + File bookFile = new File(FileUtils.getBookFullPath(book)); + helper.addAttachment(bookFile.getName(), bookFile); + dynamicMailSender.send(message); + log.info("Book sent successfully to {}", recipientEmail); + } + + private JavaMailSenderImpl setupMailSender(EmailProviderEntity emailProvider) { + JavaMailSenderImpl dynamicMailSender = new JavaMailSenderImpl(); + dynamicMailSender.setHost(emailProvider.getHost()); + dynamicMailSender.setPort(emailProvider.getPort()); + dynamicMailSender.setUsername(emailProvider.getUsername()); + dynamicMailSender.setPassword(emailProvider.getPassword()); + + Properties mailProps = dynamicMailSender.getJavaMailProperties(); + mailProps.put("mail.smtp.auth", emailProvider.isAuth()); + mailProps.put("mail.smtp.starttls.enable", emailProvider.isStartTls()); + mailProps.put("mail.smtp.connectiontimeout", 15000); // 15 seconds connection timeout + mailProps.put("mail.smtp.timeout", 15000); // 15 seconds socket timeout + + return dynamicMailSender; + } + + private String generateEmailBody(String bookTitle) { + return String.format(""" + Hello, + + You have received a book from Booklore. Please find the attached file titled '%s' for your reading pleasure. + + Thank you for using Booklore! We hope you enjoy your book. + """, bookTitle); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserCreatorService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserCreatorService.java index ee377a466..ed124b030 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserCreatorService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserCreatorService.java @@ -49,6 +49,7 @@ public class UserCreatorService { permissions.setPermissionUpload(request.isPermissionUpload()); permissions.setPermissionDownload(request.isPermissionDownload()); permissions.setPermissionEditMetadata(request.isPermissionEditMetadata()); + permissions.setPermissionEmailBook(request.isPermissionEmailBook()); user.setPermissions(permissions); user.setBookPreferences(buildDefaultBookPreferences()); @@ -81,6 +82,7 @@ public class UserCreatorService { permissions.setPermissionDownload(true); permissions.setPermissionManipulateLibrary(true); permissions.setPermissionEditMetadata(true); + permissions.setPermissionEmailBook(true); permissions.setPermissionAdmin(true); user.setPermissions(permissions); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java index 49060a48f..32223bd8a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java @@ -46,6 +46,7 @@ public class UserService { user.getPermissions().setPermissionUpload(updateRequest.getPermissions().isCanUpload()); user.getPermissions().setPermissionDownload(updateRequest.getPermissions().isCanDownload()); user.getPermissions().setPermissionEditMetadata(updateRequest.getPermissions().isCanEditMetadata()); + user.getPermissions().setPermissionEmailBook(updateRequest.getPermissions().isCanEmailBook()); List libraryIds = updateRequest.getAssignedLibraries(); if (libraryIds != null) { diff --git a/booklore-api/src/main/resources/db/migration/V3__Create_Email_Tables.sql b/booklore-api/src/main/resources/db/migration/V3__Create_Email_Tables.sql new file mode 100644 index 000000000..468edc50e --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V3__Create_Email_Tables.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS email_provider +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + host VARCHAR(255) NOT NULL, + port INT NOT NULL, + username VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + auth BOOLEAN NOT NULL, + start_tls BOOLEAN NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (name, host, username) +); + +CREATE TABLE IF NOT EXISTS email_recipient +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE user_permissions + ADD COLUMN permission_email_book BOOLEAN NOT NULL DEFAULT TRUE; + +UPDATE user_permissions up + JOIN users u ON up.user_id = u.id +SET up.permission_email_book = TRUE +WHERE u.name = 'admin'; diff --git a/booklore-ui/src/app/app.routes.ts b/booklore-ui/src/app/app.routes.ts index 0595e984f..f9bff6317 100644 --- a/booklore-ui/src/app/app.routes.ts +++ b/booklore-ui/src/app/app.routes.ts @@ -8,6 +8,7 @@ import {SettingsComponent} from './core/component/settings/settings.component'; import {PdfViewerComponent} from './book/components/pdf-viewer/pdf-viewer.component'; import {EpubViewerComponent} from './book/components/epub-viewer/component/epub-viewer.component'; import {ChangePasswordComponent} from './core/component/change-password/change-password.component'; +import {EmailComponent} from './settings/email/email.component'; export const routes: Routes = [ { diff --git a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html index 33380373a..3bbdb06b7 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html @@ -28,7 +28,7 @@

{{ book.metadata?.title }}

- + this.openShelfDialog() - }, - { - label: 'View Details', - icon: 'pi pi-info-circle', - command: () => this.metadataDialogService.openBookMetadataCenterDialog(this.book.id, 'view'), - }, - ...this.getPermissionBasedMenuItems(), - ], + label: 'Assign Shelves', + icon: 'pi pi-folder', + command: () => this.openShelfDialog() }, + { + label: 'View Details', + icon: 'pi pi-info-circle', + command: () => this.metadataDialogService.openBookMetadataCenterDialog(this.book.id, 'view'), + }, + ...this.getPermissionBasedMenuItems(), ]; } @@ -139,6 +140,56 @@ export class BookCardComponent implements OnInit { }); } + if (this.hasEmailBookPermission()) { + items.push( + { + label: 'Send Book', + icon: 'pi pi-envelope', + items: [{ + label: 'Quick Send', + icon: 'pi pi-envelope', + command: () => { + this.emailService.emailBookQuick(this.book.id).subscribe({ + next: () => { + this.messageService.add({ + severity: 'info', + summary: 'Success', + detail: 'The book sending has been scheduled.', + }); + }, + error: (err) => { + const errorMessage = err?.error?.message || 'An error occurred while sending the book.'; + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: errorMessage, + }); + }, + }); + } + }, + { + label: 'Custom Send', + icon: 'pi pi-envelope', + command: () => { + this.dialogService.open(BookSenderComponent, { + header: 'Send Book to Email', + modal: true, + closable: true, + style: { + position: 'absolute', + top: '15%', + }, + data: { + book: this.book, + } + }); + } + } + ] + }); + } + return items; } @@ -170,4 +221,8 @@ export class BookCardComponent implements OnInit { private hasDownloadPermission(): boolean { return this.userPermissions?.canDownload ?? false; } + + private hasEmailBookPermission(): boolean { + return this.userPermissions?.canEmailBook ?? false; + } } diff --git a/booklore-ui/src/app/book/components/book-sender/book-sender.component.html b/booklore-ui/src/app/book/components/book-sender/book-sender.component.html new file mode 100644 index 000000000..07f2fda96 --- /dev/null +++ b/booklore-ui/src/app/book/components/book-sender/book-sender.component.html @@ -0,0 +1,27 @@ +
+ + + + + + + + + + +
diff --git a/booklore-ui/src/app/book/components/book-sender/book-sender.component.scss b/booklore-ui/src/app/book/components/book-sender/book-sender.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/booklore-ui/src/app/book/components/book-sender/book-sender.component.ts b/booklore-ui/src/app/book/components/book-sender/book-sender.component.ts new file mode 100644 index 000000000..971c4664a --- /dev/null +++ b/booklore-ui/src/app/book/components/book-sender/book-sender.component.ts @@ -0,0 +1,114 @@ +import {Component, inject, OnInit} from '@angular/core'; +import {Button} from 'primeng/button'; +import {Select} from 'primeng/select'; +import {FormsModule} from '@angular/forms'; +import {EmailProviderService} from '../../../settings/email/email-provider/email-provider.service'; +import {EmailRecipientService} from '../../../settings/email/email-recipient/email-recipient.service'; +import {EmailProvider} from '../../../settings/email/email-provider/email-provider.model'; +import {EmailRecipient} from '../../../settings/email/email-recipient/email-recipient.model'; +import {EmailService} from '../../../settings/email/email.service'; +import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog'; +import {Book} from '../../model/book.model'; +import {MessageService} from 'primeng/api'; + +@Component({ + selector: 'app-book-sender', + imports: [ + Button, + Select, + FormsModule + ], + templateUrl: './book-sender.component.html', + styleUrls: ['./book-sender.component.scss'] +}) +export class BookSenderComponent implements OnInit { + + private emailProviderService = inject(EmailProviderService); + private emailRecipientService = inject(EmailRecipientService); + private emailService = inject(EmailService); + private messageService = inject(MessageService); + private dynamicDialogRef = inject(DynamicDialogRef); + private dynamicDialogConfig = inject(DynamicDialogConfig); + + book: Book = this.dynamicDialogConfig.data.book; + + emailProviders: { label: string, value: EmailProvider }[] = []; + emailRecipients: { label: string, value: EmailRecipient }[] = []; + selectedProvider?: any; + selectedRecipient?: any; + + ngOnInit(): void { + this.emailProviderService.getEmailProviders().subscribe({ + next: (emailProviders: EmailProvider[]) => { + this.emailProviders = emailProviders.map(provider => ({ + label: `${provider.name} | ${provider.host}`, + value: provider + })); + } + }); + + this.emailRecipientService.getRecipients().subscribe({ + next: (emailRecipients: EmailRecipient[]) => { + this.emailRecipients = emailRecipients.map(recipient => ({ + label: `${recipient.name} | ${recipient.email}`, + value: recipient + })); + } + }); + } + + sendBook() { + if (this.selectedProvider && this.selectedRecipient && this.book) { + const bookId = this.book.id; + const recipientId = this.selectedRecipient.value.id; + const providerId = this.selectedProvider.value.id; + + const emailRequest = { + bookId, + providerId, + recipientId: recipientId, + }; + + this.emailService.emailBook(emailRequest).subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Email Scheduled', + detail: 'The book has been successfully scheduled for sending.' + }); + this.dynamicDialogRef.close(true); + }, + error: (error) => { + this.messageService.add({ + severity: 'error', + summary: 'Sending Failed', + detail: 'There was an issue while scheduling the book for sending. Please try again later.' + }); + console.error('Error sending book:', error); + } + }); + } else { + if (!this.selectedProvider) { + this.messageService.add({ + severity: 'error', + summary: 'Email Provider Missing', + detail: 'Please select an email provider to proceed.' + }); + } + if (!this.selectedRecipient) { + this.messageService.add({ + severity: 'error', + summary: 'Recipient Missing', + detail: 'Please select a recipient to send the book.' + }); + } + if (!this.book) { + this.messageService.add({ + severity: 'error', + summary: 'Book Not Selected', + detail: 'Please select a book to send.' + }); + } + } + } +} diff --git a/booklore-ui/src/app/core/component/settings/admin/admin.component.html b/booklore-ui/src/app/core/component/settings/admin/admin.component.html index 22165af95..7035953f1 100644 --- a/booklore-ui/src/app/core/component/settings/admin/admin.component.html +++ b/booklore-ui/src/app/core/component/settings/admin/admin.component.html @@ -11,6 +11,7 @@ Can Upload Can Download Can Edit Metadata + Can Email Books Edit Change Password Delete @@ -54,6 +55,10 @@ + + + + diff --git a/booklore-ui/src/app/core/component/settings/admin/create-user-dialog/create-user-dialog.component.html b/booklore-ui/src/app/core/component/settings/admin/create-user-dialog/create-user-dialog.component.html index 7de9ad63c..736906a8b 100644 --- a/booklore-ui/src/app/core/component/settings/admin/create-user-dialog/create-user-dialog.component.html +++ b/booklore-ui/src/app/core/component/settings/admin/create-user-dialog/create-user-dialog.component.html @@ -65,6 +65,11 @@
+ +
+ + +
diff --git a/booklore-ui/src/app/core/component/settings/admin/create-user-dialog/create-user-dialog.component.ts b/booklore-ui/src/app/core/component/settings/admin/create-user-dialog/create-user-dialog.component.ts index 5d343ae0b..1156beb3b 100644 --- a/booklore-ui/src/app/core/component/settings/admin/create-user-dialog/create-user-dialog.component.ts +++ b/booklore-ui/src/app/core/component/settings/admin/create-user-dialog/create-user-dialog.component.ts @@ -47,7 +47,8 @@ export class CreateUserDialogComponent implements OnInit { selectedLibraries: [[], Validators.required], permissionUpload: [false], permissionDownload: [false], - permissionEditMetadata: [false] + permissionEditMetadata: [false], + permissionEmailBook: [false] }); } diff --git a/booklore-ui/src/app/core/component/settings/settings.component.html b/booklore-ui/src/app/core/component/settings/settings.component.html index 00fedc30a..79353a770 100644 --- a/booklore-ui/src/app/core/component/settings/settings.component.html +++ b/booklore-ui/src/app/core/component/settings/settings.component.html @@ -8,6 +8,9 @@ User Management + + Email + @@ -24,6 +27,11 @@ + + + + + diff --git a/booklore-ui/src/app/core/component/settings/settings.component.ts b/booklore-ui/src/app/core/component/settings/settings.component.ts index 14714dc07..76f504ce1 100644 --- a/booklore-ui/src/app/core/component/settings/settings.component.ts +++ b/booklore-ui/src/app/core/component/settings/settings.component.ts @@ -8,6 +8,7 @@ import {MetadataAdvancedFetchOptionsComponent} from "../../../metadata/metadata- import {MetadataRefreshOptions} from '../../../metadata/model/request/metadata-refresh-options.model'; import {AppSettingsService} from '../../service/app-settings.service'; import {MetadataSettingsComponent} from './metadata-settings/metadata-settings.component'; +import {EmailComponent} from '../../../settings/email/email.component'; @Component({ selector: 'app-settings', @@ -21,7 +22,8 @@ import {MetadataSettingsComponent} from './metadata-settings/metadata-settings.c AdminComponent, NgIf, AsyncPipe, - MetadataSettingsComponent + MetadataSettingsComponent, + EmailComponent ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss' diff --git a/booklore-ui/src/app/settings/email/create-email-provider-dialog/create-email-provider-dialog.component.html b/booklore-ui/src/app/settings/email/create-email-provider-dialog/create-email-provider-dialog.component.html new file mode 100644 index 000000000..4dc52d747 --- /dev/null +++ b/booklore-ui/src/app/settings/email/create-email-provider-dialog/create-email-provider-dialog.component.html @@ -0,0 +1,52 @@ +
+ +
+ + +
+ + Provider name is required. + + +
+ + +
+ + Host is required. + + +
+ + +
+ + Port is required and must be a number. + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
diff --git a/booklore-ui/src/app/settings/email/create-email-provider-dialog/create-email-provider-dialog.component.scss b/booklore-ui/src/app/settings/email/create-email-provider-dialog/create-email-provider-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/booklore-ui/src/app/settings/email/create-email-provider-dialog/create-email-provider-dialog.component.ts b/booklore-ui/src/app/settings/email/create-email-provider-dialog/create-email-provider-dialog.component.ts new file mode 100644 index 000000000..5b6fbc61e --- /dev/null +++ b/booklore-ui/src/app/settings/email/create-email-provider-dialog/create-email-provider-dialog.component.ts @@ -0,0 +1,75 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { Button } from 'primeng/button'; +import { Checkbox } from 'primeng/checkbox'; +import { InputText } from 'primeng/inputtext'; +import { NgIf } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { EmailProviderService } from '../email-provider/email-provider.service'; +import { MessageService } from 'primeng/api'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; + +@Component({ + selector: 'app-create-email-provider-dialog', + imports: [ + Button, + Checkbox, + InputText, + NgIf, + ReactiveFormsModule + ], + templateUrl: './create-email-provider-dialog.component.html', + styleUrl: './create-email-provider-dialog.component.scss' +}) +export class CreateEmailProviderDialogComponent implements OnInit { + emailProviderForm!: FormGroup; + + private fb = inject(FormBuilder); + private emailProviderService = inject(EmailProviderService); + private messageService = inject(MessageService); + private ref = inject(DynamicDialogRef); + + ngOnInit() { + this.emailProviderForm = this.fb.group({ + name: ['', [Validators.required, Validators.minLength(3)]], + host: ['', Validators.required], + port: [null, [Validators.required, Validators.min(1)]], + username: [''], + password: [''], + auth: [false], + startTls: [false] + }); + } + + createEmailProvider() { + if (this.emailProviderForm.invalid) { + this.messageService.add({ + severity: 'warn', + summary: 'Validation Error', + detail: 'Please correct errors before submitting.' + }); + return; + } + + const emailProviderData = this.emailProviderForm.value; + + this.emailProviderService.createEmailProvider(emailProviderData).subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Email Provider Created', + detail: 'The email provider has been successfully created.' + }); + this.ref.close(true); + }, + error: (err) => { + this.messageService.add({ + severity: 'error', + summary: 'Email Provider Creation Failed', + detail: err?.error?.message + ? `Unable to create email provider: ${err.error.message}` + : 'An unexpected error occurred while creating the email provider. Please try again later.' + }); + } + }); + } +} diff --git a/booklore-ui/src/app/settings/email/create-email-recipient-dialog/create-email-recipient-dialog.component.html b/booklore-ui/src/app/settings/email/create-email-recipient-dialog/create-email-recipient-dialog.component.html new file mode 100644 index 000000000..1cd2e138c --- /dev/null +++ b/booklore-ui/src/app/settings/email/create-email-recipient-dialog/create-email-recipient-dialog.component.html @@ -0,0 +1,29 @@ +
+ +
+ + +
+ + Recipient name is required. + + +
+ + +
+ + Valid email is required. + + +
+ + +
+ +
+ + +
+ +
diff --git a/booklore-ui/src/app/settings/email/create-email-recipient-dialog/create-email-recipient-dialog.component.scss b/booklore-ui/src/app/settings/email/create-email-recipient-dialog/create-email-recipient-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/booklore-ui/src/app/settings/email/create-email-recipient-dialog/create-email-recipient-dialog.component.ts b/booklore-ui/src/app/settings/email/create-email-recipient-dialog/create-email-recipient-dialog.component.ts new file mode 100644 index 000000000..3d57eae62 --- /dev/null +++ b/booklore-ui/src/app/settings/email/create-email-recipient-dialog/create-email-recipient-dialog.component.ts @@ -0,0 +1,70 @@ +import { Component, inject } from '@angular/core'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MessageService } from 'primeng/api'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Checkbox } from 'primeng/checkbox'; +import { Button } from 'primeng/button'; +import { InputText } from 'primeng/inputtext'; +import { NgIf } from '@angular/common'; +import { EmailRecipientService } from '../email-recipient/email-recipient.service'; + +@Component({ + selector: 'app-create-email-recipient-dialog', + imports: [ + Checkbox, + ReactiveFormsModule, + Button, + InputText, + NgIf + ], + templateUrl: './create-email-recipient-dialog.component.html', + styleUrls: ['./create-email-recipient-dialog.component.scss'] +}) +export class CreateEmailRecipientDialogComponent { + emailRecipientForm: FormGroup; + private fb = inject(FormBuilder); + private emailRecipientService = inject(EmailRecipientService); + private messageService = inject(MessageService); + private ref = inject(DynamicDialogRef); + + constructor() { + this.emailRecipientForm = this.fb.group({ + name: ['', Validators.required], + email: ['', [Validators.required, Validators.email]], + defaultRecipient: [false] + }); + } + + createEmailRecipient(): void { + if (this.emailRecipientForm.invalid) { + this.messageService.add({ + severity: 'warn', + summary: 'Validation Error', + detail: 'Please correct errors before submitting.' + }); + return; + } + + const emailRecipientData = this.emailRecipientForm.value; + + this.emailRecipientService.createRecipient(emailRecipientData).subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Recipient Added', + detail: `${emailRecipientData.name} has been successfully added.` + }); + this.ref.close(true); + }, + error: (err) => { + this.messageService.add({ + severity: 'error', + summary: 'Recipient Creation Failed', + detail: err?.error?.message + ? `Unable to create recipient: ${err.error.message}` + : 'An unexpected error occurred while adding the recipient. Please try again later.' + }); + } + }); + } +} diff --git a/booklore-ui/src/app/settings/email/email-provider/email-provider.component.html b/booklore-ui/src/app/settings/email/email-provider/email-provider.component.html new file mode 100644 index 000000000..1ad6dfd32 --- /dev/null +++ b/booklore-ui/src/app/settings/email/email-provider/email-provider.component.html @@ -0,0 +1,91 @@ +
+

+ Email Providers + +

+ +
+ + + + + Default + Name + Host + Port + Username + Password + Auth + StartTLS + Edit + Delete + + + + + + + + + + + + + {{ provider.name }} + + + + + {{ provider.host }} + + + + + {{ provider.port }} + + + + + {{ provider.username }} + + + + + Hidden + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/booklore-ui/src/app/settings/email/email-provider/email-provider.component.scss b/booklore-ui/src/app/settings/email/email-provider/email-provider.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/booklore-ui/src/app/settings/email/email-provider/email-provider.component.ts b/booklore-ui/src/app/settings/email/email-provider/email-provider.component.ts new file mode 100644 index 000000000..2ca186099 --- /dev/null +++ b/booklore-ui/src/app/settings/email/email-provider/email-provider.component.ts @@ -0,0 +1,140 @@ +import {Component, inject, OnInit} from '@angular/core'; +import {Button} from 'primeng/button'; +import {Checkbox} from 'primeng/checkbox'; +import {NgIf} from '@angular/common'; +import {MessageService, PrimeTemplate} from 'primeng/api'; +import {RadioButton} from 'primeng/radiobutton'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {TableModule} from 'primeng/table'; +import {Tooltip} from 'primeng/tooltip'; +import {EmailProvider} from './email-provider.model'; +import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog'; +import {EmailProviderService} from './email-provider.service'; +import {CreateEmailProviderDialogComponent} from '../create-email-provider-dialog/create-email-provider-dialog.component'; + +@Component({ + selector: 'app-email-provider', + imports: [ + Button, + Checkbox, + NgIf, + PrimeTemplate, + RadioButton, + ReactiveFormsModule, + TableModule, + Tooltip, + FormsModule + ], + templateUrl: './email-provider.component.html', + styleUrl: './email-provider.component.scss' +}) +export class EmailProviderComponent implements OnInit { + emailProviders: EmailProvider[] = []; + editingProviderIds: number[] = []; + ref: DynamicDialogRef | undefined; + private dialogService = inject(DialogService); + private emailProvidersService = inject(EmailProviderService); + private messageService = inject(MessageService); + defaultProviderId: any; + + ngOnInit(): void { + this.loadEmailProviders(); + } + + loadEmailProviders(): void { + this.emailProvidersService.getEmailProviders().subscribe({ + next: (emailProviders: EmailProvider[]) => { + this.emailProviders = emailProviders.map((provider) => ({ + ...provider, + isEditing: false, + })); + const defaultProvider = emailProviders.find((provider) => provider.defaultProvider); + this.defaultProviderId = defaultProvider ? defaultProvider.id : null; + }, + error: () => { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to load Email Providers', + }); + }, + }); + } + + toggleEdit(provider: EmailProvider): void { + provider.isEditing = !provider.isEditing; + if (provider.isEditing) { + this.editingProviderIds.push(provider.id); + } else { + this.editingProviderIds = this.editingProviderIds.filter((id) => id !== provider.id); + } + } + + saveProvider(provider: EmailProvider): void { + this.emailProvidersService.updateProvider(provider).subscribe({ + next: () => { + provider.isEditing = false; + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Provider updated successfully', + }); + this.loadEmailProviders(); + }, + error: () => { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to update provider', + }); + }, + }); + } + + deleteProvider(provider: EmailProvider): void { + if (confirm(`Are you sure you want to delete provider "${provider.name}"?`)) { + this.emailProvidersService.deleteProvider(provider.id).subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: `Provider "${provider.name}" deleted successfully`, + }); + this.loadEmailProviders(); + }, + error: () => { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to delete provider', + }); + }, + }); + } + } + + openCreateProviderDialog() { + this.ref = this.dialogService.open(CreateEmailProviderDialogComponent, { + header: 'Create New Email Provider', + modal: true, + closable: true, + style: { position: 'absolute', top: '15%' }, + }); + this.ref.onClose.subscribe((result) => { + if (result) { + this.loadEmailProviders(); + } + }); + } + + setDefaultProvider(provider: EmailProvider) { + this.emailProvidersService.setDefaultProvider(provider.id).subscribe(() => { + this.defaultProviderId = provider.id; + this.messageService.add({ + severity: 'success', + summary: 'Default Provider Set', + detail: `${provider.name} is now the default email provider.` + }); + }); + } +} diff --git a/booklore-ui/src/app/settings/email/email-provider/email-provider.model.ts b/booklore-ui/src/app/settings/email/email-provider/email-provider.model.ts new file mode 100644 index 000000000..78ed2ab60 --- /dev/null +++ b/booklore-ui/src/app/settings/email/email-provider/email-provider.model.ts @@ -0,0 +1,12 @@ +export interface EmailProvider { + isEditing: boolean; + id: number; + name: string; + host: string; + port: number; + username: string; + password: string; + auth: boolean; + startTls: boolean; + defaultProvider: boolean; +} diff --git a/booklore-ui/src/app/settings/email/email-provider/email-provider.service.ts b/booklore-ui/src/app/settings/email/email-provider/email-provider.service.ts new file mode 100644 index 000000000..30edd6e3c --- /dev/null +++ b/booklore-ui/src/app/settings/email/email-provider/email-provider.service.ts @@ -0,0 +1,34 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { EmailProvider } from './email-provider.model'; +import { API_CONFIG } from '../../../config/api-config'; + +@Injectable({ + providedIn: 'root' +}) +export class EmailProviderService { + private readonly url = `${API_CONFIG.BASE_URL}/api/v1/email/providers`; + + private http = inject(HttpClient); + + getEmailProviders(): Observable { + return this.http.get(this.url); + } + + createEmailProvider(provider: EmailProvider): Observable { + return this.http.post(this.url, provider); + } + + updateProvider(provider: EmailProvider): Observable { + return this.http.put(`${this.url}/${provider.id}`, provider); + } + + deleteProvider(id: number): Observable { + return this.http.delete(`${this.url}/${id}`); + } + + setDefaultProvider(id: number): Observable { + return this.http.patch(`${this.url}/${id}/set-default`, {}); + } +} diff --git a/booklore-ui/src/app/settings/email/email-recipient/email-recipient.component.html b/booklore-ui/src/app/settings/email/email-recipient/email-recipient.component.html new file mode 100644 index 000000000..7a1abd743 --- /dev/null +++ b/booklore-ui/src/app/settings/email/email-recipient/email-recipient.component.html @@ -0,0 +1,55 @@ +
+

+ Recipient Emails + +

+ +
+ + + + + Default + Email Address + Name + Edit + Delete + + + + + + + + + + + + + {{ recipient.email }} + + + + + {{ recipient.name }} + + + + + + + + + + + + + + diff --git a/booklore-ui/src/app/settings/email/email-recipient/email-recipient.component.scss b/booklore-ui/src/app/settings/email/email-recipient/email-recipient.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/booklore-ui/src/app/settings/email/email-recipient/email-recipient.component.ts b/booklore-ui/src/app/settings/email/email-recipient/email-recipient.component.ts new file mode 100644 index 000000000..a96923f86 --- /dev/null +++ b/booklore-ui/src/app/settings/email/email-recipient/email-recipient.component.ts @@ -0,0 +1,141 @@ +import {Component, inject, OnInit} from '@angular/core'; +import {Button} from 'primeng/button'; +import {NgIf} from '@angular/common'; +import {MessageService, PrimeTemplate} from 'primeng/api'; +import {RadioButton} from 'primeng/radiobutton'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {TableModule} from 'primeng/table'; +import {Tooltip} from 'primeng/tooltip'; +import {EmailProvider} from '../email-provider/email-provider.model'; +import {EmailRecipient} from './email-recipient.model'; +import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog'; +import {EmailProviderService} from '../email-provider/email-provider.service'; +import {EmailRecipientService} from './email-recipient.service'; +import {CreateEmailProviderDialogComponent} from '../create-email-provider-dialog/create-email-provider-dialog.component'; +import {CreateEmailRecipientDialogComponent} from '../create-email-recipient-dialog/create-email-recipient-dialog.component'; + +@Component({ + selector: 'app-email-recipient', + imports: [ + Button, + NgIf, + PrimeTemplate, + RadioButton, + ReactiveFormsModule, + TableModule, + Tooltip, + FormsModule + ], + templateUrl: './email-recipient.component.html', + styleUrl: './email-recipient.component.scss' +}) +export class EmailRecipientComponent implements OnInit { + recipientEmails: EmailRecipient[] = []; + editingRecipientIds: number[] = []; + ref: DynamicDialogRef | undefined; + private dialogService = inject(DialogService); + private emailRecipientService = inject(EmailRecipientService); + private messageService = inject(MessageService); + defaultRecipientId: any; + + ngOnInit(): void { + this.loadRecipientEmails(); + } + + loadRecipientEmails(): void { + this.emailRecipientService.getRecipients().subscribe({ + next: (recipients: EmailRecipient[]) => { + this.recipientEmails = recipients.map((recipient) => ({ + ...recipient, + isEditing: false, + })); + const defaultRecipient = recipients.find((recipient) => recipient.defaultRecipient); + this.defaultRecipientId = defaultRecipient ? defaultRecipient.id : null; + }, + error: () => { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to load recipient emails', + }); + }, + }); + } + + toggleEditRecipient(recipient: EmailRecipient): void { + recipient.isEditing = !recipient.isEditing; + if (recipient.isEditing) { + this.editingRecipientIds.push(recipient.id); + } else { + this.editingRecipientIds = this.editingRecipientIds.filter((id) => id !== recipient.id); + } + } + + saveRecipient(recipient: EmailRecipient): void { + this.emailRecipientService.updateRecipient(recipient).subscribe({ + next: () => { + recipient.isEditing = false; + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Recipient updated successfully', + }); + this.loadRecipientEmails(); + }, + error: () => { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to update recipient', + }); + }, + }); + } + + deleteRecipient(recipient: EmailRecipient): void { + if (confirm(`Are you sure you want to delete recipient "${recipient.email}"?`)) { + this.emailRecipientService.deleteRecipient(recipient.id).subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: `Recipient "${recipient.email}" deleted successfully`, + }); + this.loadRecipientEmails(); + }, + error: () => { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to delete recipient', + }); + }, + }); + } + } + + openAddRecipientDialog() { + this.ref = this.dialogService.open(CreateEmailRecipientDialogComponent, { + header: 'Add New Recipient', + modal: true, + closable: true, + style: {position: 'absolute', top: '15%'}, + }); + this.ref.onClose.subscribe((result) => { + if (result) { + this.loadRecipientEmails(); + } + }); + } + + setDefaultRecipient(recipient: EmailRecipient) { + this.emailRecipientService.setDefaultRecipient(recipient.id).subscribe(() => { + this.defaultRecipientId = recipient.id; + this.messageService.add({ + severity: 'success', + summary: 'Default Recipient Set', + detail: `${recipient.email} is now the default recipient.` + }); + }); + } +} diff --git a/booklore-ui/src/app/settings/email/email-recipient/email-recipient.model.ts b/booklore-ui/src/app/settings/email/email-recipient/email-recipient.model.ts new file mode 100644 index 000000000..0ebda3e40 --- /dev/null +++ b/booklore-ui/src/app/settings/email/email-recipient/email-recipient.model.ts @@ -0,0 +1,7 @@ +export interface EmailRecipient { + id: number; + email: string; + name: string; + defaultRecipient: boolean; + isEditing: boolean; +} diff --git a/booklore-ui/src/app/settings/email/email-recipient/email-recipient.service.ts b/booklore-ui/src/app/settings/email/email-recipient/email-recipient.service.ts new file mode 100644 index 000000000..cbdbe6700 --- /dev/null +++ b/booklore-ui/src/app/settings/email/email-recipient/email-recipient.service.ts @@ -0,0 +1,34 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { API_CONFIG } from '../../../config/api-config'; +import {EmailRecipient} from './email-recipient.model'; + +@Injectable({ + providedIn: 'root' +}) +export class EmailRecipientService { + private readonly url = `${API_CONFIG.BASE_URL}/api/v1/email/recipients`; + + private http = inject(HttpClient); + + getRecipients(): Observable { + return this.http.get(this.url); + } + + createRecipient(recipient: EmailRecipient): Observable { + return this.http.post(this.url, recipient); + } + + updateRecipient(recipient: EmailRecipient): Observable { + return this.http.put(`${this.url}/${recipient.id}`, recipient); + } + + deleteRecipient(id: number): Observable { + return this.http.delete(`${this.url}/${id}`); + } + + setDefaultRecipient(id: number): Observable { + return this.http.patch(`${this.url}/${id}/set-default`, {}); + } +} diff --git a/booklore-ui/src/app/settings/email/email.component.html b/booklore-ui/src/app/settings/email/email.component.html new file mode 100644 index 000000000..b63d0e4c0 --- /dev/null +++ b/booklore-ui/src/app/settings/email/email.component.html @@ -0,0 +1,11 @@ +
+ +
+ +
+ +
+ +
+ +
diff --git a/booklore-ui/src/app/settings/email/email.component.scss b/booklore-ui/src/app/settings/email/email.component.scss new file mode 100644 index 000000000..b886b1f68 --- /dev/null +++ b/booklore-ui/src/app/settings/email/email.component.scss @@ -0,0 +1,3 @@ +.enclosing-container { + border-color: var(--p-content-border-color); +} diff --git a/booklore-ui/src/app/settings/email/email.component.ts b/booklore-ui/src/app/settings/email/email.component.ts new file mode 100644 index 000000000..9b3516aab --- /dev/null +++ b/booklore-ui/src/app/settings/email/email.component.ts @@ -0,0 +1,20 @@ +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {TableModule} from 'primeng/table'; +import {EmailProviderComponent} from './email-provider/email-provider.component'; +import {EmailRecipientComponent} from './email-recipient/email-recipient.component'; + +@Component({ + selector: 'app-email', + imports: [ + FormsModule, + TableModule, + EmailProviderComponent, + EmailRecipientComponent + ], + templateUrl: './email.component.html', + styleUrls: ['./email.component.scss'], +}) +export class EmailComponent { + +} diff --git a/booklore-ui/src/app/settings/email/email.service.ts b/booklore-ui/src/app/settings/email/email.service.ts new file mode 100644 index 000000000..2f120c552 --- /dev/null +++ b/booklore-ui/src/app/settings/email/email.service.ts @@ -0,0 +1,22 @@ +import {inject, Injectable} from '@angular/core'; +import {API_CONFIG} from '../../config/api-config'; +import {HttpClient} from '@angular/common/http'; +import {Observable} from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class EmailService { + + private readonly apiUrl = `${API_CONFIG.BASE_URL}/api/v1/emails`; + + private http = inject(HttpClient); + + emailBook(request: { bookId: number, providerId: number, recipientId: number }): Observable { + return this.http.post(`${this.apiUrl}/send-book`, request); + } + + emailBookQuick(bookId: number): Observable { + return this.http.post(`${this.apiUrl}/send-book/${bookId}`, {}); + } +} diff --git a/booklore-ui/src/app/user.service.ts b/booklore-ui/src/app/user.service.ts index f892ed510..36685ec31 100644 --- a/booklore-ui/src/app/user.service.ts +++ b/booklore-ui/src/app/user.service.ts @@ -17,6 +17,7 @@ export interface User { admin: boolean; canUpload: boolean; canDownload: boolean; + canEmailBook: boolean; canEditMetadata: boolean; canManipulateLibrary: boolean; };