diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 41b72af..cd30cf1 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -4,6 +4,7 @@ on: push: branches: - 'main' + - 'dev' env: PIPELINE_USER_ACCESS_KEY_ID: ${{ secrets.PIPELINE_USER_ACCESS_KEY_ID}} @@ -13,7 +14,7 @@ env: TESTING_PIPELINE_EXECUTION_ROLE: ${{ secrets.TESTING_PIPELINE_EXECUTION_ROLE }} TESTING_CLOUDFORMATION_EXECUTION_ROLE: ${{ secrets.TESTING_CLOUDFORMATION_EXECUTION_ROLE }} TESTING_ARTIFACTS_BUCKET: ${{ secrets.TESTING_ARTIFACTS_BUCKET }} - TESTING_PARAMETER_OVERRIDES: TasksTableName=tasks,CognitoCallbackURL=https://start.spring.io/,UserPoolAdminGroupName=apiAdmins + TESTING_PARAMETER_OVERRIDES: TasksTableName=test-tasks CognitoCallbackURL=https://dev.d2x151q2vf1tfl.amplifyapp.com/auth/callback UserPoolAdminGroupName=apiAdmins FrontendLoginUrl=https://dev.d2x151q2vf1tfl.amplifyapp.com/ AdminEmail=${{ secrets.ADMIN_EMAIL_TEST }} AdminUsername=${{ secrets.ADMIN_USERNAME_TEST }} # If there are functions with "Image" PackageType in your template, # uncomment the line below and add "--image-repository ${TESTING_IMAGE_REPOSITORY}" to # testing "sam package" and "sam deploy" commands. @@ -23,7 +24,7 @@ env: PROD_PIPELINE_EXECUTION_ROLE: ${{ secrets.PROD_PIPELINE_EXECUTION_ROLE }} PROD_CLOUDFORMATION_EXECUTION_ROLE: ${{ secrets.PROD_CLOUDFORMATION_EXECUTION_ROLE }} PROD_ARTIFACTS_BUCKET: ${{ secrets.PROD_ARTIFACTS_BUCKET }} - PRODUCTION_PARAMETER_OVERRIDES: TasksTableName=tasks,CognitoCallbackURL=https://django-rest-framework.org/tutorial/,UserPoolAdminGroupName=apiAdmins + PRODUCTION_PARAMETER_OVERRIDES: TasksTableName=prod-tasks CognitoCallbackURL=https://main.d1m5j798vicdd1.amplifyapp.com/auth/callback UserPoolAdminGroupName=apiAdmins FrontendLoginUrl=https://main.d1m5j798vicdd1.amplifyapp.com/ AdminEmail=${{ secrets.ADMIN_EMAIL_PROD }} AdminUsername=${{ secrets.ADMIN_USERNAME_PROD }} # If there are functions with "Image" PackageType in your template, # uncomment the line below and add "--image-repository ${PROD_IMAGE_REPOSITORY}" to # prod "sam package" and "sam deploy" commands. @@ -191,4 +192,4 @@ jobs: --s3-bucket ${PROD_ARTIFACTS_BUCKET} \ --no-fail-on-empty-changeset \ --role-arn ${PROD_CLOUDFORMATION_EXECUTION_ROLE} \ - --parameter-overrides TasksTableName=tasks CognitoCallbackURL=https://django-rest-framework.org/tutorial/ UserPoolAdminGroupName=apiAdmins \ No newline at end of file + --parameter-overrides ${PRODUCTION_PARAMETER_OVERRIDES} \ No newline at end of file diff --git a/README.md b/README.md index 16e1d71..14af859 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,84 @@ -# task-management-system serverless API -The task-management-system project, created with [`aws-serverless-java-container`](https://github.com/aws/serverless-java-container). - -The starter project defines a simple `/ping` resource that can accept `GET` requests with its tests. - -The project folder also includes a `template.yml` file. You can use this [SAM](https://github.com/awslabs/serverless-application-model) file to deploy the project to AWS Lambda and Amazon API Gateway or test in local with the [SAM CLI](https://github.com/awslabs/aws-sam-cli). - -## Pre-requisites -* [AWS CLI](https://aws.amazon.com/cli/) -* [SAM CLI](https://github.com/awslabs/aws-sam-cli) -* [Gradle](https://gradle.org/) or [Maven](https://maven.apache.org/) - -## Building the project -You can use the SAM CLI to quickly build the project -```bash -$ mvn archetype:generate -DartifactId=task-management-system -DarchetypeGroupId=com.amazonaws.serverless.archetypes -DarchetypeArtifactId=aws-serverless-jersey-archetype -DarchetypeVersion=2.1.0 -DgroupId=org.umaxcode -Dversion=1.0-SNAPSHOT -Dinteractive=false -$ cd task-management-system -$ sam build -Building resource 'TaskManagementSystemFunction' -Running JavaGradleWorkflow:GradleBuild -Running JavaGradleWorkflow:CopyArtifacts - -Build Succeeded - -Built Artifacts : .aws-sam/build -Built Template : .aws-sam/build/template.yaml - -Commands you can use next -========================= -[*] Invoke Function: sam local invoke -[*] Deploy: sam deploy --guided -``` - -## Testing locally with the SAM CLI - -From the project root folder - where the `template.yml` file is located - start the API with the SAM CLI. - -```bash -$ sam local start-api - -... -Mounting com.amazonaws.serverless.archetypes.StreamLambdaHandler::handleRequest (java11) at http://127.0.0.1:3000/{proxy+} [OPTIONS GET HEAD POST PUT DELETE PATCH] -... -``` - -Using a new shell, you can send a test ping request to your API: - -```bash -$ curl -s http://127.0.0.1:3000/ping | python -m json.tool - -{ - "pong": "Hello, World!" -} -``` - -## Deploying to AWS -To deploy the application in your AWS account, you can use the SAM CLI's guided deployment process and follow the instructions on the screen - -``` -$ sam deploy --guided -``` - -Once the deployment is completed, the SAM CLI will print out the stack's outputs, including the new application URL. You can use `curl` or a web browser to make a call to the URL - -``` -... -------------------------------------------------------------------------------------------------------------- -OutputKey-Description OutputValue -------------------------------------------------------------------------------------------------------------- -TaskManagementSystemApi - URL for application https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/Prod/pets -------------------------------------------------------------------------------------------------------------- -``` - -Copy the `OutputValue` into a browser or use curl to test your first request: - -```bash -$ curl -s https://xxxxxxx.execute-api.us-west-2.amazonaws.com/Prod/ping | python -m json.tool - -{ - "pong": "Hello, World!" -} -``` +# Task Management System + +## Project Overview + +The **Task Management System** is designed to streamline task allocation, monitoring, and updates for a field team. The system enables an administrator to create and assign tasks, track their progress, and ensure timely completion. Team members can log in to view, update, and complete their tasks, while notifications and deadline tracking ensure efficient workflows. + +## Features + +- **Role-based Access**: Administrators manage all tasks, assignments, and deadlines. Team members only see their assigned tasks. +- **Notifications**: + - Assigned users receive email notifications for task details and deadlines. + - Administrators are notified of task completions or deadline breaches. +- **Task Management**: + - Tasks can be created, updated, and reopened. + - Tasks include attributes such as name, description, status, responsibility, deadlines, and user comments. +- **Scalable and Secure Architecture**: Built using AWS serverless services to ensure high availability and scalability. + +--- + +## Technical Requirements + +### Backend + +- **Deployment**: + - AWS SAM for backend resources. + - Support for test and production environments. +- **Services Used**: + - **Amazon Cognito**: User onboarding and authentication. + - **Amazon DynamoDB**: Data storage for tasks and user information. Also utilized DynamoDB Stream + - **AWS Lambda**: Business logic implementation. + - **Amazon SNS**: Notifications via email for various task-related events. + - **Amazon SQS**: Queueing for notifications and processing tasks. + - **AWS Step Functions**: Orchestrating workflows for task notifications and updates. + - **Amazon Amplify**: Serving the frontend securely and efficiently. + +### Frontend + +- **Deployment**: + - Hosted on **AWS Amplify** with testing and production environment +- **Continuous Integration/Deployment**: + - GitHub integration for automated deployments to Amplify. +- **Code**: + - [Frontend GitHub Repository](https://github.com/UmaxCode/tasks-management-app.git) + +--- + +## Functional Requirements + +1. **Task Creation**: + - Admin creates tasks with attributes: name, description, status (default: open), responsibility, deadline, and comments. +2. **Task Assignment**: + - Admin assigns tasks to users. + - Notifications are sent to assigned users via email using SNS Topic filtering. +3. **Task Status Updates**: + - Team members can update task status (e.g., mark as completed). + - Admin is notified of status changes. + - Team members can add comment +4. **Task Deadline Notifications**: + - 1 hour before a deadline, users are notified via SNS Topic filtering. +5. **Task Reopening**: + - Only admins can reopen closed tasks, triggering notifications to the assigned user. +6. **Task Deadline Breaches**: + - When a task deadline is missed: + - Task status is updated to "expired." + - Notifications are sent to the user and admin. +7. **Reassignment**: + - If a task's assigned user is changed: + - The task is removed from the old user's list. + - The new user is notified. + +--- + +## Architectural Diagram +[Link to architectural diagram](https://drive.google.com/file/d/1hnCXGY69D11RBGM_5vxP1taGss-_BIyE/view?usp=sharing) + +--- + +## Deployment Guidelines + +### Manual Deployment +Follow these steps to deploy the project manually. + +### Automated Deployment - GITHUB ACTIONS +Follow these steps to deploy the project manually. \ No newline at end of file diff --git a/pom.xml b/pom.xml index a99cd35..37d7eb6 100644 --- a/pom.xml +++ b/pom.xml @@ -102,11 +102,20 @@ sqs 2.20.31 + + org.apache.httpcomponents.client5 + httpclient5 + 5.4.1 + org.junit.jupiter junit-jupiter test + + org.springframework.boot + spring-boot-starter-validation + diff --git a/src/main/java/org/umaxcode/SNSTopicSubscriptionHandler.java b/src/main/java/org/umaxcode/SNSTopicSubscriptionHandler.java index f3ba4e6..4b45775 100644 --- a/src/main/java/org/umaxcode/SNSTopicSubscriptionHandler.java +++ b/src/main/java/org/umaxcode/SNSTopicSubscriptionHandler.java @@ -2,14 +2,12 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; +import org.umaxcode.service.impl.SNSServiceImpl; import software.amazon.awssdk.services.sns.SnsClient; -import software.amazon.awssdk.services.sns.model.SetSubscriptionAttributesRequest; -import software.amazon.awssdk.services.sns.model.SubscribeRequest; -import software.amazon.awssdk.services.sns.model.SubscribeResponse; import java.util.Map; -public class SNSTopicSubscriptionHandler implements RequestHandler, String> { +public class SNSTopicSubscriptionHandler implements RequestHandler, Void> { private final SnsClient snsClient; @@ -18,7 +16,7 @@ public SNSTopicSubscriptionHandler() { } @Override - public String handleRequest(Map event, Context context) { + public Void handleRequest(Map event, Context context) { System.out.println("Event" + event); // Extract the user's email from the event @@ -27,39 +25,8 @@ public String handleRequest(Map event, Context context) { // SNS Topic ARN (can be passed in environment variables or hardcoded) String snsTopicArn = event.get("topicArn"); - // Create SNS subscription request - SubscribeRequest subscribeRequest = SubscribeRequest.builder() - .protocol("email") // Protocol is email to receive notifications - .endpoint(userEmail) // User's email address to subscribe - .returnSubscriptionArn(true) - .topicArn(snsTopicArn) // SNS Topic ARN - .build(); + SNSServiceImpl.subscribeToTopic(snsClient, context, snsTopicArn, userEmail); - try { - SubscribeResponse response = snsClient.subscribe(subscribeRequest); - context.getLogger().log("Subscription result: " + response); - - // Define a filter policy - String filterPolicy = String.format("{ \"endpointEmail\": [\"%s\"] }", userEmail); - - // Set the filter policy for the subscription - String subscriptionArn = response.subscriptionArn(); - - System.out.println("SubscriptionArn" + subscriptionArn); - SetSubscriptionAttributesRequest filterPolicyRequest = SetSubscriptionAttributesRequest.builder() - .subscriptionArn(subscriptionArn) - .attributeName("FilterPolicy") - .attributeValue(filterPolicy) - .build(); - - snsClient.setSubscriptionAttributes(filterPolicyRequest); - - context.getLogger().log("Filter policy set for subscription: " + subscriptionArn); - - return "Successfully subscribed " + userEmail + " to the SNS topic with filter policy: " + snsTopicArn; - } catch (Exception e) { - context.getLogger().log("Error subscribing user: " + e.getMessage()); - return "Failed to subscribe user to SNS topic."; - } + return null; } } diff --git a/src/main/java/org/umaxcode/UpdateInviteMessageAdminCreationLambdaHandler.java b/src/main/java/org/umaxcode/UpdateInviteMessageAdminCreationLambdaHandler.java new file mode 100644 index 0000000..c020132 --- /dev/null +++ b/src/main/java/org/umaxcode/UpdateInviteMessageAdminCreationLambdaHandler.java @@ -0,0 +1,191 @@ +package org.umaxcode; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.LambdaLogger; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.hc.client5.http.classic.methods.HttpPut; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.umaxcode.domain.enums.Role; +import org.umaxcode.utils.PasswordGenerator; +import software.amazon.awssdk.services.cognitoidentityprovider.CognitoIdentityProviderClient; +import software.amazon.awssdk.services.cognitoidentityprovider.model.*; +import software.amazon.awssdk.services.sns.SnsClient; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.umaxcode.service.impl.SNSServiceImpl.subscribeToTopic; + +public class UpdateInviteMessageAdminCreationLambdaHandler implements RequestHandler { + + private final CognitoIdentityProviderClient cognitoIdentityProviderClient; + private final SnsClient snsClient; + private final ExecutorService executor; + + public UpdateInviteMessageAdminCreationLambdaHandler() { + this.cognitoIdentityProviderClient = CognitoIdentityProviderClient.create(); + this.snsClient = SnsClient.create(); + this.executor = Executors.newFixedThreadPool(2); + } + + @Override + public Void handleRequest(CloudFormationCustomResourceEvent event, Context context) { + String status = "SUCCESS"; + Map properties = event.getResourceProperties(); + Map responseData = new HashMap<>(); + + // Extract properties from the event + String requestType = event.getRequestType(); + String responseUrl = event.getResponseUrl(); + String userPoolId = properties.get("UserPoolId").toString(); + String adminEmail = properties.get("AdminEmail").toString(); + String adminUsername = properties.get("AdminUsername").toString(); + String loginUrl = properties.get("FrontendLoginUrl").toString(); + String taskCompleteTopicArn = properties.get("TaskCompleteTopicArn").toString(); + String closedTaskTopicArn = properties.get("ClosedTaskTopicArn").toString(); + + try { + + if ("Delete".equalsIgnoreCase(requestType)) { + sendResponse(responseUrl, event, context, status, null); + return null; + } + + updateInviteMessageTemplate(loginUrl, userPoolId, responseData, context); + + createAdminUser(adminEmail, adminUsername, userPoolId, responseData, context); + + CompletableFuture taskCompletionTopicSubscription = CompletableFuture + .runAsync(() -> subscribeToTopic(snsClient, context, taskCompleteTopicArn, adminEmail), executor); + + CompletableFuture taskClosedTopicSubscription = CompletableFuture + .runAsync(() -> subscribeToTopic(snsClient, context, closedTaskTopicArn, adminEmail), executor); + + CompletableFuture topicSubscriptionTasks = CompletableFuture.allOf(taskCompletionTopicSubscription, + taskClosedTopicSubscription); + + topicSubscriptionTasks.join(); + + // Send success response to CloudFormation + sendResponse(responseUrl, event, context, status, responseData); + + } catch (Exception e) { + context.getLogger().log("Error: " + e.getMessage()); + responseData.put("Error", e.getMessage()); + } + + return null; + } + + private void updateInviteMessageTemplate(String loginUrl, String userPoolId, Map responseData, Context context) { + + MessageTemplateType inviteMessageTemplate = MessageTemplateType.builder() + .emailMessage(String.format(""" + + +

Hello Sir/Madam

+

Welcome to our Task Management System!

+

Your username is: {username} and your temporary password is: {####}

+

Click here to sign in.

+ + + """, loginUrl)) + .emailSubject("Task Management System") + .build(); + + cognitoIdentityProviderClient.updateUserPool(UpdateUserPoolRequest.builder() + .userPoolId(userPoolId) + .adminCreateUserConfig(AdminCreateUserConfigType.builder() + .inviteMessageTemplate(inviteMessageTemplate) + .build()) + .build()); + + context.getLogger().log("UserPool updated successfully"); + responseData.put("Message", "UserPool updated successfully"); + } + + + private void createAdminUser(String adminEmail, String adminUsername, String userPoolId, Map responseData, Context context) { + + if (adminEmail != null && !adminEmail.trim().equals("None") && !adminEmail.isEmpty()) { + try { + cognitoIdentityProviderClient.adminGetUser(AdminGetUserRequest.builder() + .userPoolId(userPoolId) + .username(adminEmail) + .build()); + + context.getLogger().log("User already exists: " + adminEmail); + responseData.put("Message", "User already exists: " + adminEmail); + } catch (UserNotFoundException e) { + List userAttributes = new ArrayList<>(); + userAttributes.add(AttributeType.builder().name("email").value(adminEmail).build()); + userAttributes.add(AttributeType.builder().name("name").value(adminUsername).build()); + userAttributes.add(AttributeType.builder().name("custom:role").value(Role.ADMIN.toString()).build()); + userAttributes.add(AttributeType.builder().name("email_verified").value("true").build()); + + AdminCreateUserRequest createUserRequest = AdminCreateUserRequest.builder() + .userPoolId(userPoolId) + .username(adminEmail) + .userAttributes(userAttributes) + .temporaryPassword(PasswordGenerator.generatePassword()) + .desiredDeliveryMediums(DeliveryMediumType.EMAIL) + .build(); + + cognitoIdentityProviderClient.adminCreateUser(createUserRequest); + + } + } + + + } + + private void configureNotificationTopicsSubscription(String snsTopic, String adminEmail, Context context) { + subscribeToTopic(snsClient, context, snsTopic, adminEmail); + } + + private void sendResponse(String url, CloudFormationCustomResourceEvent event, Context context, String status, Map data) { + LambdaLogger logger = context.getLogger(); + ObjectMapper objectMapper = new ObjectMapper(); + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + + Map responseBody = Map.of( + "Status", status, + "Reason", "See the details in CloudWatch Log Stream: " + context.getLogStreamName(), + "PhysicalResourceId", event.getPhysicalResourceId() != null ? event.getPhysicalResourceId() : context.getLogStreamName(), + "StackId", event.getStackId(), + "RequestId", event.getRequestId(), + "LogicalResourceId", event.getLogicalResourceId(), + "Data", data + ); + + try { + StringEntity entity = new StringEntity(objectMapper.writeValueAsString(responseBody)); + HttpPut request = new HttpPut(url); + request.setEntity(entity); + request.setHeader("Content-Type", "application/json"); + + httpClient.execute(request, response -> { + EntityUtils.consume(response.getEntity()); + logger.log("Response sent to CloudFormation successfully."); + return null; + }); + logger.log("Response sent to CloudFormation successfully."); + } catch (IOException e) { + logger.log("Failed to send response to CloudFormation: " + e.getMessage()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/org/umaxcode/config/SecurityConfig.java b/src/main/java/org/umaxcode/config/SecurityConfig.java index eb13184..9b34ce9 100644 --- a/src/main/java/org/umaxcode/config/SecurityConfig.java +++ b/src/main/java/org/umaxcode/config/SecurityConfig.java @@ -29,7 +29,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, DelegatedEntry .cors(Customizer.withDefaults()) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth - .requestMatchers("/ping").permitAll() + .requestMatchers("/ping", "/pong").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt @@ -48,7 +48,7 @@ public CorsConfigurationSource corsConfigurationSource() { cors.addAllowedHeader("*"); cors.addAllowedMethod("*"); cors.setAllowCredentials(true); - cors.setAllowedOrigins(List.of("http://localhost:3000")); + cors.setAllowedOrigins(List.of("http://localhost:3000", "https://dev.d2x151q2vf1tfl.amplifyapp.com", "https://main.d1m5j798vicdd1.amplifyapp.com")); source.registerCorsConfiguration("/**", cors); return source; diff --git a/src/main/java/org/umaxcode/controller/TaskManagementController.java b/src/main/java/org/umaxcode/controller/TaskManagementController.java index 4bbb6e7..7f2803a 100644 --- a/src/main/java/org/umaxcode/controller/TaskManagementController.java +++ b/src/main/java/org/umaxcode/controller/TaskManagementController.java @@ -1,5 +1,6 @@ package org.umaxcode.controller; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; @@ -23,12 +24,13 @@ public class TaskManagementController { @PostMapping @PreAuthorize(value = "hasRole('ADMIN')") @ResponseStatus(HttpStatus.CREATED) - public SuccessResponse createTask(@RequestBody TasksCreationDto request, @AuthenticationPrincipal Jwt jwt) { + public SuccessResponse createTask(@Valid @RequestBody TasksCreationDto request, @AuthenticationPrincipal Jwt jwt) { String adminEmail = jwt.getClaimAsString("email"); - taskManagementService.createItem(request, adminEmail); + TaskDto createdTask = taskManagementService.createAndAssignTask(request, adminEmail); return SuccessResponse.builder() .message("Task created successfully") + .data(createdTask) .build(); } @@ -37,7 +39,7 @@ public SuccessResponse createTask(@RequestBody TasksCreationDto request, @Authen @ResponseStatus(HttpStatus.OK) public SuccessResponse retrieveTask(@PathVariable("id") String taskId) { - TaskDto taskDto = taskManagementService.readItem(taskId); + TaskDto taskDto = taskManagementService.fetchTask(taskId); return SuccessResponse.builder() .message("Task retrieved successfully") .data(taskDto) @@ -45,7 +47,7 @@ public SuccessResponse retrieveTask(@PathVariable("id") String taskId) { } @GetMapping("/users/{email}") - @PreAuthorize(value= "hasAnyRole('ADMIN', 'USER')") + @PreAuthorize(value = "hasAnyRole('ADMIN', 'USER')") @ResponseStatus(HttpStatus.OK) public SuccessResponse retrieveUserTasks(@PathVariable String email) { @@ -71,7 +73,7 @@ public SuccessResponse makeTaskAsCompleted(@PathVariable("id") String id, @Authe @PatchMapping("/{id}/reopen") @PreAuthorize(value = "hasRole('ADMIN')") @ResponseStatus(HttpStatus.OK) - public SuccessResponse reopenTask(@PathVariable("id") String id, @RequestBody TaskReopenDto request) { + public SuccessResponse reopenTask(@PathVariable("id") String id, @Valid @RequestBody TaskReopenDto request) { TaskDto updatedTask = taskManagementService.reopenTask(id, request); return SuccessResponse.builder() @@ -84,7 +86,7 @@ public SuccessResponse reopenTask(@PathVariable("id") String id, @RequestBody Ta @PreAuthorize(value = "hasRole('USER')") @ResponseStatus(HttpStatus.OK) public SuccessResponse updateTaskComment(@PathVariable("id") String id, - @RequestBody TaskCommentUpdateDto request + @Valid @RequestBody TaskCommentUpdateDto request ) { TaskDto updatedTask = taskManagementService.updateTaskComment(id, request); @@ -97,12 +99,12 @@ public SuccessResponse updateTaskComment(@PathVariable("id") String id, @PatchMapping("/{id}/reassign") @PreAuthorize(value = "hasRole('ADMIN')") @ResponseStatus(HttpStatus.OK) - public SuccessResponse reAssignTask(@PathVariable("id") String id, @RequestBody ReassignTaskDto request + public SuccessResponse reAssignTask(@PathVariable("id") String id, @Valid @RequestBody ReassignTaskDto request ) { TaskDto updatedTask = taskManagementService.reAssignTask(id, request); return SuccessResponse.builder() - .message("Task comment updated successfully") + .message("Task reassigned successfully") .data(updatedTask) .build(); } @@ -110,12 +112,12 @@ public SuccessResponse reAssignTask(@PathVariable("id") String id, @RequestBody @PatchMapping("/{id}") @PreAuthorize(value = "hasRole('ADMIN')") @ResponseStatus(HttpStatus.OK) - public SuccessResponse updateTaskDetails(@PathVariable("id") String id, @RequestBody TaskDetailsUpdateDto request + public SuccessResponse updateTaskDetails(@PathVariable("id") String id, @Valid @RequestBody TaskDetailsUpdateDto request ) { TaskDto updatedTask = taskManagementService.updateTaskDetails(id, request); return SuccessResponse.builder() - .message("Task comment updated successfully") + .message("Task details updated successfully") .data(updatedTask) .build(); } diff --git a/src/main/java/org/umaxcode/controller/UserAuthController.java b/src/main/java/org/umaxcode/controller/UserAuthController.java index 68bca81..0b3543d 100644 --- a/src/main/java/org/umaxcode/controller/UserAuthController.java +++ b/src/main/java/org/umaxcode/controller/UserAuthController.java @@ -1,5 +1,6 @@ package org.umaxcode.controller; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; @@ -21,11 +22,12 @@ public class UserAuthController { @PostMapping("/signup") @PreAuthorize(value = "hasRole('ADMIN')") @ResponseStatus(HttpStatus.CREATED) - public SuccessResponse signup(@RequestBody UserCreationDto request) { + public SuccessResponse signup(@Valid @RequestBody UserCreationDto request) { - String message = userAuthService.register(request); + UserDto registeredUser = userAuthService.register(request); return SuccessResponse.builder() - .message(message) + .message("User created successfully") + .data(registeredUser) .build(); } diff --git a/src/main/java/org/umaxcode/domain/dto/request/ReassignTaskDto.java b/src/main/java/org/umaxcode/domain/dto/request/ReassignTaskDto.java index 2cdb20e..137fb37 100644 --- a/src/main/java/org/umaxcode/domain/dto/request/ReassignTaskDto.java +++ b/src/main/java/org/umaxcode/domain/dto/request/ReassignTaskDto.java @@ -1,6 +1,12 @@ package org.umaxcode.domain.dto.request; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + public record ReassignTaskDto( + + @NotBlank(message = "User email is required") + @Email(message = "Invalid email") String userEmail ) { } diff --git a/src/main/java/org/umaxcode/domain/dto/request/TaskCommentUpdateDto.java b/src/main/java/org/umaxcode/domain/dto/request/TaskCommentUpdateDto.java index 9415edd..d39e63e 100644 --- a/src/main/java/org/umaxcode/domain/dto/request/TaskCommentUpdateDto.java +++ b/src/main/java/org/umaxcode/domain/dto/request/TaskCommentUpdateDto.java @@ -1,6 +1,10 @@ package org.umaxcode.domain.dto.request; +import jakarta.validation.constraints.NotBlank; + public record TaskCommentUpdateDto( + + @NotBlank(message = "Comment is required") String comment ) { } diff --git a/src/main/java/org/umaxcode/domain/dto/request/TaskDetailsUpdateDto.java b/src/main/java/org/umaxcode/domain/dto/request/TaskDetailsUpdateDto.java index 172efa7..b1e276a 100644 --- a/src/main/java/org/umaxcode/domain/dto/request/TaskDetailsUpdateDto.java +++ b/src/main/java/org/umaxcode/domain/dto/request/TaskDetailsUpdateDto.java @@ -1,7 +1,13 @@ package org.umaxcode.domain.dto.request; +import jakarta.validation.constraints.NotBlank; + public record TaskDetailsUpdateDto( + + @NotBlank(message = "Name is required") String name, + + @NotBlank(message = "Description is required") String description ) { } diff --git a/src/main/java/org/umaxcode/domain/dto/request/TaskReopenDto.java b/src/main/java/org/umaxcode/domain/dto/request/TaskReopenDto.java index 5d18973..c741779 100644 --- a/src/main/java/org/umaxcode/domain/dto/request/TaskReopenDto.java +++ b/src/main/java/org/umaxcode/domain/dto/request/TaskReopenDto.java @@ -1,8 +1,14 @@ package org.umaxcode.domain.dto.request; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotBlank; + import java.time.LocalDateTime; public record TaskReopenDto( + + @NotBlank(message = "Deadline is required") + @Future(message = "Deadline must be in the future") LocalDateTime deadline ) { } diff --git a/src/main/java/org/umaxcode/domain/dto/request/TasksCreationDto.java b/src/main/java/org/umaxcode/domain/dto/request/TasksCreationDto.java index bd25c74..854b3d9 100644 --- a/src/main/java/org/umaxcode/domain/dto/request/TasksCreationDto.java +++ b/src/main/java/org/umaxcode/domain/dto/request/TasksCreationDto.java @@ -1,11 +1,25 @@ package org.umaxcode.domain.dto.request; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotBlank; + import java.time.LocalDateTime; public record TasksCreationDto( + + @NotBlank(message = "Name is required") String name, + + @NotBlank(message = "Description is required") String description, + + @NotBlank(message = "Deadline is required") + @Future(message = "Deadline must be in the future") LocalDateTime deadline, + + @NotBlank(message = "Deadline is required") + @Email(message = "Invalid email") String responsibility ) { } diff --git a/src/main/java/org/umaxcode/domain/dto/request/UserCreationDto.java b/src/main/java/org/umaxcode/domain/dto/request/UserCreationDto.java index 0745f07..912fd14 100644 --- a/src/main/java/org/umaxcode/domain/dto/request/UserCreationDto.java +++ b/src/main/java/org/umaxcode/domain/dto/request/UserCreationDto.java @@ -1,7 +1,15 @@ package org.umaxcode.domain.dto.request; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + public record UserCreationDto( + + @NotBlank(message = "Username is required") String username, + + @NotBlank(message = "Email is required") + @Email(message = "Invalid email") String email ) { } diff --git a/src/main/java/org/umaxcode/domain/dto/response/UserDto.java b/src/main/java/org/umaxcode/domain/dto/response/UserDto.java index 2e42648..56e655a 100644 --- a/src/main/java/org/umaxcode/domain/dto/response/UserDto.java +++ b/src/main/java/org/umaxcode/domain/dto/response/UserDto.java @@ -5,6 +5,8 @@ @Builder public record UserDto( String userId, - String email + String username, + String email, + String role ) { } diff --git a/src/main/java/org/umaxcode/exception/ErrorMessage.java b/src/main/java/org/umaxcode/exception/ErrorMessage.java index 3e77f14..0596ede 100644 --- a/src/main/java/org/umaxcode/exception/ErrorMessage.java +++ b/src/main/java/org/umaxcode/exception/ErrorMessage.java @@ -10,6 +10,6 @@ public class ErrorMessage { private String path; - private String message; + private Object message; private String timestamp; } diff --git a/src/main/java/org/umaxcode/exception/GlobalExceptionHandler.java b/src/main/java/org/umaxcode/exception/GlobalExceptionHandler.java index e744276..5e86161 100644 --- a/src/main/java/org/umaxcode/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/umaxcode/exception/GlobalExceptionHandler.java @@ -2,11 +2,17 @@ import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { @@ -22,6 +28,45 @@ public ErrorMessage taskManagementExceptionHandler(TaskManagementException ex, H .build(); } + @ExceptionHandler(AuthenticationException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public ErrorMessage authenticationExceptionHandler(AuthenticationException ex, HttpServletRequest request) { + + return ErrorMessage.builder() + .path(request.getRequestURI()) + .message(ex.getMessage()) + .timestamp(LocalDateTime.now().toString()) + .build(); + } + + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ErrorMessage accessDeniedHandler(AccessDeniedException ex, HttpServletRequest request) { + + return ErrorMessage.builder() + .path(request.getRequestURI()) + .message("You do not have permission to access this resource.") + .timestamp(LocalDateTime.now().toString()) + .build(); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorMessage handleArgumentNotValidException(MethodArgumentNotValidException ex, HttpServletRequest request) { + + Map errors = new HashMap<>(); + + for (FieldError error : ex.getBindingResult().getFieldErrors()) { + errors.put(error.getField(), error.getDefaultMessage()); + } + + return ErrorMessage.builder() + .path(request.getRequestURI()) + .message(errors) + .timestamp(LocalDateTime.now().toString()) + .build(); + } + @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ErrorMessage exceptionHandler(Exception ex, HttpServletRequest request) { diff --git a/src/main/java/org/umaxcode/mapper/UserMapper.java b/src/main/java/org/umaxcode/mapper/UserMapper.java index 6532110..7733d77 100644 --- a/src/main/java/org/umaxcode/mapper/UserMapper.java +++ b/src/main/java/org/umaxcode/mapper/UserMapper.java @@ -19,8 +19,15 @@ public static List toUserDto(List users) { .email(u.attributes().stream() .filter(a -> "email".equalsIgnoreCase(a.name())) .map(AttributeType::value) - .findFirst().orElse(null) - ) + .findFirst().orElse(null)) + .username(u.attributes().stream() + .filter(a -> "name".equalsIgnoreCase(a.name())) + .map(AttributeType::value) + .findFirst().orElse(null)) + .role(u.attributes().stream() + .filter(a -> "custom:role".equalsIgnoreCase(a.name())) + .map(AttributeType::value) + .findFirst().orElse(null)) .build() ).toList(); } diff --git a/src/main/java/org/umaxcode/service/SNSService.java b/src/main/java/org/umaxcode/service/SNSService.java new file mode 100644 index 0000000..153ca8b --- /dev/null +++ b/src/main/java/org/umaxcode/service/SNSService.java @@ -0,0 +1,5 @@ +package org.umaxcode.service; + +public interface SNSService { + +} diff --git a/src/main/java/org/umaxcode/service/TaskManagementService.java b/src/main/java/org/umaxcode/service/TaskManagementService.java index e300627..bb2cada 100644 --- a/src/main/java/org/umaxcode/service/TaskManagementService.java +++ b/src/main/java/org/umaxcode/service/TaskManagementService.java @@ -8,9 +8,9 @@ public interface TaskManagementService { - void createItem(TasksCreationDto item, String email); + TaskDto createAndAssignTask(TasksCreationDto item, String email); - TaskDto readItem(String id); + TaskDto fetchTask(String id); List getAllTasks(); diff --git a/src/main/java/org/umaxcode/service/UserAuthService.java b/src/main/java/org/umaxcode/service/UserAuthService.java index 506761d..9612dd1 100644 --- a/src/main/java/org/umaxcode/service/UserAuthService.java +++ b/src/main/java/org/umaxcode/service/UserAuthService.java @@ -7,7 +7,7 @@ public interface UserAuthService { - String register(UserCreationDto request); + UserDto register(UserCreationDto request); List fetchAllUsers(); } diff --git a/src/main/java/org/umaxcode/service/impl/SNSServiceImpl.java b/src/main/java/org/umaxcode/service/impl/SNSServiceImpl.java new file mode 100644 index 0000000..69f6c5e --- /dev/null +++ b/src/main/java/org/umaxcode/service/impl/SNSServiceImpl.java @@ -0,0 +1,52 @@ +package org.umaxcode.service.impl; + +import com.amazonaws.services.lambda.runtime.Context; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.umaxcode.service.SNSService; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.SetSubscriptionAttributesRequest; +import software.amazon.awssdk.services.sns.model.SubscribeRequest; +import software.amazon.awssdk.services.sns.model.SubscribeResponse; + +@Service +@RequiredArgsConstructor +public class SNSServiceImpl implements SNSService { + + public static void subscribeToTopic(SnsClient snsClient, Context context, String topic, String email) { + + // Create SNS subscription request + SubscribeRequest subscribeRequest = SubscribeRequest.builder() + .protocol("email") // Protocol is email to receive notifications + .endpoint(email) // User's email address to subscribe + .returnSubscriptionArn(true) + .topicArn(topic) // SNS Topic ARN + .build(); + + try { + SubscribeResponse response = snsClient.subscribe(subscribeRequest); + context.getLogger().log("Subscription result: " + response); + + // Define a filter policy + String filterPolicy = String.format("{ \"endpointEmail\": [\"%s\"] }", email); + + // Set the filter policy for the subscription + String subscriptionArn = response.subscriptionArn(); + + System.out.println("SubscriptionArn" + subscriptionArn); + SetSubscriptionAttributesRequest filterPolicyRequest = SetSubscriptionAttributesRequest.builder() + .subscriptionArn(subscriptionArn) + .attributeName("FilterPolicy") + .attributeValue(filterPolicy) + .build(); + + snsClient.setSubscriptionAttributes(filterPolicyRequest); + + context.getLogger().log("Filter policy set for subscription: " + subscriptionArn); + context.getLogger().log("Successfully subscribed " + email + " to the SNS topic with filter policy: " + topic); + + } catch (Exception e) { + context.getLogger().log("Error subscribing user: " + e.getMessage()); + } + } +} diff --git a/src/main/java/org/umaxcode/service/impl/TaskManagementServiceImpl.java b/src/main/java/org/umaxcode/service/impl/TaskManagementServiceImpl.java index be4c2a9..a3f72aa 100644 --- a/src/main/java/org/umaxcode/service/impl/TaskManagementServiceImpl.java +++ b/src/main/java/org/umaxcode/service/impl/TaskManagementServiceImpl.java @@ -46,7 +46,7 @@ public TaskManagementServiceImpl(DynamoDbClient dynamoDbClient, CognitoIdentityP } @Override - public void createItem(TasksCreationDto request, String email) { + public TaskDto createAndAssignTask(TasksCreationDto request, String email) { Map item = new HashMap<>(); item.put("taskId", AttributeValue.builder().s(UUID.randomUUID().toString()).build()); @@ -62,14 +62,23 @@ public void createItem(TasksCreationDto request, String email) { PutItemRequest putRequest = PutItemRequest.builder() .tableName(tasksTableName) .item(item) + .returnValues("ALL_OLD") .build(); - PutItemResponse putItemResponse = dynamoDbClient.putItem(putRequest); - System.out.println("results " + putItemResponse.attributes()); + dynamoDbClient.putItem(putRequest); + return TaskDto.builder() + .id(item.get("taskId").s()) + .name(request.name()) + .description(request.description()) + .status(TaskStatus.OPEN) + .responsibility(request.responsibility()) + .deadline(request.deadline().toString()) + .assignedBy(email) + .build(); } @Override - public TaskDto readItem(String id) { + public TaskDto fetchTask(String id) { Map key = new HashMap<>(); key.put("taskId", AttributeValue.builder().s(id).build()); @@ -219,12 +228,13 @@ public TaskDto reopenTask(String id, TaskReopenDto request) { UpdateItemRequest updateItemRequest = UpdateItemRequest.builder() .tableName(tasksTableName) .key(key) - .updateExpression("SET #status = :status, deadline = :deadline") + .updateExpression("SET #status = :status, deadline = :deadline, isNotifiedForApproachDeadline = :false") .conditionExpression("#status = :expired") .expressionAttributeValues(Map.of( ":status", AttributeValue.builder().s("open").build(), ":deadline", AttributeValue.builder().s(request.deadline().toString()).build(), - ":expired", AttributeValue.builder().s("expired").build() + ":expired", AttributeValue.builder().s("expired").build(), + ":false", AttributeValue.builder().n("0").build() )) .expressionAttributeNames(Map.of( "#status", "status" @@ -255,7 +265,7 @@ public TaskDto updateTaskDetails(String id, TaskDetailsUpdateDto request) { .tableName(tasksTableName) .key(key) .updateExpression("SET #name = :name, description = :description") - .conditionExpression("#status = :open") + .conditionExpression("#status = :status") .expressionAttributeValues(Map.of( ":status", AttributeValue.builder().s("open").build(), ":name", AttributeValue.builder().s(request.name()).build(), diff --git a/src/main/java/org/umaxcode/service/impl/UserAuthServiceImpl.java b/src/main/java/org/umaxcode/service/impl/UserAuthServiceImpl.java index 50b2db4..187ad10 100644 --- a/src/main/java/org/umaxcode/service/impl/UserAuthServiceImpl.java +++ b/src/main/java/org/umaxcode/service/impl/UserAuthServiceImpl.java @@ -32,7 +32,7 @@ public class UserAuthServiceImpl implements UserAuthService { private String userPoolId; @Override - public String register(UserCreationDto request) { + public UserDto register(UserCreationDto request) { try { AdminCreateUserRequest adminRequest = AdminCreateUserRequest.builder() @@ -51,7 +51,14 @@ public String register(UserCreationDto request) { AdminCreateUserResponse response = cognitoClient.adminCreateUser(adminRequest); startStateMachineForSNSSub(request.email()); System.out.println("User" + response.user()); - return "User created: " + response.user().username(); + + return UserDto.builder() + .userId(response.user().username()) + .email(request.email()) + .username(request.username()) + .role(Role.USER.toString()) + .build(); + } catch (CognitoIdentityProviderException e) { throw new UserAuthException("Failed to create user: " + e.getMessage()); } diff --git a/template.yaml b/template.yaml index ef92c4d..fa9c4be 100644 --- a/template.yaml +++ b/template.yaml @@ -14,6 +14,15 @@ Parameters: Description: User pool group name for API administrators Type: String Default: apiAdmins + FrontendLoginUrl: + Description: Frontend url for login + Type: String + AdminEmail: + Description: Email address of admin + Type: String + AdminUsername: + Description: Username of admin + Type: String Globals: Api: @@ -34,7 +43,7 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref TasksTable - SQSSendMessagePolicy: - QueueName: tasks-queue + QueueName: !Sub ${AWS::StackName}-tasks-queue - Statement: Effect: Allow Action: @@ -137,7 +146,7 @@ Resources: Type: AWS::Cognito::UserPoolGroup Properties: Description: User group for API Administrators - GroupName: !Ref UserPoolAdminGroupName + GroupName: !Sub ${AWS::StackName}-${UserPoolAdminGroupName} Precedence: 0 UserPoolId: !Ref UserPool @@ -145,31 +154,31 @@ Resources: TasksAssignmentNotificationTopic: Type: AWS::SNS::Topic Properties: - TopicName: tasks-assignment-notifications + TopicName: !Sub ${AWS::StackName}-tasks-assignment-notifications DisplayName: "Task Assignment Notifications" TasksDeadlineNotificationTopic: Type: AWS::SNS::Topic Properties: - TopicName: tasks-deadline-notifications + TopicName: !Sub ${AWS::StackName}-tasks-deadline-notifications DisplayName: "Task Deadline Notifications" ClosedTasksNotificationTopic: Type: AWS::SNS::Topic Properties: - TopicName: closed-tasks-notifications + TopicName: !Sub ${AWS::StackName}-closed-tasks-notifications DisplayName: "Closed Tasks Notifications" ReopenedTasksNotificationTopic: Type: AWS::SNS::Topic Properties: - TopicName: reopened-tasks-notifications + TopicName: !Sub ${AWS::StackName}-reopened-tasks-notifications DisplayName: "Reopened Tasks Notifications" TaskCompleteNotificationTopic: Type: AWS::SNS::Topic Properties: - TopicName: task-complete-notifications + TopicName: !Sub ${AWS::StackName}-task-complete-notifications DisplayName: "task completion Notifications" SNSTopicSubscriptionFunction: @@ -406,7 +415,7 @@ Resources: CodeUri: . Policies: - SQSSendMessagePolicy: # Predefined policy to send messages - QueueName: tasks-queue + QueueName: !Sub ${AWS::StackName}-tasks-queue - DynamoDBStreamReadPolicy: # Predefined policy to read from DynamoDB streams TableName: !Ref TasksTable StreamName: !GetAtt TasksTable.StreamArn @@ -464,7 +473,7 @@ Resources: TasksQueue: Type: AWS::SQS::Queue Properties: - QueueName: tasks-queue + QueueName: !Sub ${AWS::StackName}-tasks-queue TaskQueuePolicy: Type: AWS::SQS::QueuePolicy @@ -492,7 +501,7 @@ Resources: CodeUri: . Policies: - SQSSendMessagePolicy: # Predefined policy to send messages - QueueName: tasks-queue + QueueName: !Sub ${AWS::StackName}-tasks-queue - DynamoDBCrudPolicy: TableName: !Ref TasksTable Environment: @@ -541,6 +550,41 @@ Resources: Variables: TASKS_CLOSED_NOTIFICATION_TOPIC_ARN: !Ref ClosedTasksNotificationTopic + UpdateInviteMessageAdminCreationLambdaHandler: + Type: AWS::Serverless::Function + Properties: + Handler: org.umaxcode.UpdateInviteMessageAdminCreationLambdaHandler::handleRequest + CodeUri: . + Policies: + - AWSLambdaBasicExecutionRole + - Statement: + Effect: Allow + Action: + - cognito-idp:UpdateUserPool + - cognito-idp:AdminCreateUser + - cognito-idp:AdminGetUser + Resource: !GetAtt UserPool.Arn + - Statement: + Effect: Allow + Action: + - sns:Subscribe + - sns:SetSubscriptionAttributes + Resource: + - !Ref ClosedTasksNotificationTopic + - !Ref TaskCompleteNotificationTopic + + UpdateInviteMessageTemplateAndCreateAdminUserCustomResource: + Type: Custom::UpdateInviteMessageTemplate + Properties: + ServiceToken: !GetAtt UpdateInviteMessageAdminCreationLambdaHandler.Arn + ServiceTimeout: 30 + UserPoolId: !Ref UserPool + FrontendLoginUrl: !Ref FrontendLoginUrl + AdminEmail: !Ref AdminEmail + AdminUsername: !Ref AdminUsername + TaskCompleteTopicArn: !Ref TaskCompleteNotificationTopic + ClosedTaskTopicArn: !Ref ClosedTasksNotificationTopic + Outputs: TaskManagementSystemApi: Description: URL for application