AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: > meal-order-manager — automated weekly meal ordering from Redefine Meals with employee order collection, Slack notifications, and payroll deduction reports. Parameters: CustomDomain: Type: String Default: orders.seahavenind.com Description: Custom domain for the order form (requires ACM cert) CertificateArn: Type: String Default: '' Description: ACM certificate ARN for the custom domain (us-east-1) PayrollEmail: Type: String Default: payroll@seahavenind.com Description: Email address for payroll deduction reports SenderEmail: Type: String Default: adam@seahavenind.com Description: SES verified sender email for payroll reports Conditions: HasCustomDomain: !Not [!Equals [!Ref CertificateArn, '']] Globals: Function: Runtime: python3.12 Architectures: - arm64 Timeout: 30 MemorySize: 256 Environment: Variables: TABLE_NAME: !Ref OrdersTable REPORTS_BUCKET: !Ref ReportsBucket SLACK_BOT_TOKEN_SECRET: meal-order-manager/slack-bot-token SLACK_CHANNEL_PARAM: /meal-order-manager/slack-channel-id FORM_URL: !If - HasCustomDomain - !Sub 'https://${CustomDomain}' - !Sub 'https://${FormDistribution.DomainName}' Layers: - !Ref SharedLayer Resources: # ─── Shared Layer ─────────────────────────────────────────────── SharedLayer: Type: AWS::Serverless::LayerVersion Properties: LayerName: meal-order-manager-shared ContentUri: src/shared/ CompatibleRuntimes: - python3.12 CompatibleArchitectures: - arm64 Metadata: BuildMethod: python3.12 BuildArchitecture: arm64 # ─── DynamoDB ─────────────────────────────────────────────────── OrdersTable: Type: AWS::DynamoDB::Table Properties: TableName: meal-order-manager-orders BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: PK AttributeType: S - AttributeName: SK AttributeType: S KeySchema: - AttributeName: PK KeyType: HASH - AttributeName: SK KeyType: RANGE TimeToLiveSpecification: AttributeName: ttl Enabled: true # ─── S3 Buckets ──────────────────────────────────────────────── FormBucket: Type: AWS::S3::Bucket Properties: BucketName: !Sub 'meal-order-manager-form-${AWS::AccountId}' PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true LifecycleConfiguration: Rules: - Id: delete-old-archives Prefix: archive/ Status: Enabled ExpirationInDays: 90 Tags: - Key: Purpose Value: meal-order-form-hosting - Key: ManagedBy Value: meal-order-manager FormBucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref FormBucket PolicyDocument: Version: '2012-10-17' Statement: - Sid: AllowCloudFrontOAC Effect: Allow Principal: Service: cloudfront.amazonaws.com Action: s3:GetObject Resource: !Sub '${FormBucket.Arn}/*' Condition: StringEquals: AWS:SourceArn: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${FormDistribution}' ReportsBucket: Type: AWS::S3::Bucket Properties: BucketName: !Sub 'meal-order-manager-reports-${AWS::AccountId}' PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true LifecycleConfiguration: Rules: - Id: archive-old-reports Status: Enabled Transitions: - StorageClass: GLACIER_IR TransitionInDays: 90 Tags: - Key: Purpose Value: meal-order-reports - Key: ManagedBy Value: meal-order-manager # ─── CloudFront ──────────────────────────────────────────────── FormOAC: Type: AWS::CloudFront::OriginAccessControl Properties: OriginAccessControlConfig: Name: meal-order-manager-oac OriginAccessControlOriginType: s3 SigningBehavior: always SigningProtocol: sigv4 FormDistribution: Type: AWS::CloudFront::Distribution Properties: DistributionConfig: Enabled: true DefaultRootObject: index.html Comment: meal-order-manager form hosting PriceClass: PriceClass_100 HttpVersion: http2and3 Aliases: !If - HasCustomDomain - [!Ref CustomDomain] - !Ref AWS::NoValue ViewerCertificate: !If - HasCustomDomain - AcmCertificateArn: !Ref CertificateArn SslSupportMethod: sni-only MinimumProtocolVersion: TLSv1.2_2021 - CloudFrontDefaultCertificate: true Origins: - Id: S3FormOrigin DomainName: !GetAtt FormBucket.RegionalDomainName OriginAccessControlId: !Ref FormOAC S3OriginConfig: OriginAccessIdentity: '' DefaultCacheBehavior: TargetOriginId: S3FormOrigin ViewerProtocolPolicy: redirect-to-https CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled Compress: true AllowedMethods: - GET - HEAD CachedMethods: - GET - HEAD CustomErrorResponses: - ErrorCode: 403 ResponseCode: 200 ResponsePagePath: /index.html # ─── API Gateway ─────────────────────────────────────────────── OrderApi: Type: AWS::Serverless::HttpApi Properties: StageName: $default CorsConfiguration: AllowOrigins: - !If - HasCustomDomain - !Sub 'https://${CustomDomain}' - !Sub 'https://${FormDistribution.DomainName}' AllowMethods: - GET - POST - OPTIONS AllowHeaders: - Content-Type - x-api-key MaxAge: 3600 # ─── Lambda Functions ────────────────────────────────────────── SubmitOrderFunction: Type: AWS::Serverless::Function Properties: FunctionName: meal-order-manager-submit-order Handler: handler.lambda_handler CodeUri: functions/submit_order/ MemorySize: 128 Timeout: 10 Environment: Variables: FORM_API_KEY_SECRET: meal-order-manager/form-api-key Policies: - DynamoDBCrudPolicy: TableName: !Ref OrdersTable - Statement: - Effect: Allow Action: secretsmanager:GetSecretValue Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*' Events: SubmitOrder: Type: HttpApi Properties: ApiId: !Ref OrderApi Path: /api/submit-order Method: POST FormStatus: Type: HttpApi Properties: ApiId: !Ref OrderApi Path: /api/form-status/{week} Method: GET CloseFormFunction: Type: AWS::Serverless::Function Properties: FunctionName: meal-order-manager-close-form Handler: handler.lambda_handler CodeUri: functions/close_form/ MemorySize: 128 Timeout: 30 Policies: - DynamoDBCrudPolicy: TableName: !Ref OrdersTable - Statement: - Effect: Allow Action: lambda:InvokeFunction Resource: !GetAtt AggregateOrdersFunction.Arn Environment: Variables: AGGREGATE_FUNCTION_ARN: !GetAtt AggregateOrdersFunction.Arn Events: CloseEST: Type: Schedule Properties: Schedule: cron(0 23 ? * THU *) Description: 'Close form Thursday 6pm EST (23:00 UTC)' Enabled: true CloseEDT: Type: Schedule Properties: Schedule: cron(0 22 ? * THU *) Description: 'Close form Thursday 6pm EDT (22:00 UTC)' Enabled: true AggregateOrdersFunction: Type: AWS::Serverless::Function Properties: FunctionName: meal-order-manager-aggregate-orders Handler: handler.lambda_handler CodeUri: functions/aggregate_orders/ MemorySize: 256 Timeout: 60 Policies: - DynamoDBCrudPolicy: TableName: !Ref OrdersTable - S3CrudPolicy: BucketName: !Ref ReportsBucket - Statement: - Effect: Allow Action: lambda:InvokeFunction Resource: !GetAtt SlackNotifierFunction.Arn Environment: Variables: SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn SlackNotifierFunction: Type: AWS::Serverless::Function Properties: FunctionName: meal-order-manager-slack-notifier Handler: handler.lambda_handler CodeUri: functions/slack_notifier/ MemorySize: 128 Timeout: 30 Policies: - DynamoDBReadPolicy: TableName: !Ref OrdersTable - Statement: - Effect: Allow Action: secretsmanager:GetSecretValue Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*' - Effect: Allow Action: ssm:GetParameter Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*' Events: ReminderEST: Type: Schedule Properties: Schedule: cron(0 15 ? * THU *) Description: 'DM reminders Thursday 10am EST (15:00 UTC)' Enabled: true Input: '{"event": "reminder"}' ReminderEDT: Type: Schedule Properties: Schedule: cron(0 14 ? * THU *) Description: 'DM reminders Thursday 10am EDT (14:00 UTC)' Enabled: true Input: '{"event": "reminder"}' SyncRosterFunction: Type: AWS::Serverless::Function Properties: FunctionName: meal-order-manager-sync-roster Handler: handler.lambda_handler CodeUri: functions/sync_roster/ MemorySize: 128 Timeout: 60 Policies: - DynamoDBCrudPolicy: TableName: !Ref OrdersTable - Statement: - Effect: Allow Action: secretsmanager:GetSecretValue Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*' - Effect: Allow Action: ssm:GetParameter Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*' Events: SyncEST: Type: Schedule Properties: Schedule: cron(55 11 ? * MON *) Description: 'Sync roster Monday 6:55am EST (11:55 UTC) — before menu publish' Enabled: true SyncEDT: Type: Schedule Properties: Schedule: cron(55 10 ? * MON *) Description: 'Sync roster Monday 6:55am EDT (10:55 UTC) — before menu publish' Enabled: true EmailReportFunction: Type: AWS::Serverless::Function Properties: FunctionName: meal-order-manager-email-report Handler: handler.lambda_handler CodeUri: functions/email_report/ MemorySize: 128 Timeout: 30 Environment: Variables: PAYROLL_EMAIL: !Ref PayrollEmail SENDER_EMAIL: !Ref SenderEmail Policies: - DynamoDBReadPolicy: TableName: !Ref OrdersTable - S3ReadPolicy: BucketName: !Ref ReportsBucket - Statement: - Effect: Allow Action: - ses:SendRawEmail Resource: '*' Events: PayrollEmailEST: Type: Schedule Properties: Schedule: cron(0 12 ? * MON *) Description: 'Email payroll deductions Monday 7am EST (12:00 UTC)' Enabled: true PayrollEmailEDT: Type: Schedule Properties: Schedule: cron(0 11 ? * MON *) Description: 'Email payroll deductions Monday 7am EDT (11:00 UTC)' Enabled: true # ─── CloudWatch Log Groups (60-day retention) ────────────────── SubmitOrderLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub '/aws/lambda/${SubmitOrderFunction}' RetentionInDays: 60 CloseFormLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub '/aws/lambda/${CloseFormFunction}' RetentionInDays: 60 AggregateOrdersLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub '/aws/lambda/${AggregateOrdersFunction}' RetentionInDays: 60 SlackNotifierLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub '/aws/lambda/${SlackNotifierFunction}' RetentionInDays: 60 EmailReportLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub '/aws/lambda/${EmailReportFunction}' RetentionInDays: 60 SyncRosterLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub '/aws/lambda/${SyncRosterFunction}' RetentionInDays: 60 # ─── SSM Parameters ──────────────────────────────────────────── SlackChannelParam: Type: AWS::SSM::Parameter Properties: Name: /meal-order-manager/slack-channel-id Type: String Value: CHANGE_ME Description: Slack channel ID for meal order notifications Outputs: ApiUrl: Description: API Gateway endpoint URL Value: !Sub 'https://${OrderApi}.execute-api.${AWS::Region}.amazonaws.com' FormUrl: Description: Order form URL Value: !If - HasCustomDomain - !Sub 'https://${CustomDomain}' - !Sub 'https://${FormDistribution.DomainName}' DistributionId: Description: CloudFront distribution ID (for cache invalidation) Value: !Ref FormDistribution FormBucketName: Description: S3 bucket for form HTML Value: !Ref FormBucket ReportsBucketName: Description: S3 bucket for CSV reports Value: !Ref ReportsBucket OrdersTableName: Description: DynamoDB table name Value: !Ref OrdersTable SubmitOrderFunctionArn: Description: Submit Order Lambda ARN Value: !GetAtt SubmitOrderFunction.Arn CloseFormFunctionArn: Description: Close Form Lambda ARN Value: !GetAtt CloseFormFunction.Arn AggregateOrdersFunctionArn: Description: Aggregate Orders Lambda ARN Value: !GetAtt AggregateOrdersFunction.Arn SlackNotifierFunctionArn: Description: Slack Notifier Lambda ARN Value: !GetAtt SlackNotifierFunction.Arn SyncRosterFunctionArn: Description: Sync Roster Lambda ARN Value: !GetAtt SyncRosterFunction.Arn EmailReportFunctionArn: Description: Email Report Lambda ARN Value: !GetAtt EmailReportFunction.Arn