diff --git a/README.md b/README.md index c6d0860..5259a80 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,15 @@ Monday 7:30am ET Employees (Mon–Thu) Thursda │ - Slack notify │ └──────────────────┘ │ │ - Slack summary │ └─────────────────┘ ▼ └──────────────────┘ ┌──────────┐ -Monday 7am ET │ API GW + │ -┌──────────────────┐ │ Lambda │ -│ EventBridge │ │ submit │ -│ - Email payroll │ └────┬─────┘ -│ deductions │ ▼ -└──────────────────┘ ┌──────────┐ +Thu 10am: Slack DM │ API GW + │ +reminders to employees │ Lambda │ +who haven't ordered │ submit │ + └────┬─────┘ + ▼ + ┌──────────┐ │ DynamoDB │ -Thu 10am: Slack DM │ orders │ -reminders to employees └──────────┘ -who haven't ordered + │ orders │ + └──────────┘ ``` ### Form frontend decisions @@ -44,7 +43,6 @@ the generated HTML, and the generated deployment artifact remains self-contained | When | What | How | |------|------|-----| | Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge → Lambda → DynamoDB | -| Monday 7am ET | Email previous week's payroll deductions to `payroll@` | EventBridge → Lambda → SES | | Monday 7:30am ET | Scrape menu, generate form, upload to S3, post link to Slack | GitHub Actions cron | | Mon–Thu | Employees visit `orders.seahaven.com` and submit orders | S3 static form → API Gateway → Lambda → DynamoDB | | Thursday 10am ET | DM employees who haven't ordered yet | EventBridge → Lambda → Slack DM | @@ -85,11 +83,10 @@ Stack name: `meal-order-manager` (us-east-1) - **CloudFront** — HTTPS distribution with custom domain `orders.seahaven.com` - **DynamoDB** — `meal-order-manager-orders` (orders, menu, roster, config) - **API Gateway** — HttpApi for order submission and admin operations -- **Lambda** — 7 functions: submit-order, admin-authorizer, close-form, aggregate-orders, slack-notifier, sync-roster, email-report -- **EventBridge** — scheduled rules (dual EST/EDT) for close, reminders, payroll email +- **Lambda** — 6 functions: submit-order, admin-authorizer, close-form, aggregate-orders, slack-notifier, sync-roster +- **EventBridge** — scheduled rules (dual EST/EDT) for close, reminders, roster sync - **Secrets Manager** — Slack bot token - Migration note: the existing `meal-order-manager/form-api-key` secret remains until this change is deployed and verified, then must be deleted during post-deploy cleanup. -- **SES** — payroll deduction emails - **CloudWatch Alarms** — coverage across the stack, all notifying the shared `site-alerts` SNS topic (see Monitoring) ## Monitoring @@ -99,7 +96,7 @@ CloudWatch alarms are defined in `template.yaml`. Every alarm sends to the share OKActions, and treats missing data as not breaching (so idle/cron functions don't sit in ALARM between runs). Alarm names follow `meal-order-manager--`. -- **Lambda Errors / Throttles** — one alarm each per function (7 functions), Sum +- **Lambda Errors / Throttles** — one alarm each per function (6 functions), Sum over 5 min, fires on any error/throttle (threshold 0). - **Lambda Duration** — p99 over 5 min at ~80% of each function's timeout. API-fronted functions (submit-order, admin-authorizer) evaluate 3/3 datapoints; @@ -168,9 +165,8 @@ sam deploy 1. Create the Slack bot token secret: `aws secretsmanager create-secret --name meal-order-manager/slack-bot-token --secret-string "xoxb-..."` 2. Set the Google OAuth client ID: `aws ssm put-parameter --name /meal-order-manager/google-client-id --type String --value "" --overwrite` 3. Update the Slack channel SSM parameter: `aws ssm put-parameter --name /meal-order-manager/slack-channel-id --value "C0XXXXXXX" --overwrite` -4. Verify SES sender identity for `adam@seahavenind.com` -5. Set up DNS: CNAME `orders.seahaven.com` → CloudFront distribution domain -6. Roster syncs automatically from Slack channel members (runs Monday 6:55am ET), or seed manually: `python3 scripts/seed_roster.py` +4. Set up DNS: CNAME `orders.seahaven.com` → CloudFront distribution domain +5. Roster syncs automatically from Slack channel members (runs Monday 6:55am ET), or seed manually: `python3 scripts/seed_roster.py` ## Local Workflow (no AWS) @@ -210,8 +206,7 @@ meal-order-manager/ │ ├── close_form/ │ ├── aggregate_orders/ │ ├── slack_notifier/ -│ ├── sync_roster/ -│ └── email_report/ +│ └── sync_roster/ ├── scripts/ # CI/CD helper scripts │ ├── upload_menu.py │ ├── notify_slack.py diff --git a/functions/email_report/handler.py b/functions/email_report/handler.py deleted file mode 100644 index 5fd7f7e..0000000 --- a/functions/email_report/handler.py +++ /dev/null @@ -1,58 +0,0 @@ -import os -from email.mime.application import MIMEApplication -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText - -import boto3 - -from shared.db import get_summary, previous_week - -_ses = boto3.client("ses") -_s3 = boto3.client("s3") - - -def lambda_handler(event, context): - week = event.get("week", previous_week()) - - summary = get_summary(week) - if not summary: - return {"status": "no_summary", "week": week} - - payroll_key = summary.get("payroll_csv_s3_key") - if not payroll_key: - return {"status": "no_payroll_csv", "week": week} - - bucket = os.environ["REPORTS_BUCKET"] - csv_obj = _s3.get_object(Bucket=bucket, Key=payroll_key) - csv_content = csv_obj["Body"].read() - - total_employees = int(summary.get("total_employees", 0)) - grand_total = float(summary.get("grand_total", 0)) - - msg = MIMEMultipart("mixed") - msg["Subject"] = f"Meal Order Payroll Deductions — {week}" - msg["From"] = os.environ["SENDER_EMAIL"] - msg["To"] = os.environ["PAYROLL_EMAIL"] - - body = MIMEText( - f"Attached is the meal order payroll deduction report for {week}.\n\n" - f"Employees: {total_employees}\n" - f"Total deductions: ${grand_total:.2f}\n\n" - f"Please apply these deductions in the next pay period.\n", - "plain", - ) - msg.attach(body) - - attachment = MIMEApplication(csv_content) - attachment.add_header( - "Content-Disposition", "attachment", filename=f"payroll-deductions-{week}.csv" - ) - msg.attach(attachment) - - _ses.send_raw_email( - Source=os.environ["SENDER_EMAIL"], - Destinations=[os.environ["PAYROLL_EMAIL"]], - RawMessage={"Data": msg.as_string()}, - ) - - return {"status": "sent", "week": week, "to": os.environ["PAYROLL_EMAIL"]} diff --git a/functions/email_report/requirements.txt b/functions/email_report/requirements.txt deleted file mode 100644 index 348b557..0000000 --- a/functions/email_report/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -boto3==1.43.78 diff --git a/samconfig.toml.example b/samconfig.toml.example index 328f48c..75d7b60 100644 --- a/samconfig.toml.example +++ b/samconfig.toml.example @@ -7,4 +7,4 @@ s3_prefix = "meal-order-manager" region = "us-east-1" confirm_changeset = true capabilities = "CAPABILITY_IAM" -parameter_overrides = "CustomDomain=orders.seahaven.com CertificateArn=arn:aws:acm:us-east-1:328440206208:certificate/CHANGE-ME PayrollEmail=payroll@seahavenind.com SenderEmail=adam@seahavenind.com" +parameter_overrides = "CustomDomain=orders.seahaven.com CertificateArn=arn:aws:acm:us-east-1:328440206208:certificate/CHANGE-ME" diff --git a/template.yaml b/template.yaml index b2f5737..19ae498 100644 --- a/template.yaml +++ b/template.yaml @@ -13,14 +13,6 @@ Parameters: 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: @@ -543,42 +535,6 @@ Resources: 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: @@ -605,12 +561,6 @@ Resources: 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: @@ -744,25 +694,6 @@ Resources: AlarmActions: - arn:aws:sns:us-east-1:328440206208:site-alerts - EmailReportErrorsAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-email-report-errors - AlarmDescription: email-report Lambda reported one or more errors in 5 minutes. - Namespace: AWS/Lambda - MetricName: Errors - Dimensions: - - Name: FunctionName - Value: !Ref EmailReportFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - # Lambda Throttles (Sum, 5min, any throttle breaches) SubmitOrderThrottlesAlarm: Type: AWS::CloudWatch::Alarm @@ -878,25 +809,6 @@ Resources: AlarmActions: - arn:aws:sns:us-east-1:328440206208:site-alerts - EmailReportThrottlesAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-email-report-throttles - AlarmDescription: email-report Lambda was throttled in the last 5 minutes. - Namespace: AWS/Lambda - MetricName: Throttles - Dimensions: - - Name: FunctionName - Value: !Ref EmailReportFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - # Lambda Duration p99 (~80% of timeout) # Synchronous (API-fronted) functions: eval 3 / datapoints 3. SubmitOrderDurationAlarm: @@ -1020,26 +932,6 @@ Resources: AlarmActions: - arn:aws:sns:us-east-1:328440206208:site-alerts - EmailReportDurationAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-email-report-duration - AlarmDescription: email-report p99 duration exceeded 24000ms (80% of 30s timeout). - Namespace: AWS/Lambda - MetricName: Duration - Dimensions: - - Name: FunctionName - Value: !Ref EmailReportFunction - ExtendedStatistic: p99 - Period: 300 - EvaluationPeriods: 1 - DatapointsToAlarm: 1 - Threshold: 24000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - # DynamoDB orders table. # NOTE: DynamoDB does NOT publish ThrottledRequests or SystemErrors at the # TableName-only dimension (verified via cloudwatch list-metrics on @@ -1202,6 +1094,3 @@ Outputs: SyncRosterFunctionArn: Description: Sync Roster Lambda ARN Value: !GetAtt SyncRosterFunction.Arn - EmailReportFunctionArn: - Description: Email Report Lambda ARN - Value: !GetAtt EmailReportFunction.Arn diff --git a/terraform/alarms.tf b/terraform/alarms.tf index ca0015c..4541885 100644 --- a/terraform/alarms.tf +++ b/terraform/alarms.tf @@ -49,12 +49,6 @@ locals { duration_timeout = "60s" duration_datapoints = 1 } - "email-report" = { - function_name = aws_lambda_function.email_report.function_name - duration_threshold = 24000 - duration_timeout = "30s" - duration_datapoints = 1 - } } } diff --git a/terraform/build_packages.sh b/terraform/build_packages.sh index ed7d2f8..d5b2540 100755 --- a/terraform/build_packages.sh +++ b/terraform/build_packages.sh @@ -13,7 +13,6 @@ FUNCTIONS=( admin_authorizer aggregate_orders close_form - email_report slack_notifier submit_order sync_roster diff --git a/terraform/events.tf b/terraform/events.tf index 12c3d0e..4c1abd5 100644 --- a/terraform/events.tf +++ b/terraform/events.tf @@ -53,20 +53,6 @@ locals { function_name = aws_lambda_function.sync_roster.function_name input = null } - "payroll-email-est" = { - description = "Email payroll deductions Monday 7am EST (12:00 UTC)" - schedule = "cron(0 12 ? * MON *)" - function_arn = aws_lambda_function.email_report.arn - function_name = aws_lambda_function.email_report.function_name - input = null - } - "payroll-email-edt" = { - description = "Email payroll deductions Monday 7am EDT (11:00 UTC)" - schedule = "cron(0 11 ? * MON *)" - function_arn = aws_lambda_function.email_report.arn - function_name = aws_lambda_function.email_report.function_name - input = null - } } } diff --git a/terraform/iam.tf b/terraform/iam.tf index 0b59926..a40d6a3 100644 --- a/terraform/iam.tf +++ b/terraform/iam.tf @@ -1,4 +1,4 @@ -# Execution roles for the seven Lambda functions plus the API Gateway role that +# Execution roles for the six Lambda functions plus the API Gateway role that # invokes the admin authorizer. # # Every Lambda execution role is created under the /tf-managed/ path and @@ -325,55 +325,3 @@ resource "aws_iam_role_policy" "sync_roster" { role = aws_iam_role.sync_roster.id policy = data.aws_iam_policy_document.sync_roster.json } - -# --------------------------------------------------------------------------- -# email-report -# --------------------------------------------------------------------------- - -resource "aws_iam_role" "email_report" { - name = "${local.project}-email-report" - path = "/tf-managed/" - assume_role_policy = data.aws_iam_policy_document.lambda_assume.json - permissions_boundary = local.boundary_arn -} - -resource "aws_iam_role_policy_attachment" "email_report_basic" { - role = aws_iam_role.email_report.name - policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" -} - -data "aws_iam_policy_document" "email_report" { - source_policy_documents = [data.aws_iam_policy_document.dynamodb_read.json] - - statement { - sid = "ReportsBucketRead" - effect = "Allow" - actions = ["s3:GetObject", "s3:GetObjectVersion"] - resources = ["${aws_s3_bucket.reports.arn}/*"] - } - - statement { - sid = "ReportsBucketList" - effect = "Allow" - actions = ["s3:GetBucketLocation", "s3:ListBucket"] - resources = [aws_s3_bucket.reports.arn] - } - - # Scope SendRawEmail to the verified sender domain/identity rather than "*". - # SES still enforces verification; IAM pins the From identity ARNs. - statement { - sid = "SendPayrollReport" - effect = "Allow" - actions = ["ses:SendRawEmail"] - resources = [ - "arn:aws:ses:${var.aws_region}:${local.account_id}:identity/${var.sender_email}", - "arn:aws:ses:${var.aws_region}:${local.account_id}:identity/seahavenind.com", - ] - } -} - -resource "aws_iam_role_policy" "email_report" { - name = "email-report" - role = aws_iam_role.email_report.id - policy = data.aws_iam_policy_document.email_report.json -} diff --git a/terraform/lambda.tf b/terraform/lambda.tf index 1341325..ad20762 100644 --- a/terraform/lambda.tf +++ b/terraform/lambda.tf @@ -1,4 +1,4 @@ -# The shared layer and the seven functions. +# The shared layer and the six functions. # # Packages come from the artifacts bucket (see artifacts.tf). Function packages # contain the handler only: boto3 comes from the runtime, and fpdf2 plus the @@ -205,36 +205,3 @@ resource "aws_lambda_function" "sync_roster" { aws_iam_role_policy_attachment.sync_roster_basic, ] } - -# --------------------------------------------------------------------------- -# email-report — emails the weekly payroll deduction report -# --------------------------------------------------------------------------- - -resource "aws_lambda_function" "email_report" { - function_name = "${local.project}-email-report" - role = aws_iam_role.email_report.arn - handler = "handler.lambda_handler" - runtime = "python3.12" - architectures = ["arm64"] - memory_size = 128 - timeout = 30 - - s3_bucket = aws_s3_bucket.artifacts.id - s3_key = aws_s3_object.function["email_report"].key - source_code_hash = data.archive_file.function["email_report"].output_base64sha256 - - layers = [aws_lambda_layer_version.shared.arn] - - environment { - variables = merge(local.common_env, { - PAYROLL_EMAIL = var.payroll_email - SENDER_EMAIL = var.sender_email - }) - } - - depends_on = [ - aws_cloudwatch_log_group.function, - aws_iam_role_policy.email_report, - aws_iam_role_policy_attachment.email_report_basic, - ] -} diff --git a/terraform/locals.tf b/terraform/locals.tf index bfa22f1..6c431fc 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -34,7 +34,6 @@ locals { "admin_authorizer", "aggregate_orders", "close_form", - "email_report", "slack_notifier", "submit_order", "sync_roster", diff --git a/terraform/outputs.tf b/terraform/outputs.tf index b73fa21..1b901d7 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -54,7 +54,6 @@ output "function_arns" { admin_authorizer = aws_lambda_function.admin_authorizer.arn aggregate_orders = aws_lambda_function.aggregate_orders.arn close_form = aws_lambda_function.close_form.arn - email_report = aws_lambda_function.email_report.arn slack_notifier = aws_lambda_function.slack_notifier.arn submit_order = aws_lambda_function.submit_order.arn sync_roster = aws_lambda_function.sync_roster.arn diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index be3b5f9..876dc01 100644 --- a/terraform/terraform.tfvars.example +++ b/terraform/terraform.tfvars.example @@ -9,10 +9,6 @@ aws_region = "us-east-1" domain_name = "orders.seahaven.com" attach_custom_domain = false -# Payroll deduction report recipient and SES-verified sender. -payroll_email = "payroll@seahavenind.com" -sender_email = "adam@seahavenind.com" - # ARN of the out-of-band Secrets Manager secret holding the Slack bot token. # Only the ARN is used; the value never enters Terraform state. slack_bot_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:secret:meal-order-manager/slack-bot-token-XXXXXX" diff --git a/terraform/variables.tf b/terraform/variables.tf index 7258c41..c53c3ef 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -16,18 +16,6 @@ variable "attach_custom_domain" { default = false } -variable "payroll_email" { - description = "Recipient of the weekly payroll deduction report." - type = string - default = "payroll@seahavenind.com" -} - -variable "sender_email" { - description = "SES-verified From address for the payroll deduction report." - type = string - default = "adam@seahavenind.com" -} - variable "slack_bot_secret_arn" { description = "ARN of the Secrets Manager secret holding the Slack bot token. The secret and its value are managed out-of-band; only the ARN enters this configuration." type = string diff --git a/tests/test_terraform_email_report.py b/tests/test_terraform_email_report.py new file mode 100644 index 0000000..98fe3bd --- /dev/null +++ b/tests/test_terraform_email_report.py @@ -0,0 +1,52 @@ +"""email_report is removed from Terraform, SAM, and the functions tree (PLAT-135).""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TERRAFORM = ROOT / "terraform" + + +def _read(name: str) -> str: + return (TERRAFORM / name).read_text() + + +def test_email_report_handler_removed(): + assert not (ROOT / "functions" / "email_report").exists() + + +def test_email_report_not_in_function_packages(): + locals_tf = _read("locals.tf") + assert '"email_report"' not in locals_tf + packages_sh = _read("build_packages.sh") + assert "email_report" not in packages_sh + + +def test_payroll_email_schedules_removed(): + events = _read("events.tf") + assert "payroll-email-est" not in events + assert "payroll-email-edt" not in events + assert "email_report" not in events + + +def test_email_report_lambda_and_role_removed(): + lambda_tf = _read("lambda.tf") + iam_tf = _read("iam.tf") + alarms = _read("alarms.tf") + outputs = _read("outputs.tf") + variables = _read("variables.tf") + assert "aws_lambda_function.email_report" not in lambda_tf + assert "aws_iam_role.email_report" not in iam_tf + assert "ses:SendRawEmail" not in iam_tf + assert "email-report" not in alarms + assert "email_report" not in outputs + assert "payroll_email" not in variables + assert "sender_email" not in variables + + +def test_email_report_removed_from_sam_template(): + template = (ROOT / "template.yaml").read_text() + assert "EmailReportFunction" not in template + assert "functions/email_report/" not in template + assert "PayrollEmail" not in template + assert "SenderEmail" not in template + assert "ses:SendRawEmail" not in template