diff --git a/lambda-durable-functions-human-in-the-loop/README.md b/lambda-durable-functions-human-in-the-loop/README.md new file mode 100644 index 000000000..762d6b48a --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/README.md @@ -0,0 +1,131 @@ +# Human in the Loop with Lambda durable functions + +This pattern demonstrates how to integrate human review or approval processes into workflows using AWS Lambda durable functions. The workflow sends email notifications via Simple Notification Service (SNS) and waits for human approval through callback links, suspending execution until the decision is made. + +**Important**: This application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Requirements + +- Create an AWS account if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +- [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- [Node.js and npm](https://nodejs.org/en/download/) installed +- [AWS CDK](https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html) installed + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + ```bash + git clone https://github.com/aws-samples/serverless-patterns + ``` + +2. Change directory to the pattern directory: + ```bash + cd lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk + ``` + +3. Install dependencies: + ```bash + npm install + ``` + +4. Bootstrap your AWS environment (if you don't have a CDK environment setup): + ```bash + cdk bootstrap + ``` + +5. Deploy the CDK stack with your email address: + ```bash + cdk deploy --context Email=your-email@example.com + ``` + Replace `your-email@example.com` with the email address that should receive approval notifications. + +6. **Confirm your SNS subscription**: After deployment, check your email inbox for a message with the subject **"AWS Notification - Subscription Confirmation"**. The email will look like this: + + ``` + You have chosen to subscribe to the topic: + arn:aws:sns:::hitl-approval-notifications + + To confirm this subscription, click or visit the link below + (If this was in error no action is necessary): + Confirm subscription + ``` + + **Click the "Confirm subscription" link.** This step is required before you can receive approval emails. + + > **Note**: Check your spam/junk folder if you don't see the email within a few minutes. + +7. Note the outputs from the CDK deployment process. These contain the resource names and/or ARNs which are used for testing: + - `Hitl-ApiUrl`: The API Gateway URL for callbacks + - `Hitl-Sns-TopicArn`: The SNS Topic ARN for approval notifications + - `Hitl-Durable-Function-Name`: The name of the durable function + +## How it works + +![Architecture](./images/human-in-the-loop-architecture.svg) + +1. **Document Submission**: An employee submits a document (e.g., expense report) that requires reviewer approval +2. **Validation**: The durable function validates the submission and extracts key details +3. **Token Storage**: The workflow generates a short approval ID (e.g., `a1b2c3d4`) and stores the callback token securely in DynamoDB +4. **Approval Request**: The workflow sends a formatted email via SNS to the manager with document details and approve/reject links +5. **Workflow Pause**: The workflow pauses using `waitForCallback()` - execution suspends without compute charges +6. **Manager Review**: The reviewer receives the email and reviews the document details +7. **Decision**: The reviewer clicks either the APPROVE or REJECT link +8. **API Gateway**: API Gateway receives the callback request with the approval ID and invokes the callback handler +9. **Token Lookup**: The callback handler looks up the actual callback token from DynamoDB using the approval ID +10. **Resume Execution**: The callback handler resumes the durable execution with the approval result +11. **Process Decision**: The workflow continues and processes the approval or rejection (e.g., initiates payment, notifies submitter) + + +![Durable Function Workflow](images/durable-operation.png) + +### Email Notification + +The email contains two clickable links with short approval IDs: + +![Email Message](images/email-message-example.png) + +### Execution Monitoring + +You can monitor the durable execution in the Lambda console, seeing each step's status. + +## Testing + +1. After deployment, you will receive an email titled "AWS Notification - Subscription Confirmation". Click on the link in the email to confirm your subscription. This will allow SNS to send you emails. + +2. Navigate to the AWS Lambda console and select the `hitl-durable-function` function. + +3. Create an asynchronous test event with the `hitl-lambda-durable-function-cdk/events/events-large-amount.json` payload + +4. Invoke the function. The durable execution will start and send an approval email. + +5. **Test via Email Links** + + Check your email for the approval request. The email will contain two links: + + + - **APPROVE**: Click this to approve the request + - **REJECT**: Click this to reject the request + + Click one of the links to complete the approval process. + +6. Observe the execution in the Lambda console. The durable function will complete with the approval result: + + ![Durable-Functions-Successful-execution](images/durable-function-success.png) + +7. Check the Durable Functions Logs for the durable function to see the workflow execution details. See the following rejection example: +![Rejected-Human-Decicison](images/human-decision-rejected.png) + +## Cleanup + +To delete the resources created by this template, use the following command: + +```bash +cdk destroy -c Email=your-email@example.com +``` + +--- + +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/.gitignore b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/.gitignore new file mode 100644 index 000000000..f60797b6a --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/.gitignore @@ -0,0 +1,8 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/.npmignore b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/.npmignore new file mode 100644 index 000000000..c1d6d45dc --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/README.md b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/README.md new file mode 100644 index 000000000..c96388ed8 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/README.md @@ -0,0 +1,285 @@ +# Human in the Loop - CDK Implementation + +This directory contains the AWS CDK implementation of the Human-in-the-Loop pattern using Lambda durable functions. + +## What's Included + +This CDK stack deploys: + +- **Lambda Durable Function** - Orchestrates the HITL workflow with automatic checkpointing and retry logic +- **Callback Handler Lambda** - Processes approval/rejection callbacks from email links +- **DynamoDB Table** - Stores callback tokens securely with 1-hour TTL (matches callback timeout) +- **SNS Topic** - Sends email notifications with approval links +- **API Gateway** - Provides callback endpoint for email links +- **IAM Roles** - Properly scoped permissions for all resources +- **CloudWatch Logs** - Structured JSON logging for all functions + +## Architecture + +``` +┌─────────────────┐ +│ Durable │ +│ Function │──────┐ +│ (Orchestrator) │ │ +└─────────────────┘ │ + │ │ + ▼ ▼ + ┌─────────────┐ ┌─────────────┐ + │ DynamoDB │ │ SNS Topic │ + │ (Tokens) │ └─────────────┘ + └─────────────┘ │ + ▲ ▼ + │ ┌─────────────┐ + │ │ Email │ + │ │ (short IDs) │ + │ └─────────────┘ + │ │ + │ ▼ + │ ┌─────────────┐ + │ │ API Gateway │ + │ │ /verify │ + │ └─────────────┘ + │ │ + │ ▼ + │ ┌─────────────┐ + └────────│ Callback │ + │ Handler │ + └─────────────┘ +``` + +## Prerequisites + +- [Node.js 20+](https://nodejs.org/) +- [AWS CLI](https://aws.amazon.com/cli/) configured with credentials +- [AWS CDK](https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html) installed globally: `npm install -g aws-cdk` +- An AWS account with appropriate permissions + +## Deployment + +1. Install dependencies: + ```bash + npm install + ``` + +2. Bootstrap CDK (first time only): + ```bash + cdk bootstrap + ``` + +3. Deploy the stack with your email address: + ```bash + cdk deploy --context Email=your-email@example.com + ``` + +4. **Confirm your SNS subscription**: Check your email inbox for a message with the subject **"AWS Notification - Subscription Confirmation"**. The email will contain: + + ``` + You have chosen to subscribe to the topic: + arn:aws:sns:::hitl-approval-notifications + + To confirm this subscription, click or visit the link below + (If this was in error no action is necessary): + Confirm subscription + ``` + + **Click the "Confirm subscription" link.** Without this confirmation, you won't receive approval emails. + + > **Tip**: Check your spam/junk folder if you don't see the email. + +5. Note the stack outputs: + - `Hitl-ApiUrl` - API Gateway endpoint for callbacks + - `Hitl-Sns-TopicArn` - SNS topic ARN + - `Hitl-Durable-Function-Name` - Durable function name + - `Hitl-Callback-Table-Name` - DynamoDB table for callback tokens + +## Testing + +1. **Confirm SNS subscription**: Check your email for "AWS Notification - Subscription Confirmation" and click the confirmation link. + +2. **Invoke the durable function** in the Lambda console with this test event (expense report example): + ```json + { + "submissionId": "DOC-2026-001234", + "submissionType": "expense_report", + "submitter": { + "name": "Alice Johnson", + "email": "alice.johnson@example.com", + "department": "Engineering", + "employeeId": "EMP-5678" + }, + "document": { + "title": "Q1 2026 Conference Travel Expenses", + "amount": 2450.00, + "currency": "USD", + "category": "Travel & Entertainment", + "description": "AWS re:Invent 2026 conference attendance", + "items": [ + { + "description": "Round-trip flight", + "amount": 850.00, + "date": "2026-11-28" + } + ], + "attachments": ["s3://receipts/flight.pdf"] + }, + "submittedAt": "2026-02-13T14:30:00Z" + } + ``` + + See `events/` folder for more examples. + +3. **Check your email** for the approval request with document details and APPROVE/REJECT links. + +4. **Click a link** to complete the workflow, or use the console "Send success" feature to manually complete the callback. + +## Project Structure + +``` +hitl-lambda-durable-function-cdk/ +├── bin/ +│ └── hitl-lambda-durable-function-cdk.ts # CDK app entry point +├── lib/ +│ └── hitl-lambda-durable-function-cdk-stack.ts # Stack definition +├── lambdas/ +│ ├── hitl-durable-functions/ +│ │ ├── index.mjs # Durable function orchestrator +│ │ └── package.json # Dependencies +│ └── callback-handler/ +│ ├── index.mjs # Callback handler +│ └── package.json # Dependencies +├── events/ +│ ├── event.json # Test event +│ ├── callback-approve.json # Approval payload +│ └── callback-reject.json # Rejection payload +├── test/ +│ └── hitl-lambda-durable-function-cdk.test.ts # Unit tests +├── cdk.json # CDK configuration +├── package.json # Project dependencies +└── tsconfig.json # TypeScript configuration +``` + +## Key CDK Constructs + +### Durable Function Configuration + +```typescript +const hitlDurableFunction = new NodejsFunction(this, 'HitlDurableFunction', { + runtime: aws_lambda.Runtime.NODEJS_22_X, + durableConfig: { + executionTimeout: Duration.hours(1), + retentionPeriod: Duration.days(30) + }, + environment: { + SNS_TOPIC_ARN: approvalTopic.topicArn, + API_URL: api.url, + }, +}); +``` + +### SNS Email Subscription + +```typescript +const approvalTopic = new aws_sns.Topic(this, 'ApprovalTopic', { + displayName: 'Human Approval Notifications', +}); + +approvalTopic.addSubscription(new EmailSubscription(email)); +``` + +### API Gateway with Callback Handler + +```typescript +const api = new aws_apigateway.RestApi(this, 'HitlCallbackApi', { + restApiName: 'HITL-Callback-API', +}); + +const verifyResource = api.root.addResource('verify'); +verifyResource.addMethod('GET', new aws_apigateway.LambdaIntegration(callbackHandler)); +``` + +## Environment Variables + +The durable function uses these environment variables: + +- `SNS_TOPIC_ARN` - ARN of the SNS topic for sending emails +- `API_URL` - API Gateway URL for callback links +- `CALLBACK_TABLE_NAME` - DynamoDB table name for storing callback tokens + +The callback handler uses: + +- `CALLBACK_TABLE_NAME` - DynamoDB table name for looking up callback tokens + +## IAM Permissions + +The stack creates these IAM policies: + +- **Durable Function**: SNS publish permissions, DynamoDB write permissions +- **Callback Handler**: Lambda durable execution callback permissions, DynamoDB read permissions + +## Monitoring + +View logs in CloudWatch: + +```bash +# Durable function logs +aws logs tail /aws/lambda/hitl-durable-function --follow + +# Callback handler logs +aws logs tail /aws/lambda/hitl-callback-handler --follow +``` + +Monitor durable executions in the Lambda console under the "Durable executions" tab. + +## Cleanup + +Remove all resources: + +```bash +cdk destroy +``` + +This will delete: +- Lambda functions +- API Gateway +- SNS topic and subscriptions +- IAM roles +- CloudWatch log groups + +## Useful CDK Commands + +- `npm run build` - Compile TypeScript to JavaScript +- `npm run watch` - Watch for changes and compile +- `npm run test` - Run Jest unit tests +- `cdk diff` - Compare deployed stack with current state +- `cdk synth` - Emit synthesized CloudFormation template +- `cdk deploy` - Deploy stack to AWS +- `cdk destroy` - Remove all stack resources + +## Troubleshooting + +**Issue**: Email not received +- Check spam/junk folder +- Verify SNS subscription was confirmed +- Check SNS topic subscriptions in AWS Console + +**Issue**: Callback link doesn't work +- Verify API Gateway URL in environment variables +- Check CloudWatch logs for errors +- Ensure callback handler has correct IAM permissions + +**Issue**: Deployment fails +- Ensure CDK is bootstrapped: `cdk bootstrap` +- Check AWS credentials are configured +- Verify you have necessary IAM permissions + +## Learn More + +- [AWS Lambda Durable Functions](https://aws.amazon.com/lambda/lambda-durable-functions/) +- [AWS CDK Documentation](https://docs.aws.amazon.com/cdk/) +- [Lambda Durable Functions SDK (JavaScript)](https://github.com/aws/durable-execution-sdk-js) + +--- + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/bin/hitl-lambda-durable-function-cdk.ts b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/bin/hitl-lambda-durable-function-cdk.ts new file mode 100644 index 000000000..95a51ea24 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/bin/hitl-lambda-durable-function-cdk.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import * as cdk from 'aws-cdk-lib/core'; +import { HitlLambdaDurableFunctionCdkStack } from '../lib/hitl-lambda-durable-function-cdk-stack'; + +const app = new cdk.App(); +new HitlLambdaDurableFunctionCdkStack(app, 'HitlLambdaDurableFunctionCdkStack', { + description: "This is a human in the loop pattern using AWS Lambda Durable Functions." + /* If you don't specify 'env', this stack will be environment-agnostic. + * Account/Region-dependent features and context lookups will not work, + * but a single synthesized template can be deployed anywhere. */ + + /* Uncomment the next line to specialize this stack for the AWS Account + * and Region that are implied by the current CLI configuration. */ + // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, + + /* Uncomment the next line if you know exactly what Account and Region you + * want to deploy the stack to. */ + // env: { account: '123456789012', region: 'us-east-1' }, + + /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ +}); diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/cdk.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/cdk.json new file mode 100644 index 000000000..6d90fc02d --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/cdk.json @@ -0,0 +1,103 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/hitl-lambda-durable-function-cdk.ts", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true, + "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true, + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, + "@aws-cdk/core:explicitStackTags": true, + "@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false, + "@aws-cdk/aws-ecs:disableEcsImdsBlocking": true, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true, + "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true, + "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true, + "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true, + "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true, + "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true, + "@aws-cdk/core:enableAdditionalMetadataCollection": true, + "@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false, + "@aws-cdk/aws-s3:setUniqueReplicationRoleName": true, + "@aws-cdk/aws-events:requireEventBusPolicySid": true, + "@aws-cdk/core:aspectPrioritiesMutating": true, + "@aws-cdk/aws-dynamodb:retainTableReplica": true, + "@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true, + "@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true, + "@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true, + "@aws-cdk/aws-s3:publicAccessBlockedByDefault": true, + "@aws-cdk/aws-lambda:useCdkManagedLogGroup": true, + "@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": true, + "@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId": true + } +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event-large-amount.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event-large-amount.json new file mode 100644 index 000000000..a65528f03 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event-large-amount.json @@ -0,0 +1,34 @@ +{ + "submissionId": "DOC-2026-001236", + "submissionType": "equipment_purchase", + "submitter": { + "name": "Carol Davis", + "email": "carol.davis@example.com", + "department": "IT", + "employeeId": "EMP-3456" + }, + "document": { + "title": "Development Team Laptop Upgrades", + "amount": 15750.00, + "currency": "USD", + "category": "Equipment & Hardware", + "description": "Purchase of 15 MacBook Pro laptops for engineering team to support new AI/ML development initiatives", + "items": [ + { + "description": "MacBook Pro 16\" M3 Max (Qty: 15)", + "amount": 14250.00, + "date": "2026-02-12" + }, + { + "description": "AppleCare+ Protection (Qty: 15)", + "amount": 1500.00, + "date": "2026-02-12" + } + ], + "attachments": [ + "s3://expense-receipts/2026/carol-davis/apple-quote.pdf", + "s3://expense-receipts/2026/carol-davis/budget-justification.pdf" + ] + }, + "submittedAt": "2026-02-13T09:15:00Z" +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event-simple.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event-simple.json new file mode 100644 index 000000000..bf1865c26 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event-simple.json @@ -0,0 +1,28 @@ +{ + "submissionId": "DOC-2026-001235", + "submissionType": "expense_report", + "submitter": { + "name": "Bob Smith", + "email": "bob.smith@example.com", + "department": "Sales", + "employeeId": "EMP-9012" + }, + "document": { + "title": "Client Lunch Meeting", + "amount": 125.50, + "currency": "USD", + "category": "Meals & Entertainment", + "description": "Lunch meeting with potential client to discuss Q2 partnership opportunities", + "items": [ + { + "description": "Restaurant bill", + "amount": 125.50, + "date": "2026-02-10" + } + ], + "attachments": [ + "s3://expense-receipts/2026/bob-smith/lunch-receipt.pdf" + ] + }, + "submittedAt": "2026-02-13T16:45:00Z" +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event.json new file mode 100644 index 000000000..ea9c8f264 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/events/event.json @@ -0,0 +1,39 @@ +{ + "submissionId": "DOC-2026-001234", + "submissionType": "expense_report", + "submitter": { + "name": "Alice Johnson", + "email": "alice.johnson@example.com", + "department": "Engineering", + "employeeId": "EMP-5678" + }, + "document": { + "title": "Q1 2026 Conference Travel Expenses", + "amount": 2450.00, + "currency": "USD", + "category": "Travel & Entertainment", + "description": "AWS re:Invent 2026 conference attendance including flights, hotel, and meals", + "items": [ + { + "description": "Round-trip flight to Las Vegas", + "amount": 850.00, + "date": "2026-11-28" + }, + { + "description": "Hotel accommodation (4 nights)", + "amount": 1200.00, + "date": "2026-11-28" + }, + { + "description": "Meals and transportation", + "amount": 400.00, + "date": "2026-11-28" + } + ], + "attachments": [ + "s3://expense-receipts/2026/alice-johnson/flight-receipt.pdf", + "s3://expense-receipts/2026/alice-johnson/hotel-invoice.pdf" + ] + }, + "submittedAt": "2026-02-13T14:30:00Z" +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/jest.config.js b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/jest.config.js new file mode 100644 index 000000000..fe2e9f679 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + }, + setupFilesAfterEnv: ['aws-cdk-lib/testhelpers/jest-autoclean'], +}; diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/callback-handler/index.mjs b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/callback-handler/index.mjs new file mode 100644 index 000000000..e2f0fdc38 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/callback-handler/index.mjs @@ -0,0 +1,74 @@ +import { LambdaClient, SendDurableExecutionCallbackSuccessCommand } from "@aws-sdk/client-lambda"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb"; + +const lambdaClient = new LambdaClient({}); +const ddbClient = new DynamoDBClient({}); +const docClient = DynamoDBDocumentClient.from(ddbClient); + +export const handler = async (event) => { + // Extract approval ID and action from query string + const approvalId = event.queryStringParameters?.id; + const action = event.queryStringParameters?.action || 'approve'; + + console.log('Callback received:', { approvalId, action }); + + if (!approvalId) { + return { + statusCode: 400, + headers: { 'Content-Type': 'text/html' }, + body: '

An Error occurred!

Missing approval ID

', + }; + } + + try { + // Look up the callback token from DynamoDB + const result = await docClient.send(new GetCommand({ + TableName: process.env.CALLBACK_TABLE_NAME, + Key: { approvalId: approvalId } + })); + + if (!result.Item) { + return { + statusCode: 404, + headers: { 'Content-Type': 'text/html' }, + body: '

An Error occurred!

Approval ID not found or expired

', + }; + } + + const callbackId = result.Item.callbackId; + + // Send callback success to resume the durable function + const callbackResult = { + approved: action === 'approve', + approvalId: approvalId, + timestamp: new Date().toISOString() + }; + + const command = new SendDurableExecutionCallbackSuccessCommand({ + CallbackId: callbackId, + Result: JSON.stringify(callbackResult) + }); + + await lambdaClient.send(command); + + const message = action === 'approve' + ? '

Request Approved

Thank you for approving this request!

' + : '

Request Rejected

You have rejected this request!

'; + + return { + statusCode: 200, + headers: { + 'Content-Type': 'text/html', + }, + body: `${message}`, + }; + } catch (error) { + console.error('Error resuming workflow:', error); + return { + statusCode: 500, + headers: { 'Content-Type': 'text/html' }, + body: '

An Error occurred

Failed to process approval. The link may have expired.

', + }; + } +}; diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/callback-handler/package.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/callback-handler/package.json new file mode 100644 index 000000000..0bbc6f41b --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/callback-handler/package.json @@ -0,0 +1,10 @@ +{ + "name": "callback-handler", + "version": "1.0.0", + "type": "module", + "dependencies": { + "@aws-sdk/client-lambda": "^3.0.0", + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/lib-dynamodb": "^3.0.0" + } +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/hitl-durable-function/index.mjs b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/hitl-durable-function/index.mjs new file mode 100644 index 000000000..259b9d8dc --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/hitl-durable-function/index.mjs @@ -0,0 +1,223 @@ +import { + withDurableExecution, +} from "@aws/durable-execution-sdk-js"; +import { SNSClient, PublishCommand } from "@aws-sdk/client-sns"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb"; +import { randomUUID } from 'crypto'; + +const snsClient = new SNSClient({}); +const ddbClient = new DynamoDBClient({}); +const docClient = DynamoDBDocumentClient.from(ddbClient); + +const storeCallbackToken = async (callbackId, submission) => { + const approvalId = randomUUID().split('-')[0]; // Short ID like "a1b2c3d4" + // TTL matches callback timeout (1 hour) + 5 minute buffer + const ttl = Math.floor(Date.now() / 1000) + (65 * 60); // 1 hour 5 minutes + + await docClient.send(new PutCommand({ + TableName: process.env.CALLBACK_TABLE_NAME, + Item: { + approvalId: approvalId, + callbackId: callbackId, + submissionId: submission.submissionId, + submitterEmail: submission.submitter.email, + ttl: ttl, + createdAt: new Date().toISOString() + } + })); + + return approvalId; +}; + +const sendApprovalRequest = async (submission, callbackId) => { + try { + // Store callback token in DynamoDB and get short approval ID + const approvalId = await storeCallbackToken(callbackId, submission); + + const baseUrl = `${process.env.API_URL}verify`; + const approveUrl = `${baseUrl}?id=${approvalId}&action=approve`; + const rejectUrl = `${baseUrl}?id=${approvalId}&action=reject`; + + // Format line items for email + const itemsList = submission.document.items + .map(item => ` • ${item.description}: $${item.amount.toFixed(2)}`) + .join('\n'); + + const message = ` + APPROVAL REQUIRED: ${submission.document.title} + + Submission ID: ${submission.submissionId} + Submitted by: ${submission.submitter.name} (${submission.submitter.department}) + Employee ID: ${submission.submitter.employeeId} + + Document Details: + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Category: ${submission.document.category} + Total Amount: $${submission.document.amount.toFixed(2)} ${submission.document.currency} + + Description: + ${submission.document.description} + + Expense Breakdown: + ${itemsList} + + Attachments: ${submission.document.attachments.length} file(s) + ${submission.document.attachments.map(a => ` • ${a.split('/').pop()}`).join('\n')} + + Submitted: ${new Date(submission.submittedAt).toLocaleString()} + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Please review and take action: + + ✓ APPROVE: ${approveUrl} + + ✗ REJECT: ${rejectUrl} + + This link will expire in 1 hour. + + Note: Approval ID for reference: ${approvalId} + `.trim(); + + const params = { + TopicArn: process.env.SNS_TOPIC_ARN, + Subject: `Approval Required: ${submission.document.title} - $${submission.document.amount.toFixed(2)}`, + Message: message + }; + const command = new PublishCommand(params); + const result = await snsClient.send(command); + return { + status: "succeeded", + result: result, + approvalId: approvalId + }; + } catch (error) { + console.log("An error occurred sending approval request: ", error) + return { + status: "failed", + error: error + }; + } +}; + +const handler = async (event, context) => { + try { + // Use the context logger for structured logging + context.logger.info("Starting HITL workflow for document submission", { + submissionId: event.submissionId, + submissionType: event.submissionType + }); + + // Validate required fields + if (!event.submissionId) { + return { error: "Missing submissionId" }; + } + if (!event.submitter || !event.submitter.email || !event.submitter.name) { + return { error: "Missing submitter information" }; + } + if (!event.document) { + return { error: "Missing document information" }; + } + + // Step 1: Validate and load submission data + const submission = await context.step("validate-submission", async () => { + context.logger.info("Validating submission", { + submissionId: event.submissionId, + amount: event.document.amount + }); + + return { + submissionId: event.submissionId, + submissionType: event.submissionType, + submitter: event.submitter, + document: event.document, + submittedAt: event.submittedAt, + status: "pending_approval" + }; + }); + + context.logger.info("Submission validated", { + submissionId: submission.submissionId, + status: submission.status + }); + + // Step 2: Send approval request and wait for human decision + context.logger.info("Sending approval request to reviewer..."); + + const approval_result = await context.waitForCallback( + "wait-for-reviewer-approval", + async (callbackId) => { + await sendApprovalRequest(submission, callbackId); + }, + { + timeout: { hours: 1 } + } + ); + + context.logger.info("Approval decision received", { + approved: approval_result.approved, + approvalId: approval_result.approvalId + }); + + // Step 3: Process the approval decision + const result = await context.step("process-approval-decision", async () => { + if (approval_result.approved === true) { + context.logger.info(`Submission APPROVED`, { + submissionId: submission.submissionId, + submitter: submission.submitter.name + }); + + return { + status: "approved", + submissionId: submission.submissionId, + submitter: submission.submitter, + document: submission.document, + approvalDetails: { + approvedAt: approval_result.timestamp, + approvalId: approval_result.approvalId + }, + nextSteps: [ + "Payment processing initiated", + "Submitter notified", + "Documents archived" + ] + }; + } else { + context.logger.info(`Submission REJECTED`, { + submissionId: submission.submissionId, + submitter: submission.submitter.name + }); + + return { + status: "rejected", + submissionId: submission.submissionId, + submitter: submission.submitter, + document: submission.document, + rejectionDetails: { + rejectedAt: approval_result.timestamp, + approvalId: approval_result.approvalId + }, + nextSteps: [ + "Submitter notified of rejection", + "Resubmission instructions sent" + ] + }; + } + }); + + context.logger.info("HITL workflow completed", { + status: result.status, + submissionId: result.submissionId + }); + + return result; + } catch (error) { + context.logger.error("Workflow failed", { + error: error.message, + stack: error.stack + }); + throw error; + } +}; + +export const lambdaHandler = withDurableExecution(handler); diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/hitl-durable-function/package.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/hitl-durable-function/package.json new file mode 100644 index 000000000..19a442dd5 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lambdas/hitl-durable-function/package.json @@ -0,0 +1,19 @@ +{ + "name": "human-in-the-loop", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@aws/durable-execution-sdk-js": "^1.0.2", + "@aws/durable-execution-sdk-js-eslint-plugin": "^0.0.1", + "@aws/durable-execution-sdk-js-testing": "^0.0.1", + "@aws-sdk/client-sns": "^3.0.0", + "@aws-sdk/client-dynamodb": "^3.0.0", + "@aws-sdk/lib-dynamodb": "^3.0.0" + } +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lib/hitl-lambda-durable-function-cdk-stack.ts b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lib/hitl-lambda-durable-function-cdk-stack.ts new file mode 100644 index 000000000..fc6649f08 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/lib/hitl-lambda-durable-function-cdk-stack.ts @@ -0,0 +1,136 @@ +import { aws_lambda, aws_logs, aws_sns, aws_apigateway, aws_iam, aws_dynamodb } from 'aws-cdk-lib'; +import { EmailSubscription } from 'aws-cdk-lib/aws-sns-subscriptions'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as cdk from 'aws-cdk-lib/core'; +import { Duration, RemovalPolicy } from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; + +export class HitlLambdaDurableFunctionCdkStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const email = this.node.tryGetContext('Email') as string; + if (!email) { + throw new Error('Email address is required. Please provide it using the --context flag.'); + } + + // DynamoDB table for storing callback tokens + const callbackTable = new aws_dynamodb.Table(this, 'CallbackTable', { + tableName: 'hitl-callback-tokens', + partitionKey: { + name: 'approvalId', + type: aws_dynamodb.AttributeType.STRING, + }, + billingMode: aws_dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: RemovalPolicy.DESTROY, + timeToLiveAttribute: 'ttl', // Auto-expire old tokens + }); + + // SNS Topic for email notifications + const approvalTopic = new aws_sns.Topic(this, 'ApprovalTopic', { + displayName: 'Human Approval Notifications', + topicName: 'hitl-approval-notifications', + }); + + // Subscribe email to SNS topic + approvalTopic.addSubscription(new EmailSubscription(email)); + + // API Gateway for callback handling + const api = new aws_apigateway.RestApi(this, 'HitlCallbackApi', { + restApiName: 'HITL-Callback-API', + description: 'this API handles human-in-the-loop callbacks', + deployOptions: { + stageName: 'prod', + }, + }); + + const verifyResource = api.root.addResource('verify'); + + // HITL orchestrator durable function + const hitlDurableFunctionLogGroup = new aws_logs.LogGroup(this, 'HitlDurableFunctionLogGroup', { + logGroupName: '/aws/lambda/hitl-durable-function', + retention: aws_logs.RetentionDays.ONE_WEEK, + removalPolicy: RemovalPolicy.DESTROY, + }); + + const hitlDurableFunction = new NodejsFunction(this, 'HitlDurableFunction', { + runtime: aws_lambda.Runtime.NODEJS_22_X, + tracing: aws_lambda.Tracing.ACTIVE, + functionName: 'hitl-durable-function', + description: 'Orchestrates human-in-the-loop workflow with email verification and approval', + loggingFormat: aws_lambda.LoggingFormat.JSON, + handler: 'index.lambdaHandler', + logGroup: hitlDurableFunctionLogGroup, + durableConfig: { + executionTimeout: Duration.hours(1), + retentionPeriod: Duration.days(30) + }, + code: aws_lambda.Code.fromAsset('lambdas/hitl-durable-function'), + environment: { + SNS_TOPIC_ARN: approvalTopic.topicArn, + API_URL: api.url, + CALLBACK_TABLE_NAME: callbackTable.tableName, + }, + }); + + // Grant permissions + approvalTopic.grantPublish(hitlDurableFunction); + callbackTable.grantWriteData(hitlDurableFunction); + + // Callback handler Lambda + const callbackHandlerLogGroup = new aws_logs.LogGroup(this, 'CallbackHandlerLogGroup', { + logGroupName: '/aws/lambda/hitl-callback-handler', + retention: aws_logs.RetentionDays.ONE_WEEK, + removalPolicy: RemovalPolicy.DESTROY, + }); + + const callbackHandler = new aws_lambda.Function(this, 'CallbackHandler', { + runtime: aws_lambda.Runtime.NODEJS_22_X, + tracing: aws_lambda.Tracing.ACTIVE, + functionName: 'hitl-callback-handler', + timeout: Duration.minutes(1), + description: 'Handles callback from human approval links in API Gateway', + loggingFormat: aws_lambda.LoggingFormat.JSON, + handler: 'index.handler', + logGroup: callbackHandlerLogGroup, + code: aws_lambda.Code.fromAsset('lambdas/callback-handler'), + environment: { + CALLBACK_TABLE_NAME: callbackTable.tableName, + }, + }); + + // Grant callback handler permission to send durable execution callbacks + callbackHandler.addToRolePolicy(new aws_iam.PolicyStatement({ + actions: ['lambda:SendDurableExecutionCallbackSuccess', 'lambda:SendDurableExecutionCallbackFailure'], + resources: ['*'], // Callback operations don't target specific function ARNs + })); + + // Grant callback handler permission to read from DynamoDB + callbackTable.grantReadData(callbackHandler); + + // API Gateway integration - GET for email callback links + const integration = new aws_apigateway.LambdaIntegration(callbackHandler); + verifyResource.addMethod('GET', integration); + + // Outputs + new cdk.CfnOutput(this, 'Hitl-ApiUrl', { + value: api.url, + description: 'API Gateway URL for callbacks', + }); + + new cdk.CfnOutput(this, 'Hitl-Sns-TopicArn', { + value: approvalTopic.topicArn, + description: 'SNS Topic ARN for approval notifications', + }); + + new cdk.CfnOutput(this, 'Hitl-Durable-Function-Name', { + value: hitlDurableFunction.functionName, + description: 'HITL Durable Function Name', + }); + + new cdk.CfnOutput(this, 'Hitl-Callback-Table-Name', { + value: callbackTable.tableName, + description: 'DynamoDB table for callback tokens', + }); + } +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/package.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/package.json new file mode 100644 index 000000000..992efcc2e --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/package.json @@ -0,0 +1,26 @@ +{ + "name": "hitl-lambda-durable-function-cdk", + "version": "0.1.0", + "bin": { + "hitl-lambda-durable-function-cdk": "bin/hitl-lambda-durable-function-cdk.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "cdk": "cdk" + }, + "devDependencies": { + "@types/jest": "^30", + "@types/node": "^24.10.1", + "jest": "^30", + "ts-jest": "^29", + "aws-cdk": "2.1100.3", + "ts-node": "^10.9.2", + "typescript": "~5.9.3" + }, + "dependencies": { + "aws-cdk-lib": "^2.232.2", + "constructs": "^10.0.0" + } +} diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/test/hitl-lambda-durable-function-cdk.test.ts b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/test/hitl-lambda-durable-function-cdk.test.ts new file mode 100644 index 000000000..2f0b4da9d --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/test/hitl-lambda-durable-function-cdk.test.ts @@ -0,0 +1,17 @@ +// import * as cdk from 'aws-cdk-lib/core'; +// import { Template } from 'aws-cdk-lib/assertions'; +// import * as HitlLambdaDurableFunctionCdk from '../lib/hitl-lambda-durable-function-cdk-stack'; + +// example test. To run these tests, uncomment this file along with the +// example resource in lib/hitl-lambda-durable-function-cdk-stack.ts +test('SQS Queue Created', () => { +// const app = new cdk.App(); +// // WHEN +// const stack = new HitlLambdaDurableFunctionCdk.HitlLambdaDurableFunctionCdkStack(app, 'MyTestStack'); +// // THEN +// const template = Template.fromStack(stack); + +// template.hasResourceProperties('AWS::SQS::Queue', { +// VisibilityTimeout: 300 +// }); +}); diff --git a/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/tsconfig.json b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/tsconfig.json new file mode 100644 index 000000000..bfc61bf83 --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/hitl-lambda-durable-function-cdk/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "es2022" + ], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "skipLibCheck": true, + "typeRoots": [ + "./node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "cdk.out" + ] +} diff --git a/lambda-durable-functions-human-in-the-loop/images/durable-function-success.png b/lambda-durable-functions-human-in-the-loop/images/durable-function-success.png new file mode 100644 index 000000000..5d471c654 Binary files /dev/null and b/lambda-durable-functions-human-in-the-loop/images/durable-function-success.png differ diff --git a/lambda-durable-functions-human-in-the-loop/images/durable-operation.png b/lambda-durable-functions-human-in-the-loop/images/durable-operation.png new file mode 100644 index 000000000..a87120c42 Binary files /dev/null and b/lambda-durable-functions-human-in-the-loop/images/durable-operation.png differ diff --git a/lambda-durable-functions-human-in-the-loop/images/email-message-example.png b/lambda-durable-functions-human-in-the-loop/images/email-message-example.png new file mode 100644 index 000000000..e20f771ba Binary files /dev/null and b/lambda-durable-functions-human-in-the-loop/images/email-message-example.png differ diff --git a/lambda-durable-functions-human-in-the-loop/images/human-decision-rejected.png b/lambda-durable-functions-human-in-the-loop/images/human-decision-rejected.png new file mode 100644 index 000000000..00ccd1392 Binary files /dev/null and b/lambda-durable-functions-human-in-the-loop/images/human-decision-rejected.png differ diff --git a/lambda-durable-functions-human-in-the-loop/images/human-in-the-loop-architecture.svg b/lambda-durable-functions-human-in-the-loop/images/human-in-the-loop-architecture.svg new file mode 100644 index 000000000..55188639b --- /dev/null +++ b/lambda-durable-functions-human-in-the-loop/images/human-in-the-loop-architecture.svg @@ -0,0 +1,4 @@ + + + +
Human Reviewer
AWS Lambda Durable Function

AWS Cloud
Callback API
Amazon API Gateway

Callback handler
AWS Lambda
1. Email is sent user to approve or deny while the workflow is paused

Add Human Approval Review
Human Reviewer Email
Amazon SNS
3. API Gateway calls a proxy Lambda to process review decision 
4. Lambda sends success callback result with approval or rejection decision back to Durable Function workflow 
Update Human Approval Review Decision
2. User opens email and clicks either approve or deny link
\ No newline at end of file