Compare commits

...

7 commits

Author SHA1 Message Date
Adam Moussa
e14eeaa17f Extract determine_shift_type into shared layer
Eliminates duplicated weekend day/night boundary logic between
the ring scheduler and Slack bot Lambdas.
2026-05-12 17:38:09 -04:00
Adam Moussa
889a1b307c 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.
2026-05-12 17:23:51 -04:00
Adam Moussa
98631a3b2f 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.
2026-05-12 17:13:19 -04:00
Adam Moussa
745f8cd31e 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.
2026-05-12 17:01:41 -04:00
Adam Moussa
69220afb9a 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
2026-05-12 16:46:11 -04:00
Adam Moussa
eb8f397b9e Add error handling to ring scheduler 3CX call 2026-05-12 16:34:06 -04:00
Adam Moussa
dbaf99df49 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
2026-05-12 16:32:46 -04:00
8 changed files with 200 additions and 73 deletions

View file

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

View file

@ -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 now.hour < 17:
return "day"
return "night"
def handler(event, context):
@ -55,13 +52,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"),

View file

@ -130,7 +130,11 @@ 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 dt.strftime("%A") in WEEKEND_DAYS
else ""
)
if action == "picked_up":
text = f":white_check_mark: <@{user_id}> picked up the *{day_label}*{type_label} shift (Ext {ext})"

View file

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

View file

@ -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")
@ -145,9 +161,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 +197,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})

View file

@ -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,6 +93,28 @@ 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 _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:
@ -186,11 +213,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(
@ -218,7 +249,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)
@ -364,7 +395,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:
@ -373,15 +416,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):
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(
@ -419,22 +469,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):
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}"
@ -465,15 +521,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)
@ -491,9 +556,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):
if is_today(date_str) and _is_active_shift_type(shift_type):
_update_3cx_routing(target["extension"])
blocks = build_shift_change_message(
@ -502,6 +567,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']})."
@ -525,9 +591,9 @@ def create_app(
respond(
text=(
"*Admin Commands:*\n"
"`admin override <date> <ext>` — Assign shift\n"
"`admin open <date>` — Mark open\n"
"`admin clear <date>` — Remove override\n"
"`admin override <date> <ext> [day|night]` — Assign shift\n"
"`admin open <date> [day|night]` — Mark open\n"
"`admin clear <date> [day|night]` — Remove override\n"
"`admin roster add <ext> <name>` — Add employee\n"
"`admin roster remove <ext>` — Remove employee\n"
"`admin roster rename <ext> <name>` — Rename"
@ -539,49 +605,75 @@ def create_app(
if subcmd == "override":
if len(parts) < 4:
respond(text="Usage: `/oncall admin override <date> <extension>`")
respond(
text="Usage: `/oncall admin override <date> <extension> [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"])
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 <date>`")
respond(text="Usage: `/oncall admin open <date> [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)
respond(text=f"*{date.strftime('%A, %b %-d')}* marked as open.")
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 <date>`")
respond(text="Usage: `/oncall admin clear <date> [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)
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')}* — reverted to weekly schedule."
text=f"Override cleared for *{date.strftime('%A, %b %-d')}* ({label}) — reverted to weekly schedule."
)
_refresh_schedule_post(client)
@ -597,7 +689,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 +727,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`.")

View file

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

View file

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