mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 07:43:13 +00:00
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:
parent
360a0435e8
commit
8935fc8f2d
6 changed files with 178 additions and 9 deletions
|
|
@ -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:
|
||||
|
|
|
|||
59
functions/sync_roster/handler.py
Normal file
59
functions/sync_roster/handler.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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
23
slack-app-manifest.yml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue