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.seahaven.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 # Shared CloudFront WAF WebACL ARN (audit M-17), published to SSM by # seahaven-account-baseline. Resolved at deploy time. WebAclArn: Type: AWS::SSM::Parameter::Value Default: /seahaven/waf/app-web-acl-arn Description: ARN of the shared seahaven-app-waf CloudFront WebACL 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_CHANNEL_PARAM: /meal-order-manager/slack-channel-id FORM_URL: !If - HasCustomDomain - !Sub 'https://${CustomDomain}' - !Sub 'https://${FormDistribution.DomainName}' SLACK_BOT_SM_NAME: meal-order-manager/slack-bot-token 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 WebACLId: !Ref WebAclArn # shared CloudFront WAF (audit M-17) 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 ─────────────────────────────────────────────── ApiAccessLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: /aws/apigateway/meal-order-manager RetentionInDays: 90 OrderApi: Type: AWS::Serverless::HttpApi Properties: StageName: $default # Gateway-level auth for /api/admin/* routes (INFRA-100). A Lambda # authorizer validates the same Google ID token (Authorization: Bearer) # the admin panel already sends, so admin routes are no longer # AuthorizationType NONE. Public routes (submit-order, form-status, # roster) stay open — they're explicitly set to NONE on their events. Auth: Authorizers: AdminGoogleAuthorizer: FunctionArn: !GetAtt AdminAuthorizerFunction.Arn FunctionInvokeRole: !GetAtt AdminAuthorizerInvokeRole.Arn Identity: Headers: - Authorization AuthorizerPayloadFormatVersion: '2.0' EnableSimpleResponses: true # Disable result caching: with caching, an expired Google token or # an admin removed from admin_emails would stay authorized for the # cache TTL. The tokeninfo call is the dominant latency anyway. AuthorizerResultTtlInSeconds: 0 # No DefaultAuthorizer — routes opt in individually so the public # routes remain unauthenticated. # Access logging + default throttling (audit M-18). AccessLogSettings: DestinationArn: !GetAtt ApiAccessLogGroup.Arn Format: '{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","requestTime":"$context.requestTime","method":"$context.httpMethod","routeKey":"$context.routeKey","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength","integrationError":"$context.integrationErrorMessage"}' DefaultRouteSettings: ThrottlingBurstLimit: 50 ThrottlingRateLimit: 100 # CORS only allows the production domain. For local development, use the # Flask dev server (app.py) which proxies API requests and doesn't enforce CORS. CorsConfiguration: AllowOrigins: - !If - HasCustomDomain - !Sub 'https://${CustomDomain}' - !Sub 'https://${FormDistribution.DomainName}' AllowMethods: - GET - POST - PUT - DELETE - OPTIONS AllowHeaders: - Content-Type - x-api-key - Authorization 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_APIKEY_SM_NAME: meal-order-manager/form-api-key SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id REPORTS_BUCKET: !Ref ReportsBucket 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: lambda:InvokeFunction Resource: !GetAtt SlackNotifierFunction.Arn - Effect: Allow Action: ssm:GetParameter Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*' # Read-only access to weekly summary PDFs (only — not the # payroll/order CSVs) for the admin summary-pdf presigned-URL # endpoint. - Effect: Allow Action: s3:GetObject Resource: !Sub '${ReportsBucket.Arn}/reports/*/weekly-summary-*.pdf' 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 Roster: Type: HttpApi Properties: ApiId: !Ref OrderApi Path: /api/roster Method: GET AdminOrders: Type: HttpApi Properties: ApiId: !Ref OrderApi Path: /api/admin/orders Method: GET Auth: Authorizer: AdminGoogleAuthorizer AdminOrdersUpdate: Type: HttpApi Properties: ApiId: !Ref OrderApi Path: /api/admin/orders Method: PUT Auth: Authorizer: AdminGoogleAuthorizer AdminOrdersDelete: Type: HttpApi Properties: ApiId: !Ref OrderApi Path: /api/admin/orders Method: DELETE Auth: Authorizer: AdminGoogleAuthorizer AdminSummaryPdf: Type: HttpApi Properties: ApiId: !Ref OrderApi Path: /api/admin/summary-pdf Method: GET Auth: Authorizer: AdminGoogleAuthorizer # ─── Admin API Authorizer (INFRA-100) ───────────────────────── # Lambda authorizer validating the Google ID token + admin-email allow-list # for every /api/admin/* route. Mirrors submit_order's _verify_admin so the # existing admin panel works unchanged. AdminAuthorizerFunction: Type: AWS::Serverless::Function Properties: FunctionName: meal-order-manager-admin-authorizer Handler: handler.lambda_handler CodeUri: functions/admin_authorizer/ MemorySize: 128 Timeout: 10 Environment: Variables: GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id Policies: - DynamoDBReadPolicy: TableName: !Ref OrdersTable - Statement: - Effect: Allow Action: ssm:GetParameter Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*' AdminAuthorizerLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub '/aws/lambda/${AdminAuthorizerFunction}' RetentionInDays: 60 # IAM role API Gateway assumes to invoke the authorizer Lambda. AdminAuthorizerInvokeRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: apigateway.amazonaws.com Action: sts:AssumeRole Policies: - PolicyName: invoke-admin-authorizer PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: lambda:InvokeFunction Resource: !GetAtt AdminAuthorizerFunction.Arn 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 # Both EST and EDT schedules fire every week year-round. The handler is # idempotent, so the "wrong timezone" firing is a harmless no-op. Events: CloseEST: Type: Schedule Properties: Schedule: cron(59 4 ? * FRI *) Description: 'Close form Thursday 11:59pm EST (04:59 UTC Friday)' Enabled: true CloseEDT: Type: Schedule Properties: Schedule: cron(59 3 ? * FRI *) Description: 'Close form Thursday 11:59pm EDT (03:59 UTC Friday)' 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 # GoogleClientIdParam (/meal-order-manager/google-client-id) is managed # manually via AWS CLI since it varies per environment. Create it with: # aws ssm put-parameter --name /meal-order-manager/google-client-id \ # --type String --value "" 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