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)
This commit is contained in:
Adam Moussa 2026-05-12 19:21:47 -04:00 • committed by GitHub
parent 360a0435e8
commit 8935fc8f2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 178 additions and 9 deletions

View file

@ -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:

View file

@ -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,
}

View file

@ -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"

23
slack-app-manifest.yml Normal file
View file

@ -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

View file

@ -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

View file

@ -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