meal-order-manager/template.yaml
Adam Moussa a752c24e0f
Some checks failed
Deploy / deploy (push) Has been cancelled
Add discount pricing, Google auth, and order hardening (#10)
* Add discount settings and two-tier pricing to order aggregation

Settings CRUD (get_settings/put_settings) for DynamoDB CONFIG#SETTINGS item.
Aggregation now tracks bulk_price and employee_price separately, with
grand_total (company cost) and employee_total (payroll deductions).

* Add Google OAuth, server-side discounts, and Slack order confirmations

Submit order Lambda now verifies Google ID tokens via tokeninfo endpoint,
calculates two-tier discount pricing server-side, and async-invokes the
Slack notifier for per-employee order confirmation DMs. Deadlines updated
to Thursday 11:59pm across all Slack messages.

* Update SAM template for Google auth, Slack invocation, and deadline change

Add SLACK_NOTIFIER_ARN and GOOGLE_CLIENT_ID_PARAM env vars to submit order
function with lambda:InvokeFunction policy. Move close-form schedule to
Thursday 11:59pm EST/EDT.

* Update order form UI and CI workflow for new features

Form now shows discount pricing, responsive grid layout, Google Sign-In
overlay, and closed-orders page with countdown timer. CI workflow fetches
discount settings from DynamoDB and Google Client ID from SSM.

* Add SSM GetParameter permission to submit order Lambda

Required for reading the Google Client ID from Parameter Store
at /meal-order-manager/google-client-id.

* Harden auth, pricing, and reliability in order handlers

Enforce Google auth when configured (reject missing tokens with 403),
return 503 on token verification outages, switch to Decimal with
ROUND_HALF_UP for financial precision, clamp discount bounds 0-100,
use email-based slugs, add 5-min cache TTL with time.monotonic(),
wrap Slack invocation in try/except, add reopen_at timestamp to
closed form status, add reminder dedup guards for dual EST/EDT crons,
escape Slack mrkdwn special characters, and handle empty employee names.

* Fix XSS risks and add closed-form UX to order page

Add escapeHtml() for all scraped content in innerHTML, fix script
injection via </script> in JSON, fix JWT base64url decoding, match
backend two-step rounding in JS employeePrice(), disable qty buttons
and submit when form is closed, add server-driven countdown from
reopen_at, add duplicate order warning via localStorage, add back
button after submission, embed favicon, use :g format for fractional
discounts, and exclude dead loadRoster code when Google auth enabled.

* Document CORS, cron idempotency, and SSM config in template

Add comments explaining CORS dev server strategy, dual EST/EDT cron
idempotency, and manual SSM parameter creation for Google Client ID.

* Add unit tests for submit, notify, and aggregate handlers

50 tests covering pricing pipeline (Decimal rounding, clamping, totals),
Google auth (enforcement, bypass prevention, audience/domain validation,
503 on outage), email slug generation, form status with reopen_at,
input validation, Slack failure resilience, reminder dedup guards,
order confirmation DMs, aggregated summaries, CSV generation, and
mrkdwn escaping.

* Use full email as order slug for defense-in-depth

Replace email-prefix slug with full lowercase email to eliminate any
possibility of cross-domain collisions, per senior review sign-off.

* Remove unused imports flagged by ruff

* Apply ruff formatting

* Fix PR review findings: auth, rounding, and close-form guard

- Remove dead elif branch in submit_order auth (always returned 403)
- Catch HTTPError before URLError so expired tokens return 403 not 503
- Wrap SSM get_parameter in try/except for fresh deployments
- Add wall-clock guard to close_form handler (Friday >= 11 PM ET)
- Add epsilon nudge to JS employeePrice for IEEE 754 boundary match
- Switch Flask dev server from round() to Decimal ROUND_HALF_UP
- Add tests for HTTPError handling and close_form guard (6 new tests)

* Fix close-form weekday guard and SSM auth fail-open

- Close form guard: check weekday == 3 (Thursday), not 4 (Friday) — the
  crons fire at Thursday 11:59 PM ET, when weekday() is 3
- SSM fail-closed: separate _google_auth_configured() (checks env var) from
  _get_google_client_id() (fetches value). If auth is configured but the SSM
  fetch fails, return 503 instead of silently falling back to manual auth
- Update close_form tests to use Thursday dates
- Add test_ssm_failure_fails_closed

* Harden Flask dev server auth and escaping

- Add hosted domain check to _verify_google_token (mirror Lambda)
- Gate auth on config (client_id presence), not request body — prevents
  bypass by omitting google_id_token when auth is configured
- Add discount percentage clamping to match Lambda handler
- Add </script> escaping to google_client_id_json

* fix: Email order filenames, SSM param TTL, DST-safe reopen_at

- Flask dev server: persist orders under lowercase email slug (match Lambda/Dynamo)
- shared.secrets: split secret vs SSM caches; expire get_parameter entries every 5 minutes
- form-status reopen_at: calendar Monday + datetime.combine for 8am ET (not 24h timedelta)
- Add _eastern_now() for testability; tests for SSM TTL and DST weekend edge case

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* Apply ruff formatting to submit_order handler

* fix(server): retry SSM for Google client id after TTL on failure

Transient SSM errors no longer cache empty client id for the process lifetime;
matches Lambda handler refresh behavior (300s TTL).

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(server): ruff-format Google client id cache helper

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(close-form): accept Fri 00–03 ET catch-up after Thu close cron

EventBridge can deliver past midnight ET; widen the wall-clock guard so a
delayed Thursday 23:59 UTC cron still closes the form. Idempotent when already
closed. Adds test for early Friday; past-window skip now starts Fri 04:00 ET.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(submit-order): bill from Dynamo menu retail, not client JSON

Load authoritative meal prices from get_menu(week); reject unknown meal names
and return 503 when the menu has no priced meals. Use meal_name in the pricing
loop to avoid shadowing the employee name. Adds regression tests for tampering,
unknown meals, and empty menu meals.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix: use single braces in loadRoster JS nested string

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Eastern fallback countdown

* Fix pricing validation and JWT display decoding

* Fix optional Google auth detection

* Format app.py line length for ruff compliance

* Fix auth config check and URL escaping in form

- _google_auth_configured() now checks env var presence (intent), not
  the fetched SSM value — prevents silent auth bypass if SSM param is
  deleted
- Add </script> escaping to URL values in generate_form.py for
  consistency with other injected values

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-05-13 18:00:21 -04:00

519 lines
17 KiB
YAML

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
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
# 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
- 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
SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn
GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id
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/*'
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
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 "<YOUR_GOOGLE_CLIENT_ID>"
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