diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b6b02e7..9b56ded 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,16 @@ 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: + - "amoussa1229" groups: minor-and-patch: update-types: @@ -13,6 +20,8 @@ updates: directory: "/" schedule: interval: "weekly" + assignees: + - "amoussa1229" groups: minor-and-patch: update-types: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3c66f09..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" + source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/ring-scheduler src/shared/python/shared" 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 }} diff --git a/README.md b/README.md index 8b6cb2c..6e5dc17 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 queue 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 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. ## 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**: Queue routing updated directly via 3CX Queue 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 queue 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 queue 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 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/src/ring-scheduler/app.py b/src/ring-scheduler/app.py new file mode 100644 index 0000000..48cdfb5 --- /dev/null +++ b/src/ring-scheduler/app.py @@ -0,0 +1,75 @@ +"""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. +""" + +import json +import logging +import os +from datetime import datetime +from zoneinfo import ZoneInfo + +from shared.ring_scheduler import update_queue_routing +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") + + +def handler(event, context): + now = datetime.now(EASTERN) + current_hour = now.hour + day_name = now.strftime("%A") + 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} + 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"] + 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"), + "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/roster_sync.py b/src/roster-sync/app.py similarity index 84% rename from src/roster_sync.py rename to src/roster-sync/app.py index 72bfb68..935884c 100644 --- a/src/roster_sync.py +++ b/src/roster-sync/app.py @@ -10,31 +10,18 @@ import os from datetime import datetime 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 +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/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 81% rename from src/blocks.py rename to src/shared/python/shared/blocks.py index 704fa1c..134564f 100644 --- a/src/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,25 +120,35 @@ 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 dt.strftime("%A") in WEEKEND_DAYS + 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}}] -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" @@ -148,6 +166,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/ring_scheduler.py b/src/shared/python/shared/ring_scheduler.py new file mode 100644 index 0000000..a3d4529 --- /dev/null +++ b/src/shared/python/shared/ring_scheduler.py @@ -0,0 +1,31 @@ +"""Core queue 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_queue_routing( + extension: str, + queue_number: str, + domain: str, + client_id: str, + client_secret: str, +) -> dict: + """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, + ) + queue = client.get_queue(queue_number) + client.update_queue_forwarding( + queue_id=queue["Id"], + closed_destination=extension, + holiday_destination=extension, + ) + logger.info("Updated queue %s to forward to Ext %s", queue_number, extension) + return {"extension": extension, "queue": queue_number} diff --git a/src/schedule.py b/src/shared/python/shared/schedule.py similarity index 76% rename from src/schedule.py rename to src/shared/python/shared/schedule.py index 45dc81e..aba7f2c 100644 --- a/src/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") @@ -142,6 +158,24 @@ 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: @@ -159,6 +193,38 @@ 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) -> 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}) + + 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/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/three_cx_client.py b/src/shared/python/shared/three_cx_client.py similarity index 76% rename from src/three_cx_client.py rename to src/shared/python/shared/three_cx_client.py index 09b5c35..15c26ee 100644 --- a/src/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/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 51% rename from src/app.py rename to src/slack-bot/app.py index 0dd8f51..6642fbd 100644 --- a/src/app.py +++ b/src/slack-bot/app.py @@ -1,23 +1,28 @@ """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 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.ring_scheduler import update_queue_routing +from shared.schedule import ( + FALLBACK_EXTENSION, + WEEKEND_DAYS, + ShiftSchedule, + determine_shift_type, +) +from shared.secrets import get_secret logger = logging.getLogger(__name__) EASTERN = ZoneInfo("America/New_York") @@ -65,27 +70,58 @@ 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 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 queue_number or not secret_prefix: + logger.warning("3CX env vars not set — skipping queue 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_queue_routing( + extension=extension, + 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 queue") def is_today(date_str: str) -> bool: return date_str == datetime.now(EASTERN).strftime("%Y-%m-%d") +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. + + 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: @@ -105,12 +141,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": @@ -131,6 +169,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`") @@ -151,6 +191,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( @@ -171,11 +220,16 @@ def create_app( ) return - if is_today(date_str) and shift_type == "night": - invoke_3cx_scheduler(employee["extension"]) + 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( response_type="in_channel", @@ -183,6 +237,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 — {this_monday.strftime('%b %-d')} to {end_date.strftime('%b %-d')}", + ) + except Exception: + logger.warning("Could not update schedule post", exc_info=True) # ── Subcommand handlers ───────────────────────────────────────────── @@ -320,8 +397,24 @@ 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) + + # 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: @@ -330,15 +423,24 @@ 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): - invoke_3cx_scheduler(employee["extension"]) + 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, 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( @@ -348,6 +450,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) @@ -370,29 +473,45 @@ def create_app( return date_str = date.strftime("%Y-%m-%d") - day_name = date.strftime("%A") - ext, name, source = schedule.resolve_shift(date_str, day_name) + if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"): + respond(text="You can't drop a shift in the past.") + return - if ext != employee["extension"]: + day_name = date.strftime("%A") + found = _find_employee_shift( + schedule, date_str, day_name, 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): - invoke_3cx_scheduler(FALLBACK_EXTENSION) + 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(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}" ) 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 @@ -416,15 +535,24 @@ def create_app( return date_str = date.strftime("%Y-%m-%d") - day_name = date.strftime("%A") - ext, name, source = schedule.resolve_shift(date_str, day_name) + if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"): + respond(text="You can't swap a shift in the past.") + return - if ext != employee["extension"]: + day_name = date.strftime("%A") + found = _find_employee_shift( + schedule, date_str, day_name, 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) @@ -442,10 +570,10 @@ 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): - invoke_3cx_scheduler(target["extension"]) + if is_today(date_str) and _is_active_shift_type(shift_type): + _update_3cx_routing(target["extension"]) blocks = build_shift_change_message( target.get("slack_user_id", user_id), @@ -453,9 +581,15 @@ def create_app( "swapped", target["extension"], 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: @@ -464,5 +598,159 @@ def create_app( ) except Exception: 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 [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" + ) + ) + return + + subcmd = parts[1] + + if subcmd == "override": + if len(parts) < 4: + 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"], 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')}* ({label}) → {employee['name']} (Ext {ext})" + ) + _refresh_schedule_post(client) + + elif subcmd == "open": + if len(parts) < 3: + 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, shift_type) + if is_today(date_str) and _is_active_shift_type(shift_type): + _update_3cx_routing(FALLBACK_EXTENSION) + 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 [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, 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, 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')}* ({label}) — 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:]) + 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": + 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 diff --git a/src/handler.py b/src/slack-bot/handler.py similarity index 56% rename from src/handler.py rename to src/slack-bot/handler.py index 08a3f3d..3a3e2fc 100644 --- a/src/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 src.app import create_app +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/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 84% rename from src/weekly_post.py rename to src/weekly-post/app.py index a7cc5a3..fdbb13b 100644 --- a/src/weekly_post.py +++ b/src/weekly-post/app.py @@ -10,8 +10,9 @@ 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 +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) @@ -193,20 +189,35 @@ 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')}", + text=f"After-Hours Schedule — {this_monday.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"]), } 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 feaa664..3077ac2 100644 --- a/template.yaml +++ b/template.yaml @@ -6,18 +6,37 @@ Parameters: Timezone: Type: String Default: "America/New_York" - SchedulerFunctionName: + ShiftChannel: Type: String - Default: "3cx-ring-group-scheduler" - Description: Name of the existing 3CX ring group scheduler Lambda + Description: Slack channel ID for schedule posts and shift notifications + QueueNumber: + Type: String + Default: "801" + Description: 3CX queue extension number to update Globals: Function: Runtime: python3.12 Timeout: 30 MemorySize: 1024 + Architectures: + - 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 @@ -40,15 +59,18 @@ 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 - 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 - SCHEDULER_FUNCTION_NAME: !Ref SchedulerFunctionName + SLACK_BOT_TOKEN_SECRET: afterhours-shift-manager/slack-bot-token + SLACK_SIGNING_SECRET: afterhours-shift-manager/slack-signing-secret + SHIFT_CHANNEL: !Ref ShiftChannel + TCX_SECRET_PREFIX: afterhours-shift-manager/3cx- + QUEUE_NUMBER: !Ref QueueNumber TZ: !Ref Timezone Policies: - DynamoDBCrudPolicy: @@ -56,21 +78,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" - - Effect: Allow - Action: - - lambda:InvokeFunction - Resource: - - !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${SchedulerFunctionName}" + - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*" Events: SlackEvents: Type: HttpApi @@ -83,13 +93,15 @@ 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 - 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 @@ -100,16 +112,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 @@ -136,13 +141,15 @@ 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: SHIFT_TABLE: !Ref ShiftTable - TCX_SSM_PREFIX: /3cx-scheduler + TCX_SECRET_PREFIX: afterhours-shift-manager/3cx- SYNC_GROUP: DEFAULT TZ: !Ref Timezone Policies: @@ -151,19 +158,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) @@ -181,6 +178,84 @@ Resources: Description: "Sync roster from 3CX at 6am EDT" Enabled: true + # --- Ring Scheduler (daily 3CX queue 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- + QUEUE_NUMBER: !Ref QueueNumber + TZ: !Ref Timezone + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ShiftTable + - Statement: + - 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 queue at 8am EST" + Enabled: true + DailyScheduleEDT: + Type: Schedule + Properties: + Schedule: cron(0 12 ? * * *) + 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 queue at 5pm EST weekends" + Enabled: true + WeekendEveningEDT: + Type: Schedule + Properties: + Schedule: cron(0 21 ? * SAT,SUN *) + Description: "Update 3CX queue at 5pm EDT weekends" + 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 + + RingSchedulerLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/lambda/${RingSchedulerFunction}" + RetentionInDays: 60 + Outputs: SlackBotApiUrl: Description: URL for Slack app Request URL configuration @@ -189,5 +264,9 @@ Outputs: Value: !Ref ShiftTable SlackBotFunctionArn: Value: !GetAtt SlackBotFunction.Arn + WeeklyPostFunctionArn: + Value: !GetAtt WeeklyPostFunction.Arn RosterSyncFunctionArn: Value: !GetAtt RosterSyncFunction.Arn + RingSchedulerFunctionArn: + Value: !GetAtt RingSchedulerFunction.Arn