mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 04:33:10 +00:00
* feat(roster): add Bearer PUT/DELETE roster API Identity hire needs to write Slack IDs onto roster rows without a stale daily 3CX sync clearing them, using the existing HTTP client contract. * fix(roster): strip Secrets Manager token whitespace A file:// secret commonly includes a trailing newline, so compare_digest must strip the cached value the same way it strips the Bearer header.
137 lines
4.5 KiB
Python
137 lines
4.5 KiB
Python
"""Lambda handler — syncs the DynamoDB roster from 3CX group members.
|
|
|
|
Runs daily via EventBridge. Pulls members from the configured 3CX group,
|
|
filters to Extension type only (excludes RingGroups, IVRs, Voicemail, etc.),
|
|
and syncs to DynamoDB. Preserves existing slack_user_id links.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import shared.sentry_init # noqa: F401
|
|
from shared.schedule import ShiftSchedule
|
|
from shared.secrets import get_secret
|
|
from shared.three_cx_client import ThreeCXClient
|
|
|
|
logger = logging.getLogger()
|
|
logger.setLevel(logging.INFO)
|
|
|
|
EASTERN = ZoneInfo("America/New_York")
|
|
|
|
EXCLUDE_NAMES = {"Voicemail", "IVR", "Fax"}
|
|
|
|
|
|
def handler(event, context):
|
|
now = datetime.now(EASTERN)
|
|
|
|
# DST guard — two EventBridge rules fire, only one is at 6am ET
|
|
force = event.get("force", False)
|
|
if not force and now.hour != 6:
|
|
logger.info(
|
|
"ET hour is %d, not 6am — skipping (wrong DST rule fired)", now.hour
|
|
)
|
|
return {"skipped": True}
|
|
|
|
group_name = os.environ.get("SYNC_GROUP", "DEFAULT")
|
|
|
|
logger.info(
|
|
"Starting roster sync from 3CX group '%s' at %s", group_name, now.isoformat()
|
|
)
|
|
|
|
secret_prefix = os.environ["TCX_SECRET_PREFIX"]
|
|
client = ThreeCXClient(
|
|
domain=get_secret(f"{secret_prefix}domain"),
|
|
auth_mode="oauth",
|
|
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)
|
|
# Only sync actual phone extensions, not ring groups, IVRs, etc.
|
|
extensions = [
|
|
m
|
|
for m in members
|
|
if m.get("Type") == "Extension" and m.get("MemberName") not in EXCLUDE_NAMES
|
|
]
|
|
logger.info(
|
|
"Found %d extensions in 3CX group '%s' (filtered from %d members)",
|
|
len(extensions),
|
|
group_name,
|
|
len(members),
|
|
)
|
|
|
|
schedule = ShiftSchedule()
|
|
current_roster = {item["SK"]: item for item in schedule.get_roster()}
|
|
|
|
threecx_extensions = set()
|
|
added = []
|
|
updated = []
|
|
|
|
for ext in extensions:
|
|
number = str(ext.get("Number", ""))
|
|
name = ext.get("MemberName", "").strip()
|
|
|
|
if not number or not name:
|
|
continue
|
|
|
|
threecx_extensions.add(number)
|
|
existing = current_roster.get(number)
|
|
|
|
if existing:
|
|
if existing.get("name") != name:
|
|
schedule.table.update_item(
|
|
Key={"PK": "ROSTER", "SK": number},
|
|
UpdateExpression="SET #n = :name",
|
|
ExpressionAttributeNames={"#n": "name"},
|
|
ExpressionAttributeValues={":name": name},
|
|
)
|
|
updated.append(f"Ext {number}: {existing['name']} -> {name}")
|
|
else:
|
|
# UpdateItem so a stale get_roster snapshot cannot PutItem-overwrite
|
|
# a roster-API row that landed after the read and wipe its Slack id.
|
|
schedule.table.update_item(
|
|
Key={"PK": "ROSTER", "SK": number},
|
|
UpdateExpression=(
|
|
"SET #n = :name, extension = :ext, "
|
|
"slack_user_id = if_not_exists(slack_user_id, :empty)"
|
|
),
|
|
ExpressionAttributeNames={"#n": "name"},
|
|
ExpressionAttributeValues={
|
|
":name": name,
|
|
":ext": number,
|
|
":empty": "",
|
|
},
|
|
)
|
|
added.append(f"Ext {number}: {name}")
|
|
|
|
removed = []
|
|
# Only remove departed employees if we got results from 3CX
|
|
# (prevents wiping roster on API errors or misconfigured group)
|
|
if threecx_extensions:
|
|
for ext_number, item in current_roster.items():
|
|
if ext_number not in threecx_extensions:
|
|
schedule.table.delete_item(Key={"PK": "ROSTER", "SK": ext_number})
|
|
removed.append(f"Ext {ext_number}: {item.get('name', 'Unknown')}")
|
|
elif current_roster:
|
|
logger.warning(
|
|
"No 3CX extensions found but roster is non-empty — skipping removal to prevent data loss"
|
|
)
|
|
|
|
result = {
|
|
"group": group_name,
|
|
"total_3cx": len(threecx_extensions),
|
|
"added": added,
|
|
"updated": updated,
|
|
"removed": removed,
|
|
}
|
|
logger.info(
|
|
"Roster sync complete: group=%s total_3cx=%d added=%d updated=%d removed=%d",
|
|
group_name,
|
|
len(threecx_extensions),
|
|
len(added),
|
|
len(updated),
|
|
len(removed),
|
|
)
|
|
return result
|