├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── README.md ├── package.json ├── packages ├── frontend │ ├── .env │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── components.json │ ├── next.config.mjs │ ├── package.json │ ├── postcss.config.js │ ├── public │ │ ├── favicon.ico │ │ ├── next.svg │ │ └── vercel.svg │ ├── src │ │ ├── components │ │ │ └── ui │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── input.tsx │ │ │ │ └── label.tsx │ │ ├── lib │ │ │ └── utils.ts │ │ ├── pages │ │ │ ├── _app.tsx │ │ │ ├── _document.tsx │ │ │ ├── admin.tsx │ │ │ └── index.tsx │ │ └── styles │ │ │ └── globals.css │ ├── sst-env.d.ts │ ├── tailwind.config.js │ ├── tailwind.config.ts │ └── tsconfig.json └── functions │ ├── package.json │ ├── src │ ├── api.ts │ ├── approval-receiver.ts │ └── ask-approval.ts │ ├── sst-env.d.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── sst.config.ts ├── stacks ├── Api.ts ├── Frontend.ts ├── StepFunction.ts └── Table.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # sst 5 | .sst 6 | .build 7 | 8 | # opennext 9 | .open-next 10 | 11 | # misc 12 | .DS_Store 13 | 14 | # local env files 15 | .env*.local 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug SST Start", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", 9 | "runtimeArgs": ["start", "--increase-timeout"], 10 | "console": "integratedTerminal", 11 | "skipFiles": ["/**"], 12 | "env": {} 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/.sst": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sfn-wait-for-callback 2 | 3 | Welcome to the `sfn-wait-for-callback` repository! This project complements our [blog post](https://blog.awsfundamentals.com/building-a-real-world-use-case-with-step-functions-and-the-callback-pattern), demonstrating a real-world application of AWS Step Functions using the callback pattern. 4 | 5 | We implement a basic UI that starts a Step Function, let's it wait for a specific input (title forbidden), and continues it only on user input. 6 | 7 | ![Step Function Workflow](https://github.com/awsfundamentals-hq/sfn-wait-for-callback/assets/19362086/08606338-9b90-4b63-99b9-7955c8601067) 8 | 9 | ## Deploy 10 | 11 | You can use any package manager, we've used pnpm. Follow these commands to deploy the project to your AWS default profile: 12 | 13 | ``` 14 | pnpm install 15 | pnpm run deploy:prod 16 | ``` 17 | 18 | 19 | ## Running Locally 20 | 21 | To run the project on your local machine: 22 | 23 | 1. Install dependencies: 24 | 25 | ``` 26 | pnpm install 27 | ``` 28 | 29 | 30 | 2. Start the development server: 31 | 32 | ``` 33 | pnpm run dev 34 | ``` 35 | 36 | 37 | 3. For the frontend, navigate to the `packages/frontend` directory and start its development server: 38 | 39 | ``` 40 | cd packages/frontend 41 | pnpm run dev 42 | ``` 43 | 44 | 45 | ## Components 46 | 47 | ### Frontend 48 | 49 | **Technology**: Next.js 50 | 51 | **Submit Articles**: We've implemented a mock form that submits new articles. We only send new article titles. If you send the title `forbidden` a step Function will halt. 52 | 53 | ![Content Moderation System UI](https://github.com/awsfundamentals-hq/sfn-wait-for-callback/assets/19362086/4033ce8e-a4fb-41fc-a506-01cdb0f60648) 54 | 55 | **Admin Page**: In `/admin` we've built an admin page that shows you all waiting step functions. 56 | On `approve` or `reject` you will continue the Step Function with the respective decision. 57 | 58 | ![image](https://github.com/awsfundamentals-hq/sfn-wait-for-callback/assets/19362086/3d3b3a20-a982-42ff-a55f-077d087e580e) 59 | 60 | More details are available in our [blog post](https://blog.awsfundamentals.com/building-a-real-world-use-case-with-step-functions-and-the-callback-pattern). 61 | 62 | ### Backend Resources 63 | 64 | - **Step Function**: Built using AWS CDK, employing the chain syntax for state management. 65 | 66 | ![Step Function](https://github.com/awsfundamentals-hq/sfn-wait-for-callback/assets/19362086/7e72e40e-586a-4508-a925-eebea9368316) 67 | 68 | - **Lambda Functions**: 69 | - REST API: Starts Step Functions and retrieves items from DynamoDB. 70 | - Approval Receiver: Handles decision and task token reception. 71 | - Manual Approval Request: Records Step Functions awaiting approval in DynamoDB. 72 | 73 | - **DynamoDB**: Stores active Step Functions and related metadata. 74 | 75 | ## Costs 76 | 77 | This project is serverless, meaning it is 100% usage-based. Testing should be free under the AWS Free Tier. For larger-scale operations, be mindful of DynamoDB scan costs and Step Function state changes. 78 | 79 | ## Contributions 80 | 81 | Interested in contributing? Great! 🚀 Simply create an issue or a pull request, and we'll take a look. 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sfn-wait-callback", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "engines": { 7 | "node": "18.x" 8 | }, 9 | "scripts": { 10 | "dev": "sst dev", 11 | "build": "sst build", 12 | "deploy": "sst deploy", 13 | "deploy:prod": "sst deploy --stage prod", 14 | "lint": "pnpm --recursive run lint", 15 | "clean": "rm -rf ./node_modules && pnpm --recursive run clean", 16 | "remove": "sst remove", 17 | "console": "sst console", 18 | "typecheck": "tsc --noEmit" 19 | }, 20 | "devDependencies": { 21 | "sst": "2.24.27", 22 | "aws-cdk-lib": "2.91.0", 23 | "constructs": "10.2.69", 24 | "typescript": "5.0.4" 25 | }, 26 | "workspaces": [ 27 | "packages/*" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /packages/frontend/.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=http://localhost:3000/api 2 | -------------------------------------------------------------------------------- /packages/frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /packages/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /packages/frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 20 | 21 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 22 | 23 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 24 | 25 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 26 | 27 | ## Learn More 28 | 29 | To learn more about Next.js, take a look at the following resources: 30 | 31 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 32 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 33 | 34 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 35 | 36 | ## Deploy on Vercel 37 | 38 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 39 | 40 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 41 | -------------------------------------------------------------------------------- /packages/frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /packages/frontend/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | export default nextConfig; 7 | -------------------------------------------------------------------------------- /packages/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "dev": "sst bind next dev", 6 | "build": "sst bind next build", 7 | "start": "next start", 8 | "lint": "next lint", 9 | "clean": "rm -rf .next .open-next node_modules" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-label": "^2.0.2", 13 | "@radix-ui/react-navigation-menu": "^1.1.4", 14 | "@radix-ui/react-slot": "^1.0.2", 15 | "class-variance-authority": "^0.7.0", 16 | "clsx": "^2.1.0", 17 | "lucide-react": "^0.352.0", 18 | "next": "13.5.6", 19 | "react": "18.2.0", 20 | "react-dom": "18.2.0", 21 | "tailwind-merge": "^2.2.1", 22 | "tailwindcss-animate": "^1.0.7" 23 | }, 24 | "devDependencies": { 25 | "@types/node": "^20", 26 | "@types/react": "18.2.55", 27 | "@types/react-dom": "18.2.19", 28 | "autoprefixer": "^10.0.1", 29 | "eslint": "^8", 30 | "eslint-config-next": "14.1.2", 31 | "postcss": "^8", 32 | "tailwindcss": "^3.3.0", 33 | "typescript": "^5" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awsfundamentals-hq/sfn-wait-for-callback/b8be583878570b1b6529bc55e8cdaba3e174b1ed/packages/frontend/public/favicon.ico -------------------------------------------------------------------------------- /packages/frontend/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /packages/frontend/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import Link from "next/link"; 3 | import "../styles/globals.css"; 4 | 5 | export default function App({ Component, pageProps }: AppProps) { 6 | return ( 7 | <> 8 | 16 |
17 | 18 |
19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/admin.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { InferGetServerSidePropsType, GetServerSideProps } from "next"; 3 | import { Label } from "@/components/ui/label"; 4 | import { CardTitle, CardHeader, CardFooter, Card } from "@/components/ui/card"; 5 | import { Button } from "@/components/ui/button"; 6 | type Execution = { 7 | id: string; 8 | stateMachineArn: string; 9 | approveUrl: string; 10 | rejectUrl: string; 11 | title: string; 12 | }; 13 | 14 | const API_URL = process.env.NEXT_PUBLIC_API_URL; 15 | 16 | if (!API_URL) { 17 | throw new Error("API_URL is required"); 18 | } 19 | 20 | export async function getServerSideProps() { 21 | // Fetch data from external API 22 | const res = await fetch(`${API_URL}invocations`); 23 | const data = (await res.json()) as Execution[]; 24 | console.log("Data: ", data); 25 | 26 | const executions = data; 27 | 28 | const executionsWithLinks = executions.map((item) => { 29 | const link = `https://console.aws.amazon.com/states/home?region=us-east-1#/executions/details/${item.id}`; 30 | return { ...item, link }; 31 | }); 32 | 33 | return { props: { executions: executionsWithLinks } }; 34 | } 35 | 36 | export default function Admin({ 37 | executions, 38 | }: InferGetServerSidePropsType) { 39 | return ( 40 |
41 | {executions.map((execution) => ( 42 | 43 | 44 |
45 | 48 | 49 | Workflow Step: Check if title is allowed 50 | 51 |
52 |
53 |

54 | This is a step function waiting for a callback token. Approve it 55 | if the title looks fine. Reject it if it is not fine. 56 |

57 |
58 | 66 |
67 | 68 | 76 | 84 | 85 |
86 | ))} 87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Input } from "@/components/ui/input"; 3 | import { ChangeEvent, FormEvent, useState } from "react"; 4 | const API_URL = process.env.NEXT_PUBLIC_API_URL; 5 | 6 | if (!API_URL) { 7 | throw new Error("API_URL is required"); 8 | } 9 | 10 | export default function Home() { 11 | // Use react-query to not do this in the future 😉 12 | const [title, setTitle] = useState(""); 13 | const [error, setError] = useState(""); 14 | const [link, setLink] = useState(""); 15 | 16 | const handleChange = (event: ChangeEvent): void => { 17 | setTitle(event.target.value); 18 | setError(""); 19 | setLink(""); 20 | }; 21 | 22 | const handleSubmit = async ( 23 | event: FormEvent 24 | ): Promise => { 25 | event.preventDefault(); 26 | setError(""); 27 | setLink(""); 28 | // @ts-ignore 29 | const titleFromEvent = event.target[0].value; 30 | 31 | const result = await fetch(`${API_URL}invoke`, { 32 | body: JSON.stringify({ title: titleFromEvent }), 33 | method: "POST", 34 | }); 35 | 36 | if (!result.ok) { 37 | console.error("Error sending title"); 38 | setError(`Error sending title: ${result.statusText}`); 39 | } 40 | 41 | const { executionArn } = await result.json(); 42 | 43 | const link = `https://console.aws.amazon.com/states/home?region=us-east-1#/executions/details/${executionArn}`; 44 | setLink(link); 45 | setTitle(""); 46 | }; 47 | 48 | return ( 49 |
50 |
51 |
52 |
53 |

Submit Article

54 |

55 | Enter a title to begin 56 |

57 |
58 |
59 | handleChange(e)} 63 | /> 64 | 65 | {error && ( 66 |

{error}

67 | )} 68 | {link && ( 69 | 74 | Step Function Invoked 75 | 76 | )} 77 |
78 |

79 | This form will invoke a Step Function 80 |

81 |

82 | If you enter forbidden the step function will be stopped and appear 83 | in the Content Moderation Tab 84 |

85 |
86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /packages/frontend/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/frontend/sst-env.d.ts: -------------------------------------------------------------------------------- 1 | /// -------------------------------------------------------------------------------- /packages/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}', 9 | ], 10 | prefix: "", 11 | theme: { 12 | container: { 13 | center: true, 14 | padding: "2rem", 15 | screens: { 16 | "2xl": "1400px", 17 | }, 18 | }, 19 | extend: { 20 | colors: { 21 | border: "hsl(var(--border))", 22 | input: "hsl(var(--input))", 23 | ring: "hsl(var(--ring))", 24 | background: "hsl(var(--background))", 25 | foreground: "hsl(var(--foreground))", 26 | primary: { 27 | DEFAULT: "hsl(var(--primary))", 28 | foreground: "hsl(var(--primary-foreground))", 29 | }, 30 | secondary: { 31 | DEFAULT: "hsl(var(--secondary))", 32 | foreground: "hsl(var(--secondary-foreground))", 33 | }, 34 | destructive: { 35 | DEFAULT: "hsl(var(--destructive))", 36 | foreground: "hsl(var(--destructive-foreground))", 37 | }, 38 | muted: { 39 | DEFAULT: "hsl(var(--muted))", 40 | foreground: "hsl(var(--muted-foreground))", 41 | }, 42 | accent: { 43 | DEFAULT: "hsl(var(--accent))", 44 | foreground: "hsl(var(--accent-foreground))", 45 | }, 46 | popover: { 47 | DEFAULT: "hsl(var(--popover))", 48 | foreground: "hsl(var(--popover-foreground))", 49 | }, 50 | card: { 51 | DEFAULT: "hsl(var(--card))", 52 | foreground: "hsl(var(--card-foreground))", 53 | }, 54 | }, 55 | borderRadius: { 56 | lg: "var(--radius)", 57 | md: "calc(var(--radius) - 2px)", 58 | sm: "calc(var(--radius) - 4px)", 59 | }, 60 | keyframes: { 61 | "accordion-down": { 62 | from: { height: "0" }, 63 | to: { height: "var(--radix-accordion-content-height)" }, 64 | }, 65 | "accordion-up": { 66 | from: { height: "var(--radix-accordion-content-height)" }, 67 | to: { height: "0" }, 68 | }, 69 | }, 70 | animation: { 71 | "accordion-down": "accordion-down 0.2s ease-out", 72 | "accordion-up": "accordion-up 0.2s ease-out", 73 | }, 74 | }, 75 | }, 76 | plugins: [require("tailwindcss-animate")], 77 | } -------------------------------------------------------------------------------- /packages/frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /packages/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "paths": { 20 | "@/*": [ 21 | "./src/*" 22 | ] 23 | }, 24 | "plugins": [ 25 | { 26 | "name": "next" 27 | } 28 | ] 29 | }, 30 | "include": [ 31 | "next-env.d.ts", 32 | "**/*.ts", 33 | "**/*.tsx", 34 | ".next/types/**/*.ts" 35 | ], 36 | "exclude": [ 37 | "node_modules" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /packages/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sfn-wait-callback/functions", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "test": "sst bind vitest", 7 | "typecheck": "tsc -noEmit" 8 | }, 9 | "devDependencies": { 10 | "@aws-sdk/client-dynamodb": "^3.529.1", 11 | "@aws-sdk/client-sfn": "^3.525.0", 12 | "@aws-sdk/util-dynamodb": "^3.529.1", 13 | "@types/aws-lambda": "^8.10.136", 14 | "@types/node": "^20.11.25", 15 | "sst": "2.40.6", 16 | "vitest": "^1.3.1" 17 | } 18 | } -------------------------------------------------------------------------------- /packages/functions/src/api.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient, ScanCommand } from "@aws-sdk/client-dynamodb"; 2 | import { SFNClient, StartExecutionCommand } from "@aws-sdk/client-sfn"; 3 | import { unmarshall } from "@aws-sdk/util-dynamodb"; 4 | import { ApiHandler } from "sst/node/api"; 5 | 6 | const sfn = new SFNClient({}); 7 | const ddb = new DynamoDBClient({}); 8 | 9 | const STATE_MACHINE_ARN = process.env.STATE_MACHINE_ARN; 10 | if (!STATE_MACHINE_ARN) { 11 | throw new Error("STATE_MACHINE_ARN is required"); 12 | } 13 | 14 | export const handler = ApiHandler(async (_evt) => { 15 | console.log("Event: ", _evt); 16 | 17 | const path = _evt.requestContext.http.path; 18 | 19 | switch (path) { 20 | case "/invoke": 21 | // Get Body 22 | const body = JSON.parse(_evt.body || "{}"); 23 | if (!body.title) { 24 | return { 25 | statusCode: 400, 26 | body: JSON.stringify({ message: "Title is required" }), 27 | }; 28 | } 29 | 30 | return await invokeNewStepFunction(body.title); 31 | case "/invocations": 32 | return await getStepFunctionInvocations(); 33 | default: 34 | return { 35 | statusCode: 404, 36 | body: JSON.stringify({ message: "Not Found" }), 37 | }; 38 | } 39 | }); 40 | 41 | async function invokeNewStepFunction(title: string) { 42 | console.log("Invoking new Step Function"); 43 | 44 | const command = new StartExecutionCommand({ 45 | stateMachineArn: STATE_MACHINE_ARN, 46 | input: JSON.stringify({ title }), 47 | }); 48 | 49 | const response = await sfn.send(command); 50 | 51 | console.log("Response: ", response); 52 | 53 | return { 54 | statusCode: 200, 55 | body: JSON.stringify({ 56 | message: "Invoked new Step Function", 57 | executionArn: response.executionArn, 58 | }), 59 | }; 60 | } 61 | 62 | async function getStepFunctionInvocations() { 63 | console.log("Getting Step Function Invocations"); 64 | 65 | // Scan DynamoDB Table 66 | const command = new ScanCommand({ 67 | TableName: process.env.REQUESTS_TABLE_NAME!, 68 | }); 69 | 70 | const response = await ddb.send(command); 71 | 72 | console.log("Response: ", response); 73 | 74 | const unmarshalledItems = response.Items?.map((item) => unmarshall(item)); 75 | 76 | return { 77 | statusCode: 200, 78 | body: JSON.stringify(unmarshalledItems), 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /packages/functions/src/approval-receiver.ts: -------------------------------------------------------------------------------- 1 | import { DeleteItemCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb"; 2 | import { SFNClient, SendTaskSuccessCommand } from "@aws-sdk/client-sfn"; 3 | import { ApiHandler } from "sst/node/api"; 4 | const sfn = new SFNClient(); 5 | const ddb = new DynamoDBClient(); 6 | 7 | export const handler = ApiHandler(async (_evt) => { 8 | console.log("Event: ", _evt); 9 | 10 | // Get task token and decision from url 11 | const taskToken = _evt.queryStringParameters?.["task-token"]; 12 | const decision = _evt.queryStringParameters?.["decision"]; 13 | const executionArn = _evt.queryStringParameters?.["execution-arn"]; 14 | 15 | console.log("Task Token: ", taskToken); 16 | console.log("Decision: ", decision); 17 | 18 | if (!taskToken || !decision || !executionArn) { 19 | return { 20 | statusCode: 400, 21 | body: JSON.stringify({ 22 | message: "task-token, decision, execution arn are required", 23 | }), 24 | }; 25 | } 26 | 27 | const command = new SendTaskSuccessCommand({ 28 | taskToken, 29 | output: JSON.stringify({ isApproved: decision === "approved" }), 30 | }); 31 | 32 | const response = await sfn.send(command); 33 | 34 | console.log("Response: ", response); 35 | 36 | // Remove DynamoDB Item with executionArn 37 | const deleteCommand = new DeleteItemCommand({ 38 | TableName: process.env.REQUESTS_TABLE_NAME, 39 | Key: { id: { S: executionArn } }, 40 | }); 41 | 42 | const deleteResult = await ddb.send(deleteCommand); 43 | 44 | console.log("Delete Result: ", deleteResult); 45 | 46 | return { 47 | statusCode: 200, 48 | body: JSON.stringify({ message: "Sent task success" }), 49 | }; 50 | }); 51 | -------------------------------------------------------------------------------- /packages/functions/src/ask-approval.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb"; 2 | import { Table } from "sst/node/table"; 3 | 4 | const client = new DynamoDBClient(); 5 | 6 | const APPROVAL_RECEIVER_URL = process.env.APPROVAL_RECEIVER_URL; 7 | 8 | if (!APPROVAL_RECEIVER_URL) { 9 | throw new Error("APPROVAL_RECEIVER_URL is required"); 10 | } 11 | 12 | type Event = { 13 | taskToken: string; 14 | executionArn: string; 15 | stateMachineArn: string; 16 | input: { 17 | title: string; 18 | }; 19 | }; 20 | 21 | export const handler = async (event: Event) => { 22 | console.log("Event: ", event); 23 | 24 | const { approveUrl, rejectUrl } = buildUrls( 25 | event.taskToken, 26 | event.executionArn 27 | ); 28 | 29 | console.log("Approve URL: ", approveUrl.toString()); 30 | console.log("Reject URL: ", rejectUrl.toString()); 31 | 32 | const command = new PutItemCommand({ 33 | TableName: process.env.REQUESTS_TABLE_NAME!, 34 | Item: { 35 | id: { S: event.executionArn }, 36 | approveUrl: { S: approveUrl.toString() }, 37 | rejectUrl: { S: rejectUrl.toString() }, 38 | stateMachineArn: { S: event.stateMachineArn }, 39 | title: { S: event.input.title }, 40 | }, 41 | }); 42 | 43 | const response = await client.send(command); 44 | 45 | console.log("Response: ", response); 46 | 47 | return { response }; 48 | }; 49 | 50 | function buildUrls(taskToken: string, executionArn: string) { 51 | const url = new URL(APPROVAL_RECEIVER_URL!); 52 | url.searchParams.append("task-token", taskToken); 53 | url.searchParams.append("execution-arn", executionArn); 54 | const approveUrl = new URL(url.toString()); 55 | approveUrl.searchParams.append("decision", "approved"); 56 | const rejectUrl = new URL(url.toString()); 57 | rejectUrl.searchParams.append("decision", "rejected"); 58 | return { approveUrl, rejectUrl }; 59 | } 60 | -------------------------------------------------------------------------------- /packages/functions/sst-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('tsconfig').TSConfig} 3 | */ 4 | { 5 | "$schema": "https://json.schemastore.org/tsconfig", 6 | "compilerOptions": { 7 | /* Base Options: */ 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "target": "es2022", 11 | "allowJs": true, 12 | "resolveJsonModule": true, 13 | "moduleDetection": "force", 14 | "isolatedModules": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "incremental": true, 17 | 18 | /* Strictness */ 19 | "strict": true, 20 | "noUncheckedIndexedAccess": true, 21 | 22 | /* If NOT transpiling with TypeScript: */ 23 | "moduleResolution": "Bundler", 24 | "module": "ESNext", 25 | "noEmit": true, 26 | 27 | /* If your code doesn't run in the DOM: */ 28 | "lib": ["es2022"], 29 | 30 | /* CUSTOM */ 31 | "baseUrl": ".", 32 | "paths": { 33 | "@octolense/core/*": ["../core/src/*"] 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/**/*" 3 | -------------------------------------------------------------------------------- /sst.config.ts: -------------------------------------------------------------------------------- 1 | import { SSTConfig } from "sst"; 2 | import { Frontend } from "./stacks/Frontend"; 3 | import { StepFunction } from "./stacks/StepFunction"; 4 | import { RequestTable } from "./stacks/Table"; 5 | import { Api } from "./stacks/Api"; 6 | 7 | export default { 8 | config(_input) { 9 | return { 10 | name: "sfn-wait-callback", 11 | region: "us-east-1", 12 | }; 13 | }, 14 | stacks(app) { 15 | app.setDefaultFunctionProps({ 16 | runtime: "nodejs18.x", 17 | }); 18 | 19 | app.stack(RequestTable); 20 | app.stack(Api); 21 | app.stack(StepFunction); 22 | app.stack(Frontend); 23 | }, 24 | } satisfies SSTConfig; 25 | -------------------------------------------------------------------------------- /stacks/Api.ts: -------------------------------------------------------------------------------- 1 | import { Function, StackContext, use } from "sst/constructs"; 2 | import { RequestTable } from "./Table"; 3 | 4 | export function Api({ stack }: StackContext) { 5 | const { requestsTable } = use(RequestTable); 6 | const apiLambda = new Function(stack, "Api", { 7 | handler: "packages/functions/src/api.handler", 8 | url: { 9 | authorizer: "none", 10 | cors: true, 11 | }, 12 | bind: [requestsTable], 13 | // Adding it as an .env var as well because I need to use an older SST Version and I can't import the binding rn 14 | environment: { 15 | REQUESTS_TABLE_NAME: requestsTable.tableName, 16 | }, 17 | }); 18 | 19 | stack.addOutputs({ 20 | ApiEndpoint: apiLambda.url, 21 | }); 22 | 23 | return { apiLambda }; 24 | } 25 | -------------------------------------------------------------------------------- /stacks/Frontend.ts: -------------------------------------------------------------------------------- 1 | import { NextjsSite, StackContext, use } from "sst/constructs"; 2 | import { StepFunction } from "./StepFunction"; 3 | import { Api } from "./Api"; 4 | 5 | export function Frontend({ stack }: StackContext) { 6 | const { stateMachine } = use(StepFunction); 7 | const { apiLambda } = use(Api); 8 | 9 | if (!apiLambda.url) { 10 | throw new Error("Api URL is required"); 11 | } 12 | 13 | const site = new NextjsSite(stack, "NextjsSite", { 14 | path: "packages/frontend", 15 | environment: { 16 | STATE_MACHINE_ARN: stateMachine.stateMachineArn, 17 | NEXT_PUBLIC_API_URL: apiLambda.url, 18 | }, 19 | }); 20 | 21 | stack.addOutputs({ 22 | FrontendUrl: site.url, 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /stacks/StepFunction.ts: -------------------------------------------------------------------------------- 1 | import * as sfn from "aws-cdk-lib/aws-stepfunctions"; 2 | import * as tasks from "aws-cdk-lib/aws-stepfunctions-tasks"; 3 | import { Function, StackContext, use } from "sst/constructs"; 4 | import { RequestTable } from "./Table"; 5 | import { Api } from "./Api"; 6 | import * as iam from "aws-cdk-lib/aws-iam"; 7 | export function StepFunction({ stack }: StackContext) { 8 | const { requestsTable } = use(RequestTable); 9 | const { apiLambda } = use(Api); 10 | 11 | // Lambda Function that receives the callback from the manual approval 12 | const approvalReceiverLambda = new Function(stack, "ApprovalReceiverLambda", { 13 | handler: "packages/functions/src/approval-receiver.handler", 14 | url: true, 15 | bind: [requestsTable], 16 | // Adding it as an .env var as well because I need to use an older SST Version and I can't import the binding rn 17 | environment: { 18 | REQUESTS_TABLE_NAME: requestsTable.tableName, 19 | }, 20 | }); 21 | 22 | // Lambda function to notify admin -- stack is actual just a mock to wait for the callback 23 | const saveApprovalLambda = new Function(stack, "NotifyLambda", { 24 | handler: "packages/functions/src/ask-approval.handler", 25 | bind: [requestsTable], 26 | 27 | environment: { 28 | APPROVAL_RECEIVER_URL: approvalReceiverLambda.url!, 29 | REQUESTS_TABLE_NAME: requestsTable.tableName, 30 | }, 31 | }); 32 | 33 | // Step Function 34 | const manualApprovalLambdaTask = new tasks.LambdaInvoke( 35 | stack, 36 | "Ask For Manual Approval", 37 | { 38 | lambdaFunction: saveApprovalLambda, 39 | integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN, 40 | payload: sfn.TaskInput.fromObject({ 41 | taskToken: sfn.JsonPath.taskToken, 42 | input: sfn.JsonPath.entirePayload, 43 | executionArn: sfn.JsonPath.stringAt("$$.Execution.Id"), 44 | stateMachineArn: sfn.JsonPath.stringAt("$$.StateMachine.Id"), 45 | }), 46 | } 47 | ); 48 | 49 | const mockSaveArticle = new sfn.Pass(stack, "Save Article - Mock").next( 50 | new sfn.Succeed(stack, "Article Saved") 51 | ); 52 | 53 | const checkTitle = new sfn.Choice(stack, "Check Title") 54 | .when( 55 | sfn.Condition.stringMatches("$.title", "forbidden"), 56 | manualApprovalLambdaTask.next( 57 | new sfn.Choice(stack, "Approved?") 58 | .when( 59 | sfn.Condition.booleanEquals("$.isApproved", true), 60 | mockSaveArticle 61 | ) 62 | .otherwise( 63 | new sfn.Pass(stack, "Article Rejected - Inform user - Mock") 64 | ) 65 | ) 66 | ) 67 | .otherwise(mockSaveArticle); 68 | 69 | const stateMachine = new sfn.StateMachine(stack, "ContentModeration", { 70 | definition: checkTitle, 71 | }); 72 | 73 | // // IAM Permissions 74 | stateMachine.grantStartExecution(apiLambda); 75 | stateMachine.grantRead(apiLambda); 76 | apiLambda.addEnvironment("STATE_MACHINE_ARN", stateMachine.stateMachineArn); 77 | 78 | approvalReceiverLambda.addToRolePolicy( 79 | new iam.PolicyStatement({ 80 | effect: iam.Effect.ALLOW, 81 | actions: ["states:SendTaskSuccess"], 82 | resources: ["*"], 83 | }) 84 | ); 85 | 86 | return { stateMachine }; 87 | } 88 | -------------------------------------------------------------------------------- /stacks/Table.ts: -------------------------------------------------------------------------------- 1 | import { StackContext, Table } from "sst/constructs"; 2 | 3 | export function RequestTable({ stack }: StackContext) { 4 | const requestsTable = new Table(stack, "RequestsTable", { 5 | fields: { 6 | id: "string", 7 | approvalUrl: "string", 8 | title: "string", 9 | declinedUrl: "string", 10 | result: "string", 11 | }, 12 | primaryIndex: { partitionKey: "id" }, 13 | }); 14 | 15 | return { 16 | requestsTable, 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('tsconfig').TSConfig} 3 | */ 4 | { 5 | "$schema": "https://json.schemastore.org/tsconfig", 6 | "compilerOptions": { 7 | /* Base Options: */ 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "target": "es2022", 11 | "allowJs": true, 12 | "resolveJsonModule": true, 13 | "moduleDetection": "force", 14 | "isolatedModules": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "incremental": true, 17 | 18 | /* Strictness */ 19 | "strict": true, 20 | "noUncheckedIndexedAccess": true, 21 | 22 | /* If NOT transpiling with TypeScript: */ 23 | "moduleResolution": "Bundler", 24 | "module": "ESNext", 25 | "noEmit": true, 26 | 27 | /* If your code doesn't run in the DOM: */ 28 | "lib": ["es2022"] 29 | }, 30 | 31 | "exclude": [ 32 | // this is only for the root 33 | "packages" 34 | ] 35 | } 36 | --------------------------------------------------------------------------------