From 3de9243f6b9072b940a15f3a765f14ea93eb06ed Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 15:36:22 -0400 Subject: [PATCH 01/20] Add arm64, log retention, and compliance fixes - Set arm64 architecture globally for all Lambda functions - Add explicit CloudWatch log groups with 60-day retention - Add missing WeeklyPostFunctionArn to stack outputs - Add Dependabot assignees for both ecosystems - Add samconfig.toml.example for onboarding --- .github/dependabot.yml | 4 ++++ samconfig.toml.example | 9 +++++++++ template.yaml | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 samconfig.toml.example diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b6b02e7..825c361 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,8 @@ updates: directory: "/" schedule: interval: "weekly" + assignees: + - "amoussa1229" groups: minor-and-patch: update-types: @@ -13,6 +15,8 @@ updates: directory: "/" schedule: interval: "weekly" + assignees: + - "amoussa1229" groups: minor-and-patch: update-types: diff --git a/samconfig.toml.example b/samconfig.toml.example new file mode 100644 index 0000000..daba8cd --- /dev/null +++ b/samconfig.toml.example @@ -0,0 +1,9 @@ +version = 0.1 + +[default.deploy.parameters] +stack_name = "afterhours-shift-manager" +resolve_s3 = true +s3_prefix = "afterhours-shift-manager" +region = "us-east-1" +capabilities = "CAPABILITY_IAM" +confirm_changeset = true diff --git a/template.yaml b/template.yaml index feaa664..5e7d2c3 100644 --- a/template.yaml +++ b/template.yaml @@ -16,6 +16,8 @@ Globals: Runtime: python3.12 Timeout: 30 MemorySize: 1024 + Architectures: + - arm64 Resources: # --- DynamoDB --- @@ -181,6 +183,25 @@ Resources: Description: "Sync roster from 3CX at 6am EDT" Enabled: true + # --- CloudWatch Log Groups (explicit 60-day retention) --- + SlackBotLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/lambda/${SlackBotFunction}" + RetentionInDays: 60 + + WeeklyPostLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/lambda/${WeeklyPostFunction}" + RetentionInDays: 60 + + RosterSyncLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/lambda/${RosterSyncFunction}" + RetentionInDays: 60 + Outputs: SlackBotApiUrl: Description: URL for Slack app Request URL configuration @@ -189,5 +210,7 @@ Outputs: Value: !Ref ShiftTable SlackBotFunctionArn: Value: !GetAtt SlackBotFunction.Arn + WeeklyPostFunctionArn: + Value: !GetAtt WeeklyPostFunction.Arn RosterSyncFunctionArn: Value: !GetAtt RosterSyncFunction.Arn From 0fed60248aa3e9ca1514400e8a8b4d61bbc86073 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 15:39:28 -0400 Subject: [PATCH 02/20] Restructure src/ to per-function layout with shared Layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move from flat src/ to per-function directories: - src/slack-bot/ — Slack Bolt Lambda handler - src/weekly-post/ — Monday schedule + pay post - src/roster-sync/ — Daily 3CX roster sync - src/shared/ — Lambda Layer with schedule, blocks, three_cx_client Each function has its own requirements.txt and CodeUri. Shared modules are deployed as a SAM Layer (afterhours-shared) importable as `from shared.X import Y`. --- .github/workflows/ci.yaml | 2 +- src/{roster_sync.py => roster-sync/app.py} | 4 +-- src/roster-sync/requirements.txt | 2 ++ src/{ => shared/python/shared}/__init__.py | 0 src/{ => shared/python/shared}/blocks.py | 0 src/{ => shared/python/shared}/schedule.py | 0 .../python/shared}/three_cx_client.py | 0 src/shared/requirements.txt | 2 ++ src/{ => slack-bot}/app.py | 4 +-- src/{ => slack-bot}/handler.py | 2 +- .../slack-bot/requirements.txt | 1 - src/{weekly_post.py => weekly-post/app.py} | 4 +-- src/weekly-post/requirements.txt | 2 ++ template.yaml | 32 +++++++++++++++---- 14 files changed, 40 insertions(+), 15 deletions(-) rename src/{roster_sync.py => roster-sync/app.py} (97%) create mode 100644 src/roster-sync/requirements.txt rename src/{ => shared/python/shared}/__init__.py (100%) rename src/{ => shared/python/shared}/blocks.py (100%) rename src/{ => shared/python/shared}/schedule.py (100%) rename src/{ => shared/python/shared}/three_cx_client.py (100%) create mode 100644 src/shared/requirements.txt rename src/{ => slack-bot}/app.py (99%) rename src/{ => slack-bot}/handler.py (97%) rename requirements.txt => src/slack-bot/requirements.txt (69%) rename src/{weekly_post.py => weekly-post/app.py} (98%) create mode 100644 src/weekly-post/requirements.txt diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3c66f09..2b6dbbc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,4 +7,4 @@ jobs: ci: uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main with: - source-dirs: "src" + source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/shared/python/shared" diff --git a/src/roster_sync.py b/src/roster-sync/app.py similarity index 97% rename from src/roster_sync.py rename to src/roster-sync/app.py index 72bfb68..fa53420 100644 --- a/src/roster_sync.py +++ b/src/roster-sync/app.py @@ -12,8 +12,8 @@ from zoneinfo import ZoneInfo import boto3 -from src.three_cx_client import ThreeCXClient -from src.schedule import ShiftSchedule +from shared.three_cx_client import ThreeCXClient +from shared.schedule import ShiftSchedule logger = logging.getLogger() logger.setLevel(logging.INFO) diff --git a/src/roster-sync/requirements.txt b/src/roster-sync/requirements.txt new file mode 100644 index 0000000..fc95321 --- /dev/null +++ b/src/roster-sync/requirements.txt @@ -0,0 +1,2 @@ +boto3>=1.43.6 +requests>=2.33.1 diff --git a/src/__init__.py b/src/shared/python/shared/__init__.py similarity index 100% rename from src/__init__.py rename to src/shared/python/shared/__init__.py diff --git a/src/blocks.py b/src/shared/python/shared/blocks.py similarity index 100% rename from src/blocks.py rename to src/shared/python/shared/blocks.py diff --git a/src/schedule.py b/src/shared/python/shared/schedule.py similarity index 100% rename from src/schedule.py rename to src/shared/python/shared/schedule.py diff --git a/src/three_cx_client.py b/src/shared/python/shared/three_cx_client.py similarity index 100% rename from src/three_cx_client.py rename to src/shared/python/shared/three_cx_client.py diff --git a/src/shared/requirements.txt b/src/shared/requirements.txt new file mode 100644 index 0000000..fc95321 --- /dev/null +++ b/src/shared/requirements.txt @@ -0,0 +1,2 @@ +boto3>=1.43.6 +requests>=2.33.1 diff --git a/src/app.py b/src/slack-bot/app.py similarity index 99% rename from src/app.py rename to src/slack-bot/app.py index 0dd8f51..3ff804c 100644 --- a/src/app.py +++ b/src/slack-bot/app.py @@ -10,14 +10,14 @@ from zoneinfo import ZoneInfo import boto3 from slack_bolt import App -from src.blocks import ( +from shared.blocks import ( build_help_blocks, build_pay_summary_blocks, build_roster_blocks, build_shift_change_message, build_week_schedule, ) -from src.schedule import FALLBACK_EXTENSION, ShiftSchedule +from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule logger = logging.getLogger(__name__) EASTERN = ZoneInfo("America/New_York") diff --git a/src/handler.py b/src/slack-bot/handler.py similarity index 97% rename from src/handler.py rename to src/slack-bot/handler.py index 08a3f3d..32fb37c 100644 --- a/src/handler.py +++ b/src/slack-bot/handler.py @@ -6,7 +6,7 @@ import os import boto3 from slack_bolt.adapter.aws_lambda import SlackRequestHandler -from src.app import create_app +from app import create_app logger = logging.getLogger() logger.setLevel(logging.INFO) diff --git a/requirements.txt b/src/slack-bot/requirements.txt similarity index 69% rename from requirements.txt rename to src/slack-bot/requirements.txt index cd1aaf2..1f9b6ab 100644 --- a/requirements.txt +++ b/src/slack-bot/requirements.txt @@ -1,3 +1,2 @@ slack_bolt>=1.28.0,<2.0 boto3>=1.43.6 -requests>=2.33.1 diff --git a/src/weekly_post.py b/src/weekly-post/app.py similarity index 98% rename from src/weekly_post.py rename to src/weekly-post/app.py index a7cc5a3..b9abb1b 100644 --- a/src/weekly_post.py +++ b/src/weekly-post/app.py @@ -10,8 +10,8 @@ from zoneinfo import ZoneInfo import boto3 from slack_sdk import WebClient -from src.blocks import build_pay_summary_blocks, build_week_schedule -from src.schedule import FALLBACK_EXTENSION, ShiftSchedule +from shared.blocks import build_pay_summary_blocks, build_week_schedule +from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule logger = logging.getLogger() logger.setLevel(logging.INFO) diff --git a/src/weekly-post/requirements.txt b/src/weekly-post/requirements.txt new file mode 100644 index 0000000..1fc4048 --- /dev/null +++ b/src/weekly-post/requirements.txt @@ -0,0 +1,2 @@ +slack_sdk>=3.33.0,<4.0 +boto3>=1.43.6 diff --git a/template.yaml b/template.yaml index 5e7d2c3..2f94e60 100644 --- a/template.yaml +++ b/template.yaml @@ -20,6 +20,20 @@ Globals: - arm64 Resources: + # --- Shared Lambda Layer --- + SharedLayer: + Type: AWS::Serverless::LayerVersion + Properties: + LayerName: afterhours-shared + ContentUri: src/shared/ + CompatibleRuntimes: + - python3.12 + CompatibleArchitectures: + - arm64 + Metadata: + BuildMethod: python3.12 + BuildArchitecture: arm64 + # --- DynamoDB --- ShiftTable: Type: AWS::DynamoDB::Table @@ -42,8 +56,10 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: afterhours-shift-manager - Handler: src/handler.handler - CodeUri: . + Handler: handler.handler + CodeUri: src/slack-bot/ + Layers: + - !Ref SharedLayer Environment: Variables: SHIFT_TABLE: !Ref ShiftTable @@ -85,8 +101,10 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: afterhours-weekly-post - Handler: src/weekly_post.handler - CodeUri: . + Handler: app.handler + CodeUri: src/weekly-post/ + Layers: + - !Ref SharedLayer Environment: Variables: SHIFT_TABLE: !Ref ShiftTable @@ -138,8 +156,10 @@ Resources: Type: AWS::Serverless::Function Properties: FunctionName: afterhours-roster-sync - Handler: src/roster_sync.handler - CodeUri: . + Handler: app.handler + CodeUri: src/roster-sync/ + Layers: + - !Ref SharedLayer Timeout: 60 Environment: Variables: From 7979e2a4e7d8dcef737abf371f8254437d70ac70 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 15:41:44 -0400 Subject: [PATCH 03/20] Migrate secrets from SSM Parameter Store to Secrets Manager - Slack bot token and signing secret now read from Secrets Manager - 3CX credentials (domain, client-id, client-secret) moved to Secrets Manager under afterhours-shift-manager/3cx-* prefix - Channel ID is now a non-secret CloudFormation parameter (ShiftChannel) - Add shared secrets.py helper for Secrets Manager reads - Remove SSM and KMS IAM policies, add secretsmanager:GetSecretValue --- src/roster-sync/app.py | 23 +++---------- src/shared/python/shared/secrets.py | 16 +++++++++ src/slack-bot/handler.py | 16 +++------ src/weekly-post/app.py | 10 ++---- template.yaml | 51 +++++++++-------------------- 5 files changed, 43 insertions(+), 73 deletions(-) create mode 100644 src/shared/python/shared/secrets.py diff --git a/src/roster-sync/app.py b/src/roster-sync/app.py index fa53420..935884c 100644 --- a/src/roster-sync/app.py +++ b/src/roster-sync/app.py @@ -10,31 +10,18 @@ import os from datetime import datetime from zoneinfo import ZoneInfo -import boto3 - from shared.three_cx_client import ThreeCXClient from shared.schedule import ShiftSchedule +from shared.secrets import get_secret logger = logging.getLogger() logger.setLevel(logging.INFO) EASTERN = ZoneInfo("America/New_York") -# System extensions to exclude from roster sync EXCLUDE_NAMES = {"Voicemail", "IVR", "Fax"} -def get_3cx_credentials() -> dict: - ssm = boto3.client("ssm") - prefix = os.environ.get("TCX_SSM_PREFIX", "/3cx-scheduler") - params = ssm.get_parameters_by_path(Path=prefix, WithDecryption=True) - creds = {} - for p in params["Parameters"]: - key = p["Name"].split("/")[-1] - creds[key] = p["Value"] - return creds - - def handler(event, context): now = datetime.now(EASTERN) @@ -52,12 +39,12 @@ def handler(event, context): "Starting roster sync from 3CX group '%s' at %s", group_name, now.isoformat() ) - creds = get_3cx_credentials() + secret_prefix = os.environ["TCX_SECRET_PREFIX"] client = ThreeCXClient( - domain=creds["domain"], + domain=get_secret(f"{secret_prefix}domain"), auth_mode="oauth", - client_id=creds["client_id"], - client_secret=creds["client_secret"], + client_id=get_secret(f"{secret_prefix}client-id"), + client_secret=get_secret(f"{secret_prefix}client-secret"), ) members = client.get_group_members(group_name) diff --git a/src/shared/python/shared/secrets.py b/src/shared/python/shared/secrets.py new file mode 100644 index 0000000..82f312a --- /dev/null +++ b/src/shared/python/shared/secrets.py @@ -0,0 +1,16 @@ +"""Fetch secrets from AWS Secrets Manager.""" + +import boto3 + +_client = None + + +def _get_client(): + global _client + if _client is None: + _client = boto3.client("secretsmanager") + return _client + + +def get_secret(secret_id: str) -> str: + return _get_client().get_secret_value(SecretId=secret_id)["SecretString"] diff --git a/src/slack-bot/handler.py b/src/slack-bot/handler.py index 32fb37c..3a3e2fc 100644 --- a/src/slack-bot/handler.py +++ b/src/slack-bot/handler.py @@ -3,10 +3,10 @@ import logging import os -import boto3 from slack_bolt.adapter.aws_lambda import SlackRequestHandler from app import create_app +from shared.secrets import get_secret logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -16,23 +16,15 @@ logging.basicConfig( format="%(asctime)s %(levelname)s %(name)s: %(message)s", level=logging.INFO ) -# Lazy-initialized app singleton _slack_handler = None def _get_handler() -> SlackRequestHandler: global _slack_handler if _slack_handler is None: - ssm = boto3.client("ssm") - bot_token = ssm.get_parameter( - Name=os.environ["SLACK_BOT_TOKEN_PARAM"], WithDecryption=True - )["Parameter"]["Value"] - signing_secret = ssm.get_parameter( - Name=os.environ["SLACK_SIGNING_SECRET_PARAM"], WithDecryption=True - )["Parameter"]["Value"] - schedule_channel = ssm.get_parameter( - Name=os.environ["SHIFT_CHANNEL_PARAM"], WithDecryption=True - )["Parameter"]["Value"] + bot_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"]) + signing_secret = get_secret(os.environ["SLACK_SIGNING_SECRET"]) + schedule_channel = os.environ["SHIFT_CHANNEL"] app = create_app(bot_token, signing_secret, schedule_channel=schedule_channel) _slack_handler = SlackRequestHandler(app=app) diff --git a/src/weekly-post/app.py b/src/weekly-post/app.py index b9abb1b..a630210 100644 --- a/src/weekly-post/app.py +++ b/src/weekly-post/app.py @@ -12,6 +12,7 @@ from slack_sdk import WebClient from shared.blocks import build_pay_summary_blocks, build_week_schedule from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule +from shared.secrets import get_secret logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -147,13 +148,8 @@ def handler(event, context): ) return {"skipped": True} - ssm = boto3.client("ssm") - bot_token = ssm.get_parameter( - Name=os.environ["SLACK_BOT_TOKEN_PARAM"], WithDecryption=True - )["Parameter"]["Value"] - channel_id = ssm.get_parameter( - Name=os.environ["SHIFT_CHANNEL_PARAM"], WithDecryption=True - )["Parameter"]["Value"] + bot_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"]) + channel_id = os.environ["SHIFT_CHANNEL"] schedule = ShiftSchedule() slack = WebClient(token=bot_token) diff --git a/template.yaml b/template.yaml index 2f94e60..654b847 100644 --- a/template.yaml +++ b/template.yaml @@ -6,6 +6,9 @@ Parameters: Timezone: Type: String Default: "America/New_York" + ShiftChannel: + Type: String + Description: Slack channel ID for schedule posts and shift notifications SchedulerFunctionName: Type: String Default: "3cx-ring-group-scheduler" @@ -63,9 +66,9 @@ Resources: Environment: Variables: SHIFT_TABLE: !Ref ShiftTable - SLACK_BOT_TOKEN_PARAM: /afterhours-shift-manager/slack-bot-token - SLACK_SIGNING_SECRET_PARAM: /afterhours-shift-manager/slack-signing-secret - SHIFT_CHANNEL_PARAM: /afterhours-shift-manager/channel-id + SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token + SLACK_SIGNING_SECRET: afterhours-shift-manager/slack-signing-secret + SHIFT_CHANNEL: !Ref ShiftChannel SCHEDULER_FUNCTION_NAME: !Ref SchedulerFunctionName TZ: !Ref Timezone Policies: @@ -74,16 +77,9 @@ Resources: - Statement: - Effect: Allow Action: - - ssm:GetParameter + - secretsmanager:GetSecretValue Resource: - - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/afterhours-shift-manager/*" - - Effect: Allow - Action: - - kms:Decrypt - Resource: "*" - Condition: - StringEquals: - "kms:ViaService": !Sub "ssm.${AWS::Region}.amazonaws.com" + - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*" - Effect: Allow Action: - lambda:InvokeFunction @@ -108,8 +104,8 @@ Resources: Environment: Variables: SHIFT_TABLE: !Ref ShiftTable - SLACK_BOT_TOKEN_PARAM: /afterhours-shift-manager/slack-bot-token - SHIFT_CHANNEL_PARAM: /afterhours-shift-manager/channel-id + SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token + SHIFT_CHANNEL: !Ref ShiftChannel SES_SENDER: noreply@seahaven.com PAYROLL_RECIPIENTS: payroll@seahaven.com PAY_REPORT_USER: U0A3SC48T47 @@ -120,16 +116,9 @@ Resources: - Statement: - Effect: Allow Action: - - ssm:GetParameter + - secretsmanager:GetSecretValue Resource: - - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/afterhours-shift-manager/*" - - Effect: Allow - Action: - - kms:Decrypt - Resource: "*" - Condition: - StringEquals: - "kms:ViaService": !Sub "ssm.${AWS::Region}.amazonaws.com" + - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*" - Effect: Allow Action: - ses:SendEmail @@ -164,7 +153,7 @@ Resources: Environment: Variables: SHIFT_TABLE: !Ref ShiftTable - TCX_SSM_PREFIX: /3cx-scheduler + TCX_SECRET_PREFIX: afterhours-shift-manager/3cx- SYNC_GROUP: DEFAULT TZ: !Ref Timezone Policies: @@ -173,19 +162,9 @@ Resources: - Statement: - Effect: Allow Action: - - ssm:GetParametersByPath - - ssm:GetParameter - - ssm:GetParameters + - secretsmanager:GetSecretValue Resource: - - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/3cx-scheduler" - - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/3cx-scheduler/*" - - Effect: Allow - Action: - - kms:Decrypt - Resource: "*" - Condition: - StringEquals: - "kms:ViaService": !Sub "ssm.${AWS::Region}.amazonaws.com" + - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*" Events: # Daily at 6am ET (before the 7am schedule post and 8am 3CX scheduler) # EST: 6am ET = 11:00 UTC (Nov-Mar) From b137334c5be1ebd567269898b38eefd8d5b825bc Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 15:44:12 -0400 Subject: [PATCH 04/20] Merge ring-scheduler-3cx as 4th Lambda function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add afterhours-ring-scheduler Lambda with 4 EventBridge rules (daily 8am EST/EDT + weekend 5pm EST/EDT) for 3CX ring group routing updates - Extract shared ring_scheduler.py module for direct ring group updates from both the scheduled Lambda and the Slack bot - Replace cross-Lambda invoke with direct update_ring_group() call in the Slack bot — eliminates lambda:InvokeFunction dependency - Use RingGroup API (correct) instead of Queue API (was wrong in the original ring-scheduler repo) - Eliminate YAML config fallback — DynamoDB is the sole schedule source - Add RingGroupNumber CloudFormation parameter --- .github/workflows/ci.yaml | 2 +- src/ring-scheduler/app.py | 73 ++++++++++++++++++++ src/ring-scheduler/requirements.txt | 2 + src/shared/python/shared/ring_scheduler.py | 33 +++++++++ src/slack-bot/app.py | 42 ++++++------ template.yaml | 78 +++++++++++++++++++--- 6 files changed, 200 insertions(+), 30 deletions(-) create mode 100644 src/ring-scheduler/app.py create mode 100644 src/ring-scheduler/requirements.txt create mode 100644 src/shared/python/shared/ring_scheduler.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2b6dbbc..4fc0c3d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,4 +7,4 @@ jobs: ci: uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main with: - source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/shared/python/shared" + source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/ring-scheduler src/shared/python/shared" diff --git a/src/ring-scheduler/app.py b/src/ring-scheduler/app.py new file mode 100644 index 0000000..b05d592 --- /dev/null +++ b/src/ring-scheduler/app.py @@ -0,0 +1,73 @@ +"""Lambda handler — updates 3CX ring group routing based on who's on shift. + +Triggered by EventBridge at 8am ET daily and 5pm ET on weekends. +Also invoked directly by the Slack bot for same-day shift changes. +""" + +import json +import logging +import os +from datetime import datetime +from zoneinfo import ZoneInfo + +from shared.ring_scheduler import update_ring_group +from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule +from shared.secrets import get_secret + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +EASTERN = ZoneInfo("America/New_York") +WEEKEND_DAYS = {"Saturday", "Sunday"} + + +def determine_shift_type(now: datetime) -> str: + day_name = now.strftime("%A") + if day_name in WEEKEND_DAYS and now.hour < 17: + return "day" + return "night" + + +def handler(event, context): + now = datetime.now(EASTERN) + current_hour = now.hour + day_name = now.strftime("%A") + logger.info("Running ring group scheduler at %s", now.isoformat()) + + force = event.get("force", False) + valid_hours = {8, 17} if day_name in WEEKEND_DAYS else {8} + if not force and current_hour not in valid_hours: + logger.info( + "ET hour is %d on %s — skipping (wrong DST rule fired)", + current_hour, + day_name, + ) + return {"skipped": True} + + shift_type = determine_shift_type(now) + + if event.get("override_extension"): + extension = event["override_extension"] + else: + schedule = ShiftSchedule() + date_str = now.strftime("%Y-%m-%d") + ext, _name, source = schedule.resolve_shift(date_str, day_name, shift_type) + extension = ext if source != "available" else FALLBACK_EXTENSION + + secret_prefix = os.environ["TCX_SECRET_PREFIX"] + result = update_ring_group( + extension=extension, + ring_group_number=os.environ["RING_GROUP_NUMBER"], + domain=get_secret(f"{secret_prefix}domain"), + client_id=get_secret(f"{secret_prefix}client-id"), + client_secret=get_secret(f"{secret_prefix}client-secret"), + ) + result.update( + { + "date": now.strftime("%Y-%m-%d"), + "day": day_name, + "shift_type": shift_type, + } + ) + logger.info("Result: %s", json.dumps(result)) + return result diff --git a/src/ring-scheduler/requirements.txt b/src/ring-scheduler/requirements.txt new file mode 100644 index 0000000..fc95321 --- /dev/null +++ b/src/ring-scheduler/requirements.txt @@ -0,0 +1,2 @@ +boto3>=1.43.6 +requests>=2.33.1 diff --git a/src/shared/python/shared/ring_scheduler.py b/src/shared/python/shared/ring_scheduler.py new file mode 100644 index 0000000..cee3bd2 --- /dev/null +++ b/src/shared/python/shared/ring_scheduler.py @@ -0,0 +1,33 @@ +"""Core ring group routing logic shared between the scheduled Lambda and the Slack bot.""" + +import logging + +from shared.three_cx_client import ThreeCXClient + +logger = logging.getLogger(__name__) + + +def update_ring_group( + extension: str, + ring_group_number: str, + domain: str, + client_id: str, + client_secret: str, +) -> dict: + """Update 3CX ring group forwarding to route calls to the given extension.""" + client = ThreeCXClient( + domain=domain, + auth_mode="oauth", + client_id=client_id, + client_secret=client_secret, + ) + ring_group = client.get_ring_group(ring_group_number) + client.update_ring_group_forwarding( + ring_group_id=ring_group["Id"], + closed_destination=extension, + holiday_destination=extension, + ) + logger.info( + "Updated ring group %s to forward to Ext %s", ring_group_number, extension + ) + return {"extension": extension, "ring_group": ring_group_number} diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 3ff804c..497e043 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -1,13 +1,11 @@ """Slack Bolt app — /oncall command handlers and interactive actions.""" -import json import logging import os import re from datetime import datetime, timedelta from zoneinfo import ZoneInfo -import boto3 from slack_bolt import App from shared.blocks import ( @@ -17,7 +15,9 @@ from shared.blocks import ( build_shift_change_message, build_week_schedule, ) +from shared.ring_scheduler import update_ring_group from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule +from shared.secrets import get_secret logger = logging.getLogger(__name__) EASTERN = ZoneInfo("America/New_York") @@ -65,21 +65,23 @@ def parse_date(text: str) -> datetime | None: return None -def invoke_3cx_scheduler(extension: str) -> None: - """Invoke the 3CX scheduler Lambda to update the ring group immediately.""" - fn_name = os.environ.get("SCHEDULER_FUNCTION_NAME") - if not fn_name: - logger.warning("SCHEDULER_FUNCTION_NAME not set — skipping 3CX update") +def _update_3cx_routing(extension: str) -> None: + """Update the 3CX ring group to forward calls to the given extension.""" + ring_group_number = os.environ.get("RING_GROUP_NUMBER") + secret_prefix = os.environ.get("TCX_SECRET_PREFIX") + if not ring_group_number or not secret_prefix: + logger.warning("3CX env vars not set — skipping ring group update") return - - client = boto3.client("lambda") - payload = {"force": True, "override_extension": extension} - logger.info("Invoking %s with %s", fn_name, payload) - client.invoke( - FunctionName=fn_name, - InvocationType="Event", # async — don't wait - Payload=json.dumps(payload), - ) + try: + update_ring_group( + extension=extension, + ring_group_number=ring_group_number, + domain=get_secret(f"{secret_prefix}domain"), + client_id=get_secret(f"{secret_prefix}client-id"), + client_secret=get_secret(f"{secret_prefix}client-secret"), + ) + except Exception: + logger.exception("Failed to update 3CX ring group") def is_today(date_str: str) -> bool: @@ -172,7 +174,7 @@ def create_app( return if is_today(date_str) and shift_type == "night": - invoke_3cx_scheduler(employee["extension"]) + _update_3cx_routing(employee["extension"]) blocks = build_shift_change_message( user_id, date_str, "picked_up", employee["extension"], employee["name"] @@ -333,7 +335,7 @@ def create_app( schedule.set_override(date_str, employee["extension"], employee["name"]) if is_today(date_str): - invoke_3cx_scheduler(employee["extension"]) + _update_3cx_routing(employee["extension"]) respond(text=f"You picked up the shift for *{date.strftime('%A, %b %-d')}*.") @@ -380,7 +382,7 @@ def create_app( schedule.mark_open(date_str) if is_today(date_str): - invoke_3cx_scheduler(FALLBACK_EXTENSION) + _update_3cx_routing(FALLBACK_EXTENSION) respond( text=f"You dropped the shift for *{date.strftime('%A, %b %-d')}*. It's now open for pickup." @@ -445,7 +447,7 @@ def create_app( schedule.set_override(date_str, target["extension"], target["name"]) if is_today(date_str): - invoke_3cx_scheduler(target["extension"]) + _update_3cx_routing(target["extension"]) blocks = build_shift_change_message( target.get("slack_user_id", user_id), diff --git a/template.yaml b/template.yaml index 654b847..0d0ec56 100644 --- a/template.yaml +++ b/template.yaml @@ -9,10 +9,10 @@ Parameters: ShiftChannel: Type: String Description: Slack channel ID for schedule posts and shift notifications - SchedulerFunctionName: + RingGroupNumber: Type: String - Default: "3cx-ring-group-scheduler" - Description: Name of the existing 3CX ring group scheduler Lambda + Default: "800" + Description: 3CX ring group extension number to update Globals: Function: @@ -69,7 +69,8 @@ Resources: SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token SLACK_SIGNING_SECRET: afterhours-shift-manager/slack-signing-secret SHIFT_CHANNEL: !Ref ShiftChannel - SCHEDULER_FUNCTION_NAME: !Ref SchedulerFunctionName + TCX_SECRET_PREFIX: afterhours-shift-manager/3cx- + RING_GROUP_NUMBER: !Ref RingGroupNumber TZ: !Ref Timezone Policies: - DynamoDBCrudPolicy: @@ -80,11 +81,6 @@ Resources: - secretsmanager:GetSecretValue Resource: - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*" - - Effect: Allow - Action: - - lambda:InvokeFunction - Resource: - - !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${SchedulerFunctionName}" Events: SlackEvents: Type: HttpApi @@ -182,6 +178,62 @@ Resources: Description: "Sync roster from 3CX at 6am EDT" Enabled: true + # --- Ring Group Scheduler (daily 3CX routing updates) --- + RingSchedulerFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: afterhours-ring-scheduler + Handler: app.handler + CodeUri: src/ring-scheduler/ + Layers: + - !Ref SharedLayer + Timeout: 60 + Environment: + Variables: + SHIFT_TABLE: !Ref ShiftTable + TCX_SECRET_PREFIX: afterhours-shift-manager/3cx- + RING_GROUP_NUMBER: !Ref RingGroupNumber + TZ: !Ref Timezone + Policies: + - Statement: + - Effect: Allow + Action: + - dynamodb:GetItem + Resource: + - !GetAtt ShiftTable.Arn + - Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: + - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*" + Events: + # Daily at 8am ET — update after-hours routing + DailyScheduleEST: + Type: Schedule + Properties: + Schedule: cron(0 13 ? * * *) + Description: "Update 3CX ring group at 8am EST" + Enabled: true + DailyScheduleEDT: + Type: Schedule + Properties: + Schedule: cron(0 12 ? * * *) + Description: "Update 3CX ring group at 8am EDT" + Enabled: true + # Weekends at 5pm ET — switch to night shift person + WeekendEveningEST: + Type: Schedule + Properties: + Schedule: cron(0 22 ? * SAT,SUN *) + Description: "Update 3CX ring group at 5pm EST weekends" + Enabled: true + WeekendEveningEDT: + Type: Schedule + Properties: + Schedule: cron(0 21 ? * SAT,SUN *) + Description: "Update 3CX ring group at 5pm EDT weekends" + Enabled: true + # --- CloudWatch Log Groups (explicit 60-day retention) --- SlackBotLogGroup: Type: AWS::Logs::LogGroup @@ -201,6 +253,12 @@ Resources: LogGroupName: !Sub "/aws/lambda/${RosterSyncFunction}" RetentionInDays: 60 + RingSchedulerLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/lambda/${RingSchedulerFunction}" + RetentionInDays: 60 + Outputs: SlackBotApiUrl: Description: URL for Slack app Request URL configuration @@ -213,3 +271,5 @@ Outputs: Value: !GetAtt WeeklyPostFunction.Arn RosterSyncFunctionArn: Value: !GetAtt RosterSyncFunction.Arn + RingSchedulerFunctionArn: + Value: !GetAtt RingSchedulerFunction.Arn From 9dd7cbd0f8ca011f1f133c5e9d85f9aa8351bae6 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 15:46:14 -0400 Subject: [PATCH 05/20] Add schedule post live-update and old post deletion (#40, #41) - Store schedule message timestamp in DynamoDB (SCHEDULE_POST record) - Delete previous week's schedule post before posting the new one - Live-update the schedule post via chat_update after any pick/drop/swap/button-pickup so it always reflects current state --- src/shared/python/shared/schedule.py | 20 ++++++++++++++++++++ src/slack-bot/app.py | 26 ++++++++++++++++++++++++++ src/weekly-post/app.py | 17 +++++++++++++++-- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/shared/python/shared/schedule.py b/src/shared/python/shared/schedule.py index 45dc81e..5a29d31 100644 --- a/src/shared/python/shared/schedule.py +++ b/src/shared/python/shared/schedule.py @@ -142,6 +142,26 @@ class ShiftSchedule: return (FALLBACK_EXTENSION, "Available", "available") + # ── Schedule post tracking ─────────────────────────────────────────── + + def get_schedule_post(self, channel_id: str) -> dict | None: + resp = self.table.get_item( + Key={"PK": "SCHEDULE_POST", "SK": channel_id} + ) + return resp.get("Item") + + def save_schedule_post( + self, channel_id: str, message_ts: str, week_start: str + ) -> None: + self.table.put_item( + Item={ + "PK": "SCHEDULE_POST", + "SK": channel_id, + "message_ts": message_ts, + "week_start": week_start, + } + ) + # ── Pay records ───────────────────────────────────────────────────── def get_pay_record(self, week_key: str) -> dict | None: diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 497e043..7a7bd25 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -185,6 +185,29 @@ def create_app( blocks=blocks, text=f"Shift picked up for {date_str}", ) + _refresh_schedule_post(client) + + def _refresh_schedule_post(client): + """Update the pinned schedule message in-place after a shift change.""" + channel = schedule_channel + if not channel: + return + post = schedule.get_schedule_post(channel) + if not post or not post.get("message_ts"): + return + try: + blocks = build_week_schedule(schedule) + now = datetime.now(EASTERN) + this_monday = now - timedelta(days=now.weekday()) + end_date = this_monday + timedelta(days=13) + client.chat_update( + channel=channel, + ts=post["message_ts"], + blocks=blocks, + text=f"After-Hours Schedule — {now.strftime('%b %-d')} to {end_date.strftime('%b %-d')}", + ) + except Exception: + logger.warning("Could not update schedule post", exc_info=True) # ── Subcommand handlers ───────────────────────────────────────────── @@ -350,6 +373,7 @@ def create_app( ) except Exception: logger.exception("Failed to post pickup notification to channel") + _refresh_schedule_post(client) def _handle_drop(respond, schedule, user_id, text, channel_id, client): parts = text.split(maxsplit=1) @@ -395,6 +419,7 @@ def create_app( ) except Exception: logger.exception("Failed to post drop notification to channel") + _refresh_schedule_post(client) def _handle_swap(respond, schedule, user_id, text, channel_id, client): # Expected format: swap @user OR swap @@ -466,5 +491,6 @@ def create_app( ) except Exception: logger.exception("Failed to post swap notification to channel") + _refresh_schedule_post(client) return app diff --git a/src/weekly-post/app.py b/src/weekly-post/app.py index a630210..f15b7cb 100644 --- a/src/weekly-post/app.py +++ b/src/weekly-post/app.py @@ -189,20 +189,33 @@ def handler(event, context): # Email pay summary to payroll _send_pay_email(week_label, pay_record) + # --- Delete previous week's schedule post --- + old_post = schedule.get_schedule_post(channel_id) + if old_post and old_post.get("message_ts"): + try: + slack.chat_delete(channel=channel_id, ts=old_post["message_ts"]) + logger.info("Deleted previous schedule post %s", old_post["message_ts"]) + except Exception: + logger.warning("Could not delete old schedule post", exc_info=True) + # --- Two-week schedule (always starts on Monday of this week) --- this_monday = now - timedelta(days=now.weekday()) blocks = build_week_schedule(schedule, start_date=this_monday) end_date = this_monday + timedelta(days=13) - slack.chat_postMessage( + result = slack.chat_postMessage( channel=channel_id, blocks=blocks, text=f"After-Hours Schedule — {now.strftime('%b %-d')} to {end_date.strftime('%b %-d')}", ) - logger.info("Posted weekly schedule to channel %s", channel_id) + schedule.save_schedule_post( + channel_id, result["ts"], this_monday.strftime("%Y-%m-%d") + ) + logger.info("Posted weekly schedule to channel %s (ts=%s)", channel_id, result["ts"]) return { "posted": True, "channel": channel_id, + "message_ts": result["ts"], "pay_calculated": bool(pay_record["breakdown"]), } From e9b55d357b3f249366af30efa3f10a79ce240edb Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 15:48:35 -0400 Subject: [PATCH 06/20] Disallow past shifts and add day/night labels (#43, #42) - Reject /oncall pick and /oncall drop for past dates - Show ephemeral error when stale pickup buttons are clicked - Hide pickup buttons for dates in the past - Add explicit "Day (8am-5pm)" and "Night (5pm-8am)" labels to schedule lines, pickup buttons, and shift change notifications --- src/shared/python/shared/blocks.py | 36 +++++++++++++++++++++--------- src/slack-bot/app.py | 20 ++++++++++++++++- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/shared/python/shared/blocks.py b/src/shared/python/shared/blocks.py index 704fa1c..7d35abe 100644 --- a/src/shared/python/shared/blocks.py +++ b/src/shared/python/shared/blocks.py @@ -8,6 +8,12 @@ EASTERN = ZoneInfo("America/New_York") WEEKEND_DAYS = {"Saturday", "Sunday"} +SHIFT_LABELS = { + "day": "Day (8am–5pm)", + "night": "Night (5pm–8am)", +} + + def _format_shift_line( date: datetime, ext: str, @@ -17,8 +23,8 @@ def _format_shift_line( shift_type: str = "night", ) -> str: day_label = date.strftime("%a %b %-d") - if shift_type == "day": - day_label += " (Day 8a–5p)" + if shift_type in SHIFT_LABELS: + day_label += f" {SHIFT_LABELS[shift_type]}" if is_today: day_label = f"*{day_label} (today)*" @@ -56,17 +62,19 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di day_name = date.strftime("%A") is_today = date_str == today_str + is_past = date_str < today_str + # Weekend day shift (8am–5pm) if day_name in WEEKEND_DAYS: ext, name, source = schedule.resolve_shift(date_str, day_name, "day") lines.append(_format_shift_line(date, ext, name, source, is_today, "day")) - if source == "available": + if source == "available" and not is_past: open_shifts.append((date_str, "day")) # After-hours (night) shift ext, name, source = schedule.resolve_shift(date_str, day_name) lines.append(_format_shift_line(date, ext, name, source, is_today)) - if source == "available": + if source == "available" and not is_past: open_shifts.append((date_str, "night")) blocks = [ @@ -89,14 +97,14 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di elements = [] for date_str, shift_type in open_shifts: dt = datetime.strptime(date_str, "%Y-%m-%d") - suffix = " (Day)" if shift_type == "day" else "" + label = "Day" if shift_type == "day" else "Night" action_suffix = "_day" if shift_type == "day" else "" elements.append( { "type": "button", "text": { "type": "plain_text", - "text": f"Pick up {dt.strftime('%a %b %-d')}{suffix}", + "text": f"Pick up {dt.strftime('%a %b %-d')} {label}", }, "action_id": f"pickup_{date_str}{action_suffix}", "style": "primary", @@ -112,20 +120,26 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di def build_shift_change_message( - user_id: str, date_str: str, action: str, ext: str, name: str + user_id: str, + date_str: str, + action: str, + ext: str, + name: str, + shift_type: str = "night", ) -> list[dict]: """Build a channel notification for a shift change.""" dt = datetime.strptime(date_str, "%Y-%m-%d") day_label = dt.strftime("%A, %b %-d") + type_label = f" ({SHIFT_LABELS.get(shift_type, shift_type)})" if shift_type == "day" else "" if action == "picked_up": - text = f":white_check_mark: <@{user_id}> picked up the shift for *{day_label}* (Ext {ext})" + text = f":white_check_mark: <@{user_id}> picked up the *{day_label}*{type_label} shift (Ext {ext})" elif action == "dropped": - text = f":warning: <@{user_id}> dropped the shift for *{day_label}* — it's now *Available*" + text = f":warning: <@{user_id}> dropped the *{day_label}*{type_label} shift — it's now *Available*" elif action == "swapped": - text = f":arrows_counterclockwise: <@{user_id}> swapped into the shift for *{day_label}* (Ext {ext})" + text = f":arrows_counterclockwise: <@{user_id}> swapped into the *{day_label}*{type_label} shift (Ext {ext})" else: - text = f"Shift updated for *{day_label}*: {name} (Ext {ext})" + text = f"Shift updated for *{day_label}*{type_label}: {name} (Ext {ext})" return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 7a7bd25..f254b54 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -153,6 +153,15 @@ def create_app( user_id = body["user"]["id"] channel_id = body["channel"]["id"] + today_str = datetime.now(EASTERN).strftime("%Y-%m-%d") + if date_str < today_str: + client.chat_postEphemeral( + channel=channel_id, + user=user_id, + text="That shift has already passed and can't be picked up.", + ) + return + employee = schedule.get_employee_by_slack_id(user_id) if not employee: client.chat_postEphemeral( @@ -177,7 +186,8 @@ def create_app( _update_3cx_routing(employee["extension"]) blocks = build_shift_change_message( - user_id, date_str, "picked_up", employee["extension"], employee["name"] + user_id, date_str, "picked_up", employee["extension"], employee["name"], + shift_type=shift_type, ) respond( response_type="in_channel", @@ -345,6 +355,10 @@ def create_app( return date_str = date.strftime("%Y-%m-%d") + if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"): + respond(text="You can't pick up a shift in the past.") + return + day_name = date.strftime("%A") ext, name, source = schedule.resolve_shift(date_str, day_name) @@ -396,6 +410,10 @@ def create_app( return date_str = date.strftime("%Y-%m-%d") + if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"): + respond(text="You can't drop a shift in the past.") + return + day_name = date.strftime("%A") ext, name, source = schedule.resolve_shift(date_str, day_name) From debac9f8a7b73bc2f458494fe64823e1f476ed9f Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 15:50:24 -0400 Subject: [PATCH 07/20] Add admin slash commands for shift and roster management (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /oncall admin override — assign a shift - /oncall admin open — mark shift as open - /oncall admin clear — remove override, revert to weekly - /oncall admin roster add/remove/rename — manage roster entries - Admin access gated by admin_users list in DynamoDB CONFIG - Help message shows admin commands for admin users --- src/shared/python/shared/blocks.py | 12 ++- src/shared/python/shared/schedule.py | 26 ++++++ src/slack-bot/app.py | 126 ++++++++++++++++++++++++++- 3 files changed, 162 insertions(+), 2 deletions(-) diff --git a/src/shared/python/shared/blocks.py b/src/shared/python/shared/blocks.py index 7d35abe..b362057 100644 --- a/src/shared/python/shared/blocks.py +++ b/src/shared/python/shared/blocks.py @@ -144,7 +144,7 @@ def build_shift_change_message( return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] -def build_help_blocks() -> list[dict]: +def build_help_blocks(is_admin: bool = False) -> list[dict]: """Build help message blocks.""" text = ( "*After-Hours Shift Manager*\n\n" @@ -162,6 +162,16 @@ def build_help_blocks() -> list[dict]: "`/oncall help` — Show this help message\n\n" "_Dates can be: today, tomorrow, monday, friday, 4/5, 2026-04-05_" ) + if is_admin: + text += ( + "\n\n*Admin Commands*\n" + "`/oncall admin override ` — Assign a shift to an extension\n" + "`/oncall admin open ` — Mark a shift as open\n" + "`/oncall admin clear ` — Remove override (revert to weekly)\n" + "`/oncall admin roster add ` — Add a roster entry\n" + "`/oncall admin roster remove ` — Remove a roster entry\n" + "`/oncall admin roster rename ` — Rename an employee" + ) return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] diff --git a/src/shared/python/shared/schedule.py b/src/shared/python/shared/schedule.py index 5a29d31..2644d50 100644 --- a/src/shared/python/shared/schedule.py +++ b/src/shared/python/shared/schedule.py @@ -179,6 +179,32 @@ class ShiftSchedule: resp = self.table.get_item(Key={"PK": "CONFIG", "SK": "CONFIG"}) return resp.get("Item", {}) + def get_admin_users(self) -> list[str]: + config = self.get_config() + return config.get("admin_users", []) + + def add_roster_entry(self, extension: str, name: str) -> None: + self.table.put_item( + Item={ + "PK": "ROSTER", + "SK": extension, + "name": name, + "extension": extension, + "slack_user_id": "", + } + ) + + def remove_roster_entry(self, extension: str) -> None: + self.table.delete_item(Key={"PK": "ROSTER", "SK": extension}) + + def rename_roster_entry(self, extension: str, new_name: str) -> None: + self.table.update_item( + Key={"PK": "ROSTER", "SK": extension}, + UpdateExpression="SET #n = :name", + ExpressionAttributeNames={"#n": "name"}, + ExpressionAttributeValues={":name": new_name}, + ) + def set_default_shift_rate(self, rate: float) -> None: """Set the global default shift rate in config.""" self.table.update_item( diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index f254b54..8c1ffa7 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -107,12 +107,14 @@ def create_app( user_id = command["user_id"] channel_id = command["channel_id"] + is_admin = user_id in schedule.get_admin_users() + if not text or text == "schedule": _show_schedule(respond, schedule) elif text == "next": _show_next_week(respond, schedule) elif text == "help": - respond(blocks=build_help_blocks()) + respond(blocks=build_help_blocks(is_admin=is_admin)) elif text == "roster": respond(blocks=build_roster_blocks(schedule.get_roster())) elif text == "pay": @@ -133,6 +135,8 @@ def create_app( _handle_swap( respond, schedule, user_id, text, schedule_channel or channel_id, client ) + elif text.startswith("admin"): + _handle_admin(respond, schedule, user_id, text, is_admin, client) else: respond(text="Unknown command. Try `/oncall help`") @@ -511,4 +515,124 @@ def create_app( logger.exception("Failed to post swap notification to channel") _refresh_schedule_post(client) + def _handle_admin(respond, schedule, user_id, text, is_admin, client): + if not is_admin: + respond(text="Admin commands are restricted. Contact an administrator.") + return + + parts = text.split() + if len(parts) < 2: + respond( + text=( + "*Admin Commands:*\n" + "`admin override ` — Assign shift\n" + "`admin open ` — Mark open\n" + "`admin clear ` — Remove override\n" + "`admin roster add ` — Add employee\n" + "`admin roster remove ` — Remove employee\n" + "`admin roster rename ` — Rename" + ) + ) + return + + subcmd = parts[1] + + if subcmd == "override": + if len(parts) < 4: + respond(text="Usage: `/oncall admin override `") + return + date = parse_date(parts[2]) + if not date: + respond(text=f"Couldn't parse date: `{parts[2]}`") + return + ext = parts[3] + employee = schedule.get_employee_by_extension(ext) + if not employee: + respond(text=f"Extension `{ext}` not found in the roster.") + return + date_str = date.strftime("%Y-%m-%d") + schedule.set_override(date_str, employee["extension"], employee["name"]) + respond( + text=f"Override set: *{date.strftime('%A, %b %-d')}* → {employee['name']} (Ext {ext})" + ) + _refresh_schedule_post(client) + + elif subcmd == "open": + if len(parts) < 3: + respond(text="Usage: `/oncall admin open `") + return + date = parse_date(parts[2]) + if not date: + respond(text=f"Couldn't parse date: `{parts[2]}`") + return + date_str = date.strftime("%Y-%m-%d") + schedule.mark_open(date_str) + respond(text=f"*{date.strftime('%A, %b %-d')}* marked as open.") + _refresh_schedule_post(client) + + elif subcmd == "clear": + if len(parts) < 3: + respond(text="Usage: `/oncall admin clear `") + return + date = parse_date(parts[2]) + if not date: + respond(text=f"Couldn't parse date: `{parts[2]}`") + return + date_str = date.strftime("%Y-%m-%d") + schedule.remove_override(date_str) + respond( + text=f"Override cleared for *{date.strftime('%A, %b %-d')}* — reverted to weekly schedule." + ) + _refresh_schedule_post(client) + + elif subcmd == "roster": + if len(parts) < 3: + respond(text="Usage: `admin roster add|remove|rename [name]`") + return + roster_cmd = parts[2] + + if roster_cmd == "add": + if len(parts) < 5: + respond(text="Usage: `/oncall admin roster add `") + return + ext = parts[3] + name = " ".join(parts[4:]) + schedule.add_roster_entry(ext, name) + respond(text=f"Added *{name}* (Ext {ext}) to the roster.") + + elif roster_cmd == "remove": + if len(parts) < 4: + respond(text="Usage: `/oncall admin roster remove `") + return + ext = parts[3] + employee = schedule.get_employee_by_extension(ext) + if not employee: + respond(text=f"Extension `{ext}` not found in the roster.") + return + schedule.remove_roster_entry(ext) + respond( + text=f"Removed *{employee.get('name', ext)}* (Ext {ext}) from the roster." + ) + + elif roster_cmd == "rename": + if len(parts) < 5: + respond(text="Usage: `/oncall admin roster rename `") + return + ext = parts[3] + employee = schedule.get_employee_by_extension(ext) + if not employee: + respond(text=f"Extension `{ext}` not found in the roster.") + return + new_name = " ".join(parts[4:]) + schedule.rename_roster_entry(ext, new_name) + respond( + text=f"Renamed Ext {ext}: {employee.get('name', '?')} → *{new_name}*" + ) + + else: + respond(text="Unknown roster command. Use `add`, `remove`, or `rename`.") + + else: + respond(text=f"Unknown admin command: `{subcmd}`. Try `/oncall help`.") + return app From 1f2237f4420c048ffd5bbe85b61f95429db71a3f Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 15:51:21 -0400 Subject: [PATCH 08/20] Update README for merged architecture and new features --- README.md | 93 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 54 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 8b6cb2c..b96f377 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # After-Hours Shift Manager -Slack bot for managing after-hours on-call shifts at Sea Haven Industries. Employees can pick up, drop, and swap shifts directly from Slack. Changes automatically update 3CX ring group 800 routing. +Slack bot for managing after-hours on-call shifts at Sea Haven Industries. Employees can pick up, drop, and swap shifts directly from Slack. Changes automatically update 3CX ring group routing via the integrated ring scheduler. ## How It Works -A recurring weekly schedule assigns employees to after-hours phone duty (5pm-8am). Any unassigned day shows as **Available** in Slack with a pickup button. When someone picks up or drops a shift for today, the 3CX phone system is updated immediately. Future changes take effect when the 3CX scheduler runs at 8am. +A recurring weekly schedule assigns employees to after-hours phone duty. Weekend shifts are split into Day (8am-5pm) and Night (5pm-8am). Any unassigned shift shows as **Available** in Slack with a pickup button. When someone picks up or drops a shift for today, the 3CX ring group is updated immediately. Future changes take effect when the ring scheduler runs at 8am daily and 5pm on weekends. + +The weekly schedule post is updated live when shifts change, and the previous week's post is automatically deleted when the new one goes out. ## Slack Commands @@ -23,15 +25,49 @@ A recurring weekly schedule assigns employees to after-hours phone duty (5pm-8am | `/oncall rate ` | Set a per-person shift rate | | `/oncall help` | Show help | +### Admin Commands + +Available to users listed in `admin_users` in the CONFIG record: + +| Command | Description | +|---|---| +| `/oncall admin override ` | Assign a shift to an extension | +| `/oncall admin open ` | Mark a shift as open | +| `/oncall admin clear ` | Remove override (revert to weekly) | +| `/oncall admin roster add ` | Add an employee to the roster | +| `/oncall admin roster remove ` | Remove an employee | +| `/oncall admin roster rename ` | Rename an employee | + Dates accept: `today`, `tomorrow`, `monday`-`sunday`, `4/5`, `2026-04-05` ## Architecture -- **Runtime**: Python 3.12 on AWS Lambda (via API Gateway) +- **Runtime**: Python 3.12 on AWS Lambda (arm64) - **Data**: DynamoDB single-table (`afterhours-shifts`) -- **IaC**: AWS SAM (`template.yaml`) +- **IaC**: AWS SAM (`template.yaml`) with shared Lambda Layer - **Slack**: Slack Bolt framework with `/oncall` slash command -- **3CX Integration**: Invokes the `3cx-ring-group-scheduler` Lambda for same-day changes; the scheduler reads DynamoDB for future dates +- **3CX Integration**: Ring group routing updated directly via 3CX RingGroup XAPI +- **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`) + +### Lambda Functions + +| Function | Trigger | Purpose | +|---|---|---| +| `afterhours-shift-manager` | API Gateway (POST /slack/events) | Slack bot — handles `/oncall` commands and interactive buttons | +| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email | +| `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX | +| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX ring group routing based on who's on shift | + +### Project Layout + +``` +src/ + slack-bot/ Slack Bolt Lambda (handler + app) + weekly-post/ Monday schedule + pay post + roster-sync/ Daily 3CX roster sync + ring-scheduler/ 3CX ring group routing updates + shared/ Lambda Layer (schedule, blocks, 3CX client, secrets) +``` ### DynamoDB Schema @@ -42,12 +78,23 @@ Single table with `PK` / `SK` keys: | `ROSTER` | `` | Employee: name, extension, slack_user_id | | `WEEKLY` | `` | Default weekly schedule: extension, name | | `OVERRIDE` | `` | Date override from pickup/drop (or `OPEN`) | +| `SCHEDULE_POST` | `` | Current schedule message timestamp | | `PAY` | `` | Weekly pay record (Monday date key) | -| `CONFIG` | `CONFIG` | Settings: shift_rate, fallback_extension | +| `CONFIG` | `CONFIG` | Settings: shift_rate, fallback_extension, admin_users | + +### Secrets Manager + +| Secret | Description | +|---|---| +| `afterhours-shift-manager/slack-bot-token` | Slack bot OAuth token (`xoxb-...`) | +| `afterhours-shift-manager/slack-signing-secret` | Slack app signing secret | +| `afterhours-shift-manager/3cx-domain` | 3CX FQDN (e.g. `company.3cx.us`) | +| `afterhours-shift-manager/3cx-client-id` | 3CX OAuth2 client ID | +| `afterhours-shift-manager/3cx-client-secret` | 3CX OAuth2 client secret | ## Deployment -Merges to `main` are automatically deployed via **CodePipeline + CodeBuild**. The pipeline stack (`afterhours-shift-manager-pipeline`) watches the GitHub repo and runs `sam build && sam package` then deploys via CloudFormation changeset. +Merges to `main` are automatically deployed via **GitHub Actions** using reusable SAM workflows from the Sea Haven org. For manual deploys: @@ -57,35 +104,3 @@ sam deploy ``` See [SETUP.md](SETUP.md) for full deployment and Slack app creation instructions. - -### Version Bumps - -A GitHub Actions workflow runs daily at 6pm ET, collects all PRs merged since the last version tag, creates a semver patch bump (e.g. v1.6.0 → v1.6.1), updates the Slack changelog canvas, and posts a summary to the team channel. Minor bumps can be triggered manually via `workflow_dispatch`. - -### SSM Parameters - -| Parameter | Description | -|---|---| -| `/afterhours-shift-manager/slack-bot-token` | Slack bot OAuth token (`xoxb-...`) | -| `/afterhours-shift-manager/slack-signing-secret` | Slack app signing secret | -| `/afterhours-shift-manager/channel-id` | Slack channel ID for notifications | - -## Weekly Auto-Post - -Every Monday at 7am ET, the bot posts the week's schedule to the configured channel with pickup buttons for any available shifts. - -## Pay Report Email - -A weekly Bonus Pay Summary email is sent via SES to payroll@seahaven.com with a table showing each employee's name, rate, and total pay for the week. - -### Lambda Functions - -| Function | Trigger | Purpose | -|---|---|---| -| `afterhours-shift-manager` | API Gateway (POST /slack/events) | Slack bot — handles `/oncall` commands and interactive buttons | -| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email | -| `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX | - -## Related - -- [3cx-ring-scheduler](https://github.com/Sea-Haven-Industries/3cx-ring-scheduler) — the Lambda that updates 3CX ring group routing daily From 954ac018ce75fd138081c9e344e079e38d580b33 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 16:23:24 -0400 Subject: [PATCH 09/20] Switch from RingGroup API to Queue API at extension 801 The 3CX routing was changed from ring group 800 to queue 801 in a previous PR on ring-scheduler-3cx. Updates all callers and the SAM template parameter default accordingly. --- README.md | 10 ++-- src/ring-scheduler/app.py | 10 ++-- src/shared/python/shared/ring_scheduler.py | 18 +++---- src/shared/python/shared/three_cx_client.py | 54 ++++++++++++++++++--- src/slack-bot/app.py | 16 +++--- template.yaml | 20 ++++---- 6 files changed, 84 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index b96f377..6e5dc17 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # After-Hours Shift Manager -Slack bot for managing after-hours on-call shifts at Sea Haven Industries. Employees can pick up, drop, and swap shifts directly from Slack. Changes automatically update 3CX ring group routing via the integrated ring scheduler. +Slack bot for managing after-hours on-call shifts at Sea Haven Industries. Employees can pick up, drop, and swap shifts directly from Slack. Changes automatically update 3CX queue routing via the integrated ring scheduler. ## How It Works -A recurring weekly schedule assigns employees to after-hours phone duty. Weekend shifts are split into Day (8am-5pm) and Night (5pm-8am). Any unassigned shift shows as **Available** in Slack with a pickup button. When someone picks up or drops a shift for today, the 3CX ring group is updated immediately. Future changes take effect when the ring scheduler runs at 8am daily and 5pm on weekends. +A recurring weekly schedule assigns employees to after-hours phone duty. Weekend shifts are split into Day (8am-5pm) and Night (5pm-8am). Any unassigned shift shows as **Available** in Slack with a pickup button. When someone picks up or drops a shift for today, the 3CX queue is updated immediately. Future changes take effect when the ring scheduler runs at 8am daily and 5pm on weekends. The weekly schedule post is updated live when shifts change, and the previous week's post is automatically deleted when the new one goes out. @@ -46,7 +46,7 @@ Dates accept: `today`, `tomorrow`, `monday`-`sunday`, `4/5`, `2026-04-05` - **Data**: DynamoDB single-table (`afterhours-shifts`) - **IaC**: AWS SAM (`template.yaml`) with shared Lambda Layer - **Slack**: Slack Bolt framework with `/oncall` slash command -- **3CX Integration**: Ring group routing updated directly via 3CX RingGroup XAPI +- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI - **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`) ### Lambda Functions @@ -56,7 +56,7 @@ Dates accept: `today`, `tomorrow`, `monday`-`sunday`, `4/5`, `2026-04-05` | `afterhours-shift-manager` | API Gateway (POST /slack/events) | Slack bot — handles `/oncall` commands and interactive buttons | | `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email | | `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX | -| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX ring group routing based on who's on shift | +| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift | ### Project Layout @@ -65,7 +65,7 @@ src/ slack-bot/ Slack Bolt Lambda (handler + app) weekly-post/ Monday schedule + pay post roster-sync/ Daily 3CX roster sync - ring-scheduler/ 3CX ring group routing updates + ring-scheduler/ 3CX queue routing updates shared/ Lambda Layer (schedule, blocks, 3CX client, secrets) ``` diff --git a/src/ring-scheduler/app.py b/src/ring-scheduler/app.py index b05d592..4e3ce9a 100644 --- a/src/ring-scheduler/app.py +++ b/src/ring-scheduler/app.py @@ -1,4 +1,4 @@ -"""Lambda handler — updates 3CX ring group routing based on who's on shift. +"""Lambda handler — updates 3CX queue routing based on who's on shift. Triggered by EventBridge at 8am ET daily and 5pm ET on weekends. Also invoked directly by the Slack bot for same-day shift changes. @@ -10,7 +10,7 @@ import os from datetime import datetime from zoneinfo import ZoneInfo -from shared.ring_scheduler import update_ring_group +from shared.ring_scheduler import update_queue_routing from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule from shared.secrets import get_secret @@ -32,7 +32,7 @@ def handler(event, context): now = datetime.now(EASTERN) current_hour = now.hour day_name = now.strftime("%A") - logger.info("Running ring group scheduler at %s", now.isoformat()) + logger.info("Running queue scheduler at %s", now.isoformat()) force = event.get("force", False) valid_hours = {8, 17} if day_name in WEEKEND_DAYS else {8} @@ -55,9 +55,9 @@ def handler(event, context): extension = ext if source != "available" else FALLBACK_EXTENSION secret_prefix = os.environ["TCX_SECRET_PREFIX"] - result = update_ring_group( + result = update_queue_routing( extension=extension, - ring_group_number=os.environ["RING_GROUP_NUMBER"], + queue_number=os.environ["QUEUE_NUMBER"], domain=get_secret(f"{secret_prefix}domain"), client_id=get_secret(f"{secret_prefix}client-id"), client_secret=get_secret(f"{secret_prefix}client-secret"), diff --git a/src/shared/python/shared/ring_scheduler.py b/src/shared/python/shared/ring_scheduler.py index cee3bd2..4c786e9 100644 --- a/src/shared/python/shared/ring_scheduler.py +++ b/src/shared/python/shared/ring_scheduler.py @@ -1,4 +1,4 @@ -"""Core ring group routing logic shared between the scheduled Lambda and the Slack bot.""" +"""Core queue routing logic shared between the scheduled Lambda and the Slack bot.""" import logging @@ -7,27 +7,27 @@ from shared.three_cx_client import ThreeCXClient logger = logging.getLogger(__name__) -def update_ring_group( +def update_queue_routing( extension: str, - ring_group_number: str, + queue_number: str, domain: str, client_id: str, client_secret: str, ) -> dict: - """Update 3CX ring group forwarding to route calls to the given extension.""" + """Update 3CX queue forwarding to route calls to the given extension.""" client = ThreeCXClient( domain=domain, auth_mode="oauth", client_id=client_id, client_secret=client_secret, ) - ring_group = client.get_ring_group(ring_group_number) - client.update_ring_group_forwarding( - ring_group_id=ring_group["Id"], + queue = client.get_queue(queue_number) + client.update_queue_forwarding( + queue_id=queue["Id"], closed_destination=extension, holiday_destination=extension, ) logger.info( - "Updated ring group %s to forward to Ext %s", ring_group_number, extension + "Updated queue %s to forward to Ext %s", queue_number, extension ) - return {"extension": extension, "ring_group": ring_group_number} + return {"extension": extension, "queue": queue_number} diff --git a/src/shared/python/shared/three_cx_client.py b/src/shared/python/shared/three_cx_client.py index 09b5c35..15c26ee 100644 --- a/src/shared/python/shared/three_cx_client.py +++ b/src/shared/python/shared/three_cx_client.py @@ -98,13 +98,7 @@ class ThreeCXClient: closed_destination: str, holiday_destination: str, ): - """Update the OutOfOfficeRoute and HolidaysRoute on a ring group. - - Args: - ring_group_id: Numeric ID from the ring group entity - closed_destination: Extension number for after-hours routing - holiday_destination: Extension number for holiday routing - """ + """Update the OutOfOfficeRoute and HolidaysRoute on a ring group.""" payload = { "OutOfOfficeRoute": { "IsPromptEnabled": False, @@ -135,3 +129,49 @@ class ThreeCXClient: holiday_destination, ) return resp.status_code + + def get_queue(self, extension_number: str) -> dict: + """Fetch queue config by extension number.""" + resp = self.session.get( + f"{self.base_url}/xapi/v1/Queues/Pbx.GetByNumber(number='{extension_number}')", + ) + resp.raise_for_status() + return resp.json() + + def update_queue_forwarding( + self, + queue_id: int, + closed_destination: str, + holiday_destination: str, + ): + """Update the OutOfOfficeRoute and HolidaysRoute on a queue.""" + payload = { + "OutOfOfficeRoute": { + "IsPromptEnabled": False, + "Route": { + "To": "Extension", + "Number": closed_destination, + "External": "", + }, + }, + "HolidaysRoute": { + "IsPromptEnabled": False, + "Route": { + "To": "Extension", + "Number": holiday_destination, + "External": "", + }, + }, + } + resp = self.session.patch( + f"{self.base_url}/xapi/v1/Queues({queue_id})", + json=payload, + ) + resp.raise_for_status() + logger.info( + "Updated queue %s: closed->Ext %s, holiday->Ext %s", + queue_id, + closed_destination, + holiday_destination, + ) + return resp.status_code diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 8c1ffa7..583b920 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -15,7 +15,7 @@ from shared.blocks import ( build_shift_change_message, build_week_schedule, ) -from shared.ring_scheduler import update_ring_group +from shared.ring_scheduler import update_queue_routing from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule from shared.secrets import get_secret @@ -66,22 +66,22 @@ def parse_date(text: str) -> datetime | None: def _update_3cx_routing(extension: str) -> None: - """Update the 3CX ring group to forward calls to the given extension.""" - ring_group_number = os.environ.get("RING_GROUP_NUMBER") + """Update the 3CX queue to forward calls to the given extension.""" + queue_number = os.environ.get("QUEUE_NUMBER") secret_prefix = os.environ.get("TCX_SECRET_PREFIX") - if not ring_group_number or not secret_prefix: - logger.warning("3CX env vars not set — skipping ring group update") + if not queue_number or not secret_prefix: + logger.warning("3CX env vars not set — skipping queue update") return try: - update_ring_group( + update_queue_routing( extension=extension, - ring_group_number=ring_group_number, + queue_number=queue_number, domain=get_secret(f"{secret_prefix}domain"), client_id=get_secret(f"{secret_prefix}client-id"), client_secret=get_secret(f"{secret_prefix}client-secret"), ) except Exception: - logger.exception("Failed to update 3CX ring group") + logger.exception("Failed to update 3CX queue") def is_today(date_str: str) -> bool: diff --git a/template.yaml b/template.yaml index 0d0ec56..617dc0f 100644 --- a/template.yaml +++ b/template.yaml @@ -9,10 +9,10 @@ Parameters: ShiftChannel: Type: String Description: Slack channel ID for schedule posts and shift notifications - RingGroupNumber: + QueueNumber: Type: String - Default: "800" - Description: 3CX ring group extension number to update + Default: "801" + Description: 3CX queue extension number to update Globals: Function: @@ -70,7 +70,7 @@ Resources: SLACK_SIGNING_SECRET: afterhours-shift-manager/slack-signing-secret SHIFT_CHANNEL: !Ref ShiftChannel TCX_SECRET_PREFIX: afterhours-shift-manager/3cx- - RING_GROUP_NUMBER: !Ref RingGroupNumber + QUEUE_NUMBER: !Ref QueueNumber TZ: !Ref Timezone Policies: - DynamoDBCrudPolicy: @@ -178,7 +178,7 @@ Resources: Description: "Sync roster from 3CX at 6am EDT" Enabled: true - # --- Ring Group Scheduler (daily 3CX routing updates) --- + # --- Ring Scheduler (daily 3CX queue routing updates) --- RingSchedulerFunction: Type: AWS::Serverless::Function Properties: @@ -192,7 +192,7 @@ Resources: Variables: SHIFT_TABLE: !Ref ShiftTable TCX_SECRET_PREFIX: afterhours-shift-manager/3cx- - RING_GROUP_NUMBER: !Ref RingGroupNumber + QUEUE_NUMBER: !Ref QueueNumber TZ: !Ref Timezone Policies: - Statement: @@ -212,26 +212,26 @@ Resources: Type: Schedule Properties: Schedule: cron(0 13 ? * * *) - Description: "Update 3CX ring group at 8am EST" + Description: "Update 3CX queue at 8am EST" Enabled: true DailyScheduleEDT: Type: Schedule Properties: Schedule: cron(0 12 ? * * *) - Description: "Update 3CX ring group at 8am EDT" + Description: "Update 3CX queue at 8am EDT" Enabled: true # Weekends at 5pm ET — switch to night shift person WeekendEveningEST: Type: Schedule Properties: Schedule: cron(0 22 ? * SAT,SUN *) - Description: "Update 3CX ring group at 5pm EST weekends" + Description: "Update 3CX queue at 5pm EST weekends" Enabled: true WeekendEveningEDT: Type: Schedule Properties: Schedule: cron(0 21 ? * SAT,SUN *) - Description: "Update 3CX ring group at 5pm EDT weekends" + Description: "Update 3CX queue at 5pm EDT weekends" Enabled: true # --- CloudWatch Log Groups (explicit 60-day retention) --- From 7decc63e21afc9152bd5c1528d7af1e5b5bf86dc Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 16:28:01 -0400 Subject: [PATCH 10/20] Pass SAM parameter overrides in deploy workflow --- .github/workflows/deploy.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index be87e6c..96c2c05 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -19,3 +19,4 @@ jobs: cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + parameter-overrides: ${{ secrets.SAM_PARAMETER_OVERRIDES }} From dbaf99df497e693ec09a2f67be9cde1338f348b7 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 16:32:46 -0400 Subject: [PATCH 11/20] Fix review findings: IAM, routing guards, past-date check, roster safety - Ring scheduler: use DynamoDBCrudPolicy (resolve_shift needs Query) - Button pickup: update 3CX for active shift type, not just night - Pick/drop/swap commands: only update 3CX when shift type is active - Swap command: add missing past-date guard - add_roster_entry: reject if extension already exists - Apply ruff formatting --- src/shared/python/shared/blocks.py | 4 ++- src/shared/python/shared/ring_scheduler.py | 4 +-- src/shared/python/shared/schedule.py | 30 +++++++++------- src/slack-bot/app.py | 41 ++++++++++++++++++---- src/weekly-post/app.py | 4 ++- template.yaml | 7 ++-- 6 files changed, 60 insertions(+), 30 deletions(-) diff --git a/src/shared/python/shared/blocks.py b/src/shared/python/shared/blocks.py index b362057..1c3d947 100644 --- a/src/shared/python/shared/blocks.py +++ b/src/shared/python/shared/blocks.py @@ -130,7 +130,9 @@ def build_shift_change_message( """Build a channel notification for a shift change.""" dt = datetime.strptime(date_str, "%Y-%m-%d") day_label = dt.strftime("%A, %b %-d") - type_label = f" ({SHIFT_LABELS.get(shift_type, shift_type)})" if shift_type == "day" else "" + type_label = ( + f" ({SHIFT_LABELS.get(shift_type, shift_type)})" if shift_type == "day" else "" + ) if action == "picked_up": text = f":white_check_mark: <@{user_id}> picked up the *{day_label}*{type_label} shift (Ext {ext})" diff --git a/src/shared/python/shared/ring_scheduler.py b/src/shared/python/shared/ring_scheduler.py index 4c786e9..a3d4529 100644 --- a/src/shared/python/shared/ring_scheduler.py +++ b/src/shared/python/shared/ring_scheduler.py @@ -27,7 +27,5 @@ def update_queue_routing( closed_destination=extension, holiday_destination=extension, ) - logger.info( - "Updated queue %s to forward to Ext %s", queue_number, extension - ) + logger.info("Updated queue %s to forward to Ext %s", queue_number, extension) return {"extension": extension, "queue": queue_number} diff --git a/src/shared/python/shared/schedule.py b/src/shared/python/shared/schedule.py index 2644d50..40c6b83 100644 --- a/src/shared/python/shared/schedule.py +++ b/src/shared/python/shared/schedule.py @@ -145,9 +145,7 @@ class ShiftSchedule: # ── Schedule post tracking ─────────────────────────────────────────── def get_schedule_post(self, channel_id: str) -> dict | None: - resp = self.table.get_item( - Key={"PK": "SCHEDULE_POST", "SK": channel_id} - ) + resp = self.table.get_item(Key={"PK": "SCHEDULE_POST", "SK": channel_id}) return resp.get("Item") def save_schedule_post( @@ -183,16 +181,22 @@ class ShiftSchedule: config = self.get_config() return config.get("admin_users", []) - def add_roster_entry(self, extension: str, name: str) -> None: - self.table.put_item( - Item={ - "PK": "ROSTER", - "SK": extension, - "name": name, - "extension": extension, - "slack_user_id": "", - } - ) + def add_roster_entry(self, extension: str, name: str) -> bool: + """Add a new roster entry. Returns False if extension already exists.""" + try: + self.table.put_item( + Item={ + "PK": "ROSTER", + "SK": extension, + "name": name, + "extension": extension, + "slack_user_id": "", + }, + ConditionExpression="attribute_not_exists(PK)", + ) + return True + except self.table.meta.client.exceptions.ConditionalCheckFailedException: + return False def remove_roster_entry(self, extension: str) -> None: self.table.delete_item(Key={"PK": "ROSTER", "SK": extension}) diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 583b920..9532a88 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -88,6 +88,18 @@ def is_today(date_str: str) -> bool: return date_str == datetime.now(EASTERN).strftime("%Y-%m-%d") +WEEKEND_DAYS = {"Saturday", "Sunday"} + + +def _is_active_shift_type(shift_type: str) -> bool: + """Check if the given shift type is the currently active one.""" + now = datetime.now(EASTERN) + day_name = now.strftime("%A") + if day_name in WEEKEND_DAYS and now.hour < 17: + return shift_type == "day" + return shift_type == "night" + + def create_app( bot_token: str, signing_secret: str, schedule_channel: str | None = None ) -> App: @@ -186,11 +198,15 @@ def create_app( ) return - if is_today(date_str) and shift_type == "night": + if is_today(date_str) and _is_active_shift_type(shift_type): _update_3cx_routing(employee["extension"]) blocks = build_shift_change_message( - user_id, date_str, "picked_up", employee["extension"], employee["name"], + user_id, + date_str, + "picked_up", + employee["extension"], + employee["name"], shift_type=shift_type, ) respond( @@ -375,7 +391,7 @@ def create_app( schedule.set_override(date_str, employee["extension"], employee["name"]) - if is_today(date_str): + if is_today(date_str) and _is_active_shift_type("night"): _update_3cx_routing(employee["extension"]) respond(text=f"You picked up the shift for *{date.strftime('%A, %b %-d')}*.") @@ -427,7 +443,7 @@ def create_app( schedule.mark_open(date_str) - if is_today(date_str): + if is_today(date_str) and _is_active_shift_type("night"): _update_3cx_routing(FALLBACK_EXTENSION) respond( @@ -465,6 +481,10 @@ def create_app( return date_str = date.strftime("%Y-%m-%d") + if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"): + respond(text="You can't swap a shift in the past.") + return + day_name = date.strftime("%A") ext, name, source = schedule.resolve_shift(date_str, day_name) @@ -493,7 +513,7 @@ def create_app( schedule.set_override(date_str, target["extension"], target["name"]) - if is_today(date_str): + if is_today(date_str) and _is_active_shift_type("night"): _update_3cx_routing(target["extension"]) blocks = build_shift_change_message( @@ -597,7 +617,12 @@ def create_app( return ext = parts[3] name = " ".join(parts[4:]) - schedule.add_roster_entry(ext, name) + added = schedule.add_roster_entry(ext, name) + if not added: + respond( + text=f"Extension `{ext}` already exists. Use `roster rename` to change the name." + ) + return respond(text=f"Added *{name}* (Ext {ext}) to the roster.") elif roster_cmd == "remove": @@ -630,7 +655,9 @@ def create_app( ) else: - respond(text="Unknown roster command. Use `add`, `remove`, or `rename`.") + respond( + text="Unknown roster command. Use `add`, `remove`, or `rename`." + ) else: respond(text=f"Unknown admin command: `{subcmd}`. Try `/oncall help`.") diff --git a/src/weekly-post/app.py b/src/weekly-post/app.py index f15b7cb..6f36901 100644 --- a/src/weekly-post/app.py +++ b/src/weekly-post/app.py @@ -212,7 +212,9 @@ def handler(event, context): schedule.save_schedule_post( channel_id, result["ts"], this_monday.strftime("%Y-%m-%d") ) - logger.info("Posted weekly schedule to channel %s (ts=%s)", channel_id, result["ts"]) + logger.info( + "Posted weekly schedule to channel %s (ts=%s)", channel_id, result["ts"] + ) return { "posted": True, "channel": channel_id, diff --git a/template.yaml b/template.yaml index 617dc0f..3077ac2 100644 --- a/template.yaml +++ b/template.yaml @@ -195,12 +195,9 @@ Resources: QUEUE_NUMBER: !Ref QueueNumber TZ: !Ref Timezone Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ShiftTable - Statement: - - Effect: Allow - Action: - - dynamodb:GetItem - Resource: - - !GetAtt ShiftTable.Arn - Effect: Allow Action: - secretsmanager:GetSecretValue From eb8f397b9ed6580996e6583c2d304f8855421b03 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 16:34:06 -0400 Subject: [PATCH 12/20] Add error handling to ring scheduler 3CX call --- src/ring-scheduler/app.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/ring-scheduler/app.py b/src/ring-scheduler/app.py index 4e3ce9a..c58f7aa 100644 --- a/src/ring-scheduler/app.py +++ b/src/ring-scheduler/app.py @@ -55,13 +55,18 @@ def handler(event, context): extension = ext if source != "available" else FALLBACK_EXTENSION secret_prefix = os.environ["TCX_SECRET_PREFIX"] - result = update_queue_routing( - extension=extension, - queue_number=os.environ["QUEUE_NUMBER"], - domain=get_secret(f"{secret_prefix}domain"), - client_id=get_secret(f"{secret_prefix}client-id"), - client_secret=get_secret(f"{secret_prefix}client-secret"), - ) + try: + result = update_queue_routing( + extension=extension, + queue_number=os.environ["QUEUE_NUMBER"], + domain=get_secret(f"{secret_prefix}domain"), + client_id=get_secret(f"{secret_prefix}client-id"), + client_secret=get_secret(f"{secret_prefix}client-secret"), + ) + except Exception: + logger.exception("Failed to update 3CX queue routing") + return {"error": True, "date": now.strftime("%Y-%m-%d"), "day": day_name} + result.update( { "date": now.strftime("%Y-%m-%d"), From 69220afb9ab44154943fcd5b1fd0693184b70b63 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 16:46:11 -0400 Subject: [PATCH 13/20] Fix weekend day shift commands and admin 3CX routing - Add _find_employee_shift() to check both day/night on weekends - Drop/swap now correctly find and operate on weekend day shifts - Pick finds first available shift type on weekends - Admin override/open/clear update 3CX for same-day active shifts --- src/slack-bot/app.py | 85 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 9532a88..ce24457 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -100,6 +100,24 @@ def _is_active_shift_type(shift_type: str) -> bool: return shift_type == "night" +def _find_employee_shift(schedule, date_str, day_name, employee_ext): + """Find which shift type an employee is assigned to on a given date. + + On weekends, checks both day and night shifts. Returns (ext, name, source, shift_type) + or None if not found on any shift. + """ + if day_name in WEEKEND_DAYS: + for st in ("day", "night"): + ext, name, source = schedule.resolve_shift(date_str, day_name, st) + if ext == employee_ext: + return ext, name, source, st + return None + ext, name, source = schedule.resolve_shift(date_str, day_name, "night") + if ext == employee_ext: + return ext, name, source, "night" + return None + + def create_app( bot_token: str, signing_secret: str, schedule_channel: str | None = None ) -> App: @@ -380,7 +398,19 @@ def create_app( return day_name = date.strftime("%A") - ext, name, source = schedule.resolve_shift(date_str, day_name) + + # On weekends, find the first available shift (day then night) + shift_type = "night" + if day_name in WEEKEND_DAYS: + for st in ("day", "night"): + ext, name, source = schedule.resolve_shift(date_str, day_name, st) + if source == "available": + shift_type = st + break + else: + ext, name, source = schedule.resolve_shift(date_str, day_name) + else: + ext, name, source = schedule.resolve_shift(date_str, day_name) # Already assigned to someone else (not open) if source in ("weekly", "override") and ext != FALLBACK_EXTENSION: @@ -389,15 +419,22 @@ def create_app( ) return - schedule.set_override(date_str, employee["extension"], employee["name"]) + schedule.set_override( + date_str, employee["extension"], employee["name"], shift_type + ) - if is_today(date_str) and _is_active_shift_type("night"): + if is_today(date_str) and _is_active_shift_type(shift_type): _update_3cx_routing(employee["extension"]) respond(text=f"You picked up the shift for *{date.strftime('%A, %b %-d')}*.") blocks = build_shift_change_message( - user_id, date_str, "picked_up", employee["extension"], employee["name"] + user_id, + date_str, + "picked_up", + employee["extension"], + employee["name"], + shift_type=shift_type, ) try: client.chat_postMessage( @@ -435,22 +472,28 @@ def create_app( return day_name = date.strftime("%A") - ext, name, source = schedule.resolve_shift(date_str, day_name) + found = _find_employee_shift( + schedule, date_str, day_name, employee["extension"] + ) - if ext != employee["extension"]: + if not found: + ext, name, _source = schedule.resolve_shift(date_str, day_name) respond(text=f"That's not your shift — it belongs to {name} (Ext {ext}).") return - schedule.mark_open(date_str) + ext, name, source, shift_type = found + schedule.mark_open(date_str, shift_type) - if is_today(date_str) and _is_active_shift_type("night"): + if is_today(date_str) and _is_active_shift_type(shift_type): _update_3cx_routing(FALLBACK_EXTENSION) respond( text=f"You dropped the shift for *{date.strftime('%A, %b %-d')}*. It's now open for pickup." ) - blocks = build_shift_change_message(user_id, date_str, "dropped", ext, name) + blocks = build_shift_change_message( + user_id, date_str, "dropped", ext, name, shift_type=shift_type + ) try: client.chat_postMessage( channel=channel_id, blocks=blocks, text=f"Shift dropped for {date_str}" @@ -486,14 +529,19 @@ def create_app( return day_name = date.strftime("%A") - ext, name, source = schedule.resolve_shift(date_str, day_name) + found = _find_employee_shift( + schedule, date_str, day_name, employee["extension"] + ) - if ext != employee["extension"]: + if not found: + ext, name, _source = schedule.resolve_shift(date_str, day_name) respond( text=f"That's not your shift — it belongs to {name} (Ext {ext}). You can only swap your own shifts." ) return + _ext, _name, _source, shift_type = found + # Resolve target user — could be <@U12345> or an extension number target_text = parts[2].strip() slack_id_match = re.match(r"<@(\w+)(?:\|[^>]*)?>", target_text) @@ -511,9 +559,9 @@ def create_app( respond(text=f"Extension `{target_text}` not found in the roster.") return - schedule.set_override(date_str, target["extension"], target["name"]) + schedule.set_override(date_str, target["extension"], target["name"], shift_type) - if is_today(date_str) and _is_active_shift_type("night"): + if is_today(date_str) and _is_active_shift_type(shift_type): _update_3cx_routing(target["extension"]) blocks = build_shift_change_message( @@ -522,6 +570,7 @@ def create_app( "swapped", target["extension"], target["name"], + shift_type=shift_type, ) respond( text=f"Swapped *{date.strftime('%A, %b %-d')}* to {target['name']} (Ext {target['extension']})." @@ -572,6 +621,8 @@ def create_app( return date_str = date.strftime("%Y-%m-%d") schedule.set_override(date_str, employee["extension"], employee["name"]) + if is_today(date_str) and _is_active_shift_type("night"): + _update_3cx_routing(employee["extension"]) respond( text=f"Override set: *{date.strftime('%A, %b %-d')}* → {employee['name']} (Ext {ext})" ) @@ -587,6 +638,8 @@ def create_app( return date_str = date.strftime("%Y-%m-%d") schedule.mark_open(date_str) + if is_today(date_str) and _is_active_shift_type("night"): + _update_3cx_routing(FALLBACK_EXTENSION) respond(text=f"*{date.strftime('%A, %b %-d')}* marked as open.") _refresh_schedule_post(client) @@ -600,6 +653,12 @@ def create_app( return date_str = date.strftime("%Y-%m-%d") schedule.remove_override(date_str) + if is_today(date_str) and _is_active_shift_type("night"): + day_name = date.strftime("%A") + ext, _name, _source = schedule.resolve_shift( + date_str, day_name, "night" + ) + _update_3cx_routing(ext) respond( text=f"Override cleared for *{date.strftime('%A, %b %-d')}* — reverted to weekly schedule." ) From 745f8cd31ee2ccc97c8c9f030768f7d768af8344 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 17:01:41 -0400 Subject: [PATCH 14/20] Fix dependabot directories and admin weekend shift handling Dependabot now scans per-function requirement directories instead of the repo root. Admin override/open/clear commands accept an optional day/night parameter for weekend day shift management. --- .github/dependabot.yml | 7 +++++- src/slack-bot/app.py | 48 ++++++++++++++++++++++++++++-------------- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 825c361..9b56ded 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,12 @@ version: 2 updates: - package-ecosystem: "pip" - directory: "/" + directories: + - "/src/slack-bot" + - "/src/weekly-post" + - "/src/roster-sync" + - "/src/ring-scheduler" + - "/src/shared" schedule: interval: "weekly" assignees: diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index ce24457..0124070 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -594,9 +594,9 @@ def create_app( respond( text=( "*Admin Commands:*\n" - "`admin override ` — Assign shift\n" - "`admin open ` — Mark open\n" - "`admin clear ` — Remove override\n" + "`admin override [day|night]` — Assign shift\n" + "`admin open [day|night]` — Mark open\n" + "`admin clear [day|night]` — Remove override\n" "`admin roster add ` — Add employee\n" "`admin roster remove ` — Remove employee\n" "`admin roster rename ` — Rename" @@ -608,59 +608,75 @@ def create_app( if subcmd == "override": if len(parts) < 4: - respond(text="Usage: `/oncall admin override `") + respond( + text="Usage: `/oncall admin override [day|night]`" + ) return date = parse_date(parts[2]) if not date: respond(text=f"Couldn't parse date: `{parts[2]}`") return ext = parts[3] + shift_type = ( + parts[4] if len(parts) > 4 and parts[4] in ("day", "night") else "night" + ) employee = schedule.get_employee_by_extension(ext) if not employee: respond(text=f"Extension `{ext}` not found in the roster.") return date_str = date.strftime("%Y-%m-%d") - schedule.set_override(date_str, employee["extension"], employee["name"]) - if is_today(date_str) and _is_active_shift_type("night"): + schedule.set_override( + date_str, employee["extension"], employee["name"], shift_type + ) + if is_today(date_str) and _is_active_shift_type(shift_type): _update_3cx_routing(employee["extension"]) + label = "Day" if shift_type == "day" else "Night" respond( - text=f"Override set: *{date.strftime('%A, %b %-d')}* → {employee['name']} (Ext {ext})" + text=f"Override set: *{date.strftime('%A, %b %-d')}* ({label}) → {employee['name']} (Ext {ext})" ) _refresh_schedule_post(client) elif subcmd == "open": if len(parts) < 3: - respond(text="Usage: `/oncall admin open `") + respond(text="Usage: `/oncall admin open [day|night]`") return date = parse_date(parts[2]) if not date: respond(text=f"Couldn't parse date: `{parts[2]}`") return + shift_type = ( + parts[3] if len(parts) > 3 and parts[3] in ("day", "night") else "night" + ) date_str = date.strftime("%Y-%m-%d") - schedule.mark_open(date_str) - if is_today(date_str) and _is_active_shift_type("night"): + schedule.mark_open(date_str, shift_type) + if is_today(date_str) and _is_active_shift_type(shift_type): _update_3cx_routing(FALLBACK_EXTENSION) - respond(text=f"*{date.strftime('%A, %b %-d')}* marked as open.") + label = "Day" if shift_type == "day" else "Night" + respond(text=f"*{date.strftime('%A, %b %-d')}* ({label}) marked as open.") _refresh_schedule_post(client) elif subcmd == "clear": if len(parts) < 3: - respond(text="Usage: `/oncall admin clear `") + respond(text="Usage: `/oncall admin clear [day|night]`") return date = parse_date(parts[2]) if not date: respond(text=f"Couldn't parse date: `{parts[2]}`") return + shift_type = ( + parts[3] if len(parts) > 3 and parts[3] in ("day", "night") else "night" + ) date_str = date.strftime("%Y-%m-%d") - schedule.remove_override(date_str) - if is_today(date_str) and _is_active_shift_type("night"): + schedule.remove_override(date_str, shift_type) + if is_today(date_str) and _is_active_shift_type(shift_type): day_name = date.strftime("%A") ext, _name, _source = schedule.resolve_shift( - date_str, day_name, "night" + date_str, day_name, shift_type ) _update_3cx_routing(ext) + label = "Day" if shift_type == "day" else "Night" respond( - text=f"Override cleared for *{date.strftime('%A, %b %-d')}* — reverted to weekly schedule." + text=f"Override cleared for *{date.strftime('%A, %b %-d')}* ({label}) — reverted to weekly schedule." ) _refresh_schedule_post(client) From 98631a3b2f1cc95d4280b454f92be2db327fd65e Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 17:13:19 -0400 Subject: [PATCH 15/20] Fix weekend day shift active window to 8am-5pm Before midnight-8am on weekends incorrectly reported the day shift as active when the previous night shift is still running. --- src/ring-scheduler/app.py | 2 +- src/slack-bot/app.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ring-scheduler/app.py b/src/ring-scheduler/app.py index c58f7aa..9517d31 100644 --- a/src/ring-scheduler/app.py +++ b/src/ring-scheduler/app.py @@ -23,7 +23,7 @@ WEEKEND_DAYS = {"Saturday", "Sunday"} def determine_shift_type(now: datetime) -> str: day_name = now.strftime("%A") - if day_name in WEEKEND_DAYS and now.hour < 17: + if day_name in WEEKEND_DAYS and 8 <= now.hour < 17: return "day" return "night" diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 0124070..e51e9cf 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -95,7 +95,7 @@ def _is_active_shift_type(shift_type: str) -> bool: """Check if the given shift type is the currently active one.""" now = datetime.now(EASTERN) day_name = now.strftime("%A") - if day_name in WEEKEND_DAYS and now.hour < 17: + if day_name in WEEKEND_DAYS and 8 <= now.hour < 17: return shift_type == "day" return shift_type == "night" From 889a1b307c07533aac8ee5300804aa94915a0552 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 17:23:51 -0400 Subject: [PATCH 16/20] Show shift type label for both weekend shifts in notifications Night shift notifications on weekends were missing the type label, making them ambiguous. Also fix schedule post text fallback to use this_monday instead of now for the start date. --- src/shared/python/shared/blocks.py | 4 +++- src/slack-bot/app.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/shared/python/shared/blocks.py b/src/shared/python/shared/blocks.py index 1c3d947..134564f 100644 --- a/src/shared/python/shared/blocks.py +++ b/src/shared/python/shared/blocks.py @@ -131,7 +131,9 @@ def build_shift_change_message( dt = datetime.strptime(date_str, "%Y-%m-%d") day_label = dt.strftime("%A, %b %-d") type_label = ( - f" ({SHIFT_LABELS.get(shift_type, shift_type)})" if shift_type == "day" else "" + f" ({SHIFT_LABELS.get(shift_type, shift_type)})" + if dt.strftime("%A") in WEEKEND_DAYS + else "" ) if action == "picked_up": diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index e51e9cf..707f6aa 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -252,7 +252,7 @@ def create_app( channel=channel, ts=post["message_ts"], blocks=blocks, - text=f"After-Hours Schedule — {now.strftime('%b %-d')} to {end_date.strftime('%b %-d')}", + text=f"After-Hours Schedule — {this_monday.strftime('%b %-d')} to {end_date.strftime('%b %-d')}", ) except Exception: logger.warning("Could not update schedule post", exc_info=True) From e14eeaa17f75545b18d9b4d9fe82924a0a035332 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 17:38:09 -0400 Subject: [PATCH 17/20] Extract determine_shift_type into shared layer Eliminates duplicated weekend day/night boundary logic between the ring scheduler and Slack bot Lambdas. --- src/ring-scheduler/app.py | 15 ++++++--------- src/shared/python/shared/schedule.py | 18 +++++++++++++++++- src/slack-bot/app.py | 17 +++++++---------- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/ring-scheduler/app.py b/src/ring-scheduler/app.py index 9517d31..48cdfb5 100644 --- a/src/ring-scheduler/app.py +++ b/src/ring-scheduler/app.py @@ -11,21 +11,18 @@ from datetime import datetime from zoneinfo import ZoneInfo from shared.ring_scheduler import update_queue_routing -from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule +from shared.schedule import ( + FALLBACK_EXTENSION, + WEEKEND_DAYS, + ShiftSchedule, + determine_shift_type, +) from shared.secrets import get_secret logger = logging.getLogger() logger.setLevel(logging.INFO) EASTERN = ZoneInfo("America/New_York") -WEEKEND_DAYS = {"Saturday", "Sunday"} - - -def determine_shift_type(now: datetime) -> str: - day_name = now.strftime("%A") - if day_name in WEEKEND_DAYS and 8 <= now.hour < 17: - return "day" - return "night" def handler(event, context): diff --git a/src/shared/python/shared/schedule.py b/src/shared/python/shared/schedule.py index 40c6b83..aba7f2c 100644 --- a/src/shared/python/shared/schedule.py +++ b/src/shared/python/shared/schedule.py @@ -8,14 +8,30 @@ Single-table design: """ import os +from datetime import datetime +from zoneinfo import ZoneInfo import boto3 from boto3.dynamodb.conditions import Key - +EASTERN = ZoneInfo("America/New_York") +WEEKEND_DAYS = {"Saturday", "Sunday"} FALLBACK_EXTENSION = "100" +def determine_shift_type(now: datetime | None = None) -> str: + """Return the currently active shift type: 'day' or 'night'. + + Weekend 8am-5pm is the day shift; all other times are night. + """ + if now is None: + now = datetime.now(EASTERN) + day_name = now.strftime("%A") + if day_name in WEEKEND_DAYS and 8 <= now.hour < 17: + return "day" + return "night" + + class ShiftSchedule: def __init__(self, table_name: str | None = None): dynamodb = boto3.resource("dynamodb") diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 707f6aa..8992d62 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -16,7 +16,12 @@ from shared.blocks import ( build_week_schedule, ) from shared.ring_scheduler import update_queue_routing -from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule +from shared.schedule import ( + FALLBACK_EXTENSION, + WEEKEND_DAYS, + ShiftSchedule, + determine_shift_type, +) from shared.secrets import get_secret logger = logging.getLogger(__name__) @@ -88,16 +93,8 @@ def is_today(date_str: str) -> bool: return date_str == datetime.now(EASTERN).strftime("%Y-%m-%d") -WEEKEND_DAYS = {"Saturday", "Sunday"} - - def _is_active_shift_type(shift_type: str) -> bool: - """Check if the given shift type is the currently active one.""" - now = datetime.now(EASTERN) - day_name = now.strftime("%A") - if day_name in WEEKEND_DAYS and 8 <= now.hour < 17: - return shift_type == "day" - return shift_type == "night" + return determine_shift_type() == shift_type def _find_employee_shift(schedule, date_str, day_name, employee_ext): From 6f6cdd94e811bb936e204506b61f8fc7f3b4e55b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 12 May 2026 22:00:04 +0000 Subject: [PATCH 18/20] Fix weekly schedule fallback start date Co-authored-by: Adam Moussa --- src/weekly-post/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/weekly-post/app.py b/src/weekly-post/app.py index 6f36901..fdbb13b 100644 --- a/src/weekly-post/app.py +++ b/src/weekly-post/app.py @@ -206,7 +206,7 @@ def handler(event, context): result = slack.chat_postMessage( channel=channel_id, blocks=blocks, - text=f"After-Hours Schedule — {now.strftime('%b %-d')} to {end_date.strftime('%b %-d')}", + text=f"After-Hours Schedule — {this_monday.strftime('%b %-d')} to {end_date.strftime('%b %-d')}", ) schedule.save_schedule_post( From fa563338f06619e03874d70e0f4a012b9483198a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 12 May 2026 22:17:50 +0000 Subject: [PATCH 19/20] Include weekend shift type in command confirmations Co-authored-by: Adam Moussa --- src/slack-bot/app.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 8992d62..e6f700f 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -97,6 +97,13 @@ def _is_active_shift_type(shift_type: str) -> bool: return determine_shift_type() == shift_type +def _shift_type_label(day_name: str, shift_type: str) -> str: + if day_name not in WEEKEND_DAYS: + return "" + label = "Day" if shift_type == "day" else "Night" + return f" ({label})" + + def _find_employee_shift(schedule, date_str, day_name, employee_ext): """Find which shift type an employee is assigned to on a given date. @@ -423,7 +430,11 @@ def create_app( if is_today(date_str) and _is_active_shift_type(shift_type): _update_3cx_routing(employee["extension"]) - respond(text=f"You picked up the shift for *{date.strftime('%A, %b %-d')}*.") + date_label = date.strftime("%A, %b %-d") + shift_label = _shift_type_label(day_name, shift_type) + respond( + text=f"You picked up the shift for *{date_label}*{shift_label}." + ) blocks = build_shift_change_message( user_id, @@ -484,8 +495,13 @@ def create_app( if is_today(date_str) and _is_active_shift_type(shift_type): _update_3cx_routing(FALLBACK_EXTENSION) + date_label = date.strftime("%A, %b %-d") + shift_label = _shift_type_label(day_name, shift_type) respond( - text=f"You dropped the shift for *{date.strftime('%A, %b %-d')}*. It's now open for pickup." + text=( + f"You dropped the shift for *{date_label}*{shift_label}. " + "It's now open for pickup." + ) ) blocks = build_shift_change_message( @@ -569,8 +585,13 @@ def create_app( target["name"], shift_type=shift_type, ) + date_label = date.strftime("%A, %b %-d") + shift_label = _shift_type_label(day_name, shift_type) respond( - text=f"Swapped *{date.strftime('%A, %b %-d')}* to {target['name']} (Ext {target['extension']})." + text=( + f"Swapped *{date_label}*{shift_label} to {target['name']} " + f"(Ext {target['extension']})." + ) ) try: From 702f1fd9a215ac5ae0354601278659b4e3b29961 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 18:31:28 -0400 Subject: [PATCH 20/20] Apply ruff formatting to app.py --- src/slack-bot/app.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index e6f700f..6642fbd 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -432,9 +432,7 @@ def create_app( date_label = date.strftime("%A, %b %-d") shift_label = _shift_type_label(day_name, shift_type) - respond( - text=f"You picked up the shift for *{date_label}*{shift_label}." - ) + respond(text=f"You picked up the shift for *{date_label}*{shift_label}.") blocks = build_shift_change_message( user_id,