From 8935fc8f2d37e93e1d70492da9237ac185cdc9aa Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 19:21:47 -0400 Subject: [PATCH] Add roster sync Lambda, move API key to Secrets Manager, add Slack manifest (#3) - Add sync-roster Lambda that auto-syncs employee roster from Slack channel membership (runs Monday 6:55am ET before menu publish) - Move FormApiKey from CloudFormation parameter/env var to Secrets Manager (meal-order-manager/form-api-key) per security conventions - Add Slack app manifest with required bot scopes - Add get_channel_members() and get_user_info() to shared Slack module - Add Lambda function ARN outputs to CloudFormation - Add log group for sync-roster Lambda (60-day retention) --- functions/submit_order/handler.py | 12 +++++- functions/sync_roster/handler.py | 59 +++++++++++++++++++++++++++ samconfig.toml.example | 2 +- slack-app-manifest.yml | 23 +++++++++++ src/shared/shared/slack.py | 24 +++++++++++ template.yaml | 67 ++++++++++++++++++++++++++++--- 6 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 functions/sync_roster/handler.py create mode 100644 slack-app-manifest.yml diff --git a/functions/submit_order/handler.py b/functions/submit_order/handler.py index 21ecf00..06546fc 100644 --- a/functions/submit_order/handler.py +++ b/functions/submit_order/handler.py @@ -4,9 +4,17 @@ from datetime import datetime from zoneinfo import ZoneInfo from shared.db import current_week, get_form_status, put_order +from shared.secrets import get_secret EASTERN = ZoneInfo("America/New_York") -API_KEY = os.environ.get("FORM_API_KEY", "") +_api_key = None + + +def _get_api_key() -> str: + global _api_key + if _api_key is None: + _api_key = get_secret(os.environ["FORM_API_KEY_SECRET"]) + return _api_key def lambda_handler(event, context): @@ -30,7 +38,7 @@ def handle_form_status(event): def handle_submit(event): api_key = event.get("headers", {}).get("x-api-key", "") - if API_KEY and api_key != API_KEY: + if api_key != _get_api_key(): return response(403, {"error": "Invalid API key"}) try: diff --git a/functions/sync_roster/handler.py b/functions/sync_roster/handler.py new file mode 100644 index 0000000..2ef8ff6 --- /dev/null +++ b/functions/sync_roster/handler.py @@ -0,0 +1,59 @@ +import os + +from shared.db import get_roster, put_roster +from shared.slack import get_channel_members, get_user_info + + +def lambda_handler(event, context): + channel_id = os.environ.get("SLACK_CHANNEL_ID") or __import__("shared.secrets", fromlist=["get_parameter"]).get_parameter(os.environ["SLACK_CHANNEL_PARAM"]) + + member_ids = get_channel_members(channel_id) + if not member_ids: + return {"status": "error", "message": "No members found in channel"} + + employees = [] + skipped = 0 + for uid in member_ids: + info = get_user_info(uid) + if not info or info.get("is_bot") or info.get("id") == "USLACKBOT": + skipped += 1 + continue + + profile = info.get("profile", {}) + name = profile.get("real_name") or info.get("real_name", "") + email = profile.get("email", "") + + if not name or not email: + skipped += 1 + continue + + employees.append({ + "name": name, + "email": email, + "slack_user_id": uid, + }) + + employees.sort(key=lambda e: e["name"]) + + existing = get_roster() + existing_set = {(e["name"], e["email"], e["slack_user_id"]) for e in existing} + new_set = {(e["name"], e["email"], e["slack_user_id"]) for e in employees} + + if existing_set == new_set: + return { + "status": "no_change", + "employee_count": len(employees), + } + + put_roster(employees) + + added = new_set - existing_set + removed = existing_set - new_set + + return { + "status": "synced", + "employee_count": len(employees), + "added": len(added), + "removed": len(removed), + "skipped": skipped, + } diff --git a/samconfig.toml.example b/samconfig.toml.example index b54e806..788af9b 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.seahavenind.com CertificateArn=arn:aws:acm:us-east-1:328440206208:certificate/CHANGE-ME FormApiKey=CHANGE-ME PayrollEmail=payroll@seahavenind.com SenderEmail=adam@seahavenind.com" +parameter_overrides = "CustomDomain=orders.seahavenind.com CertificateArn=arn:aws:acm:us-east-1:328440206208:certificate/CHANGE-ME PayrollEmail=payroll@seahavenind.com SenderEmail=adam@seahavenind.com" diff --git a/slack-app-manifest.yml b/slack-app-manifest.yml new file mode 100644 index 0000000..1703268 --- /dev/null +++ b/slack-app-manifest.yml @@ -0,0 +1,23 @@ +display_information: + name: Meal Order Manager + description: Weekly meal ordering automation for Sea Haven Industries + background_color: "#1a5276" + long_description: | + Automates weekly bulk meal ordering from Redefine Meals. + Posts menu links, sends order reminders, and shares weekly summaries. + +features: + bot_user: + display_name: Meal Orders + always_online: true + +oauth_config: + scopes: + bot: + - channels:read + - chat:write + - files:write + - groups:read + - im:write + - users:read + - users:read.email diff --git a/src/shared/shared/slack.py b/src/shared/shared/slack.py index 7b824f1..546380d 100644 --- a/src/shared/shared/slack.py +++ b/src/shared/shared/slack.py @@ -60,3 +60,27 @@ def upload_file(channel: str | None, filename: str, content: str, title: str = " "title": title or filename, } return _api_call("files.upload", payload) + + +def get_channel_members(channel_id: str) -> list[str]: + members = [] + cursor = None + while True: + payload = {"channel": channel_id, "limit": 200} + if cursor: + payload["cursor"] = cursor + resp = _api_call("conversations.members", payload) + if not resp.get("ok"): + break + members.extend(resp.get("members", [])) + cursor = resp.get("response_metadata", {}).get("next_cursor") + if not cursor: + break + return members + + +def get_user_info(user_id: str) -> dict | None: + resp = _api_call("users.info", {"user": user_id}) + if resp.get("ok"): + return resp["user"] + return None diff --git a/template.yaml b/template.yaml index 34ca11c..26d20ac 100644 --- a/template.yaml +++ b/template.yaml @@ -21,11 +21,6 @@ Parameters: Type: String Default: adam@seahavenind.com Description: SES verified sender email for payroll reports - FormApiKey: - Type: String - NoEcho: true - Description: API key embedded in the order form for submission auth - Conditions: HasCustomDomain: !Not [!Equals [!Ref CertificateArn, '']] @@ -234,10 +229,14 @@ Resources: Timeout: 10 Environment: Variables: - FORM_API_KEY: !Ref FormApiKey + 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 @@ -339,6 +338,38 @@ Resources: 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: @@ -407,6 +438,12 @@ Resources: LogGroupName: !Sub '/aws/lambda/${EmailReportFunction}' RetentionInDays: 60 + SyncRosterLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub '/aws/lambda/${SyncRosterFunction}' + RetentionInDays: 60 + # ─── SSM Parameters ──────────────────────────────────────────── SlackChannelParam: @@ -439,3 +476,21 @@ Outputs: 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