mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-02 23:53:21 +00:00
* fix(side-effects): keep non-prod off Slack and 3CX Dev portal actions could still name the production Slack channel and phone queue. Skip those calls unless STAGE is prod, and leave the identifiers empty on non-prod tasks. * style: apply formatter --------- Co-authored-by: sea-haven-auto-fix[bot] <332630863+sea-haven-auto-fix[bot]@users.noreply.github.com>
141 lines
4.7 KiB
Python
141 lines
4.7 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 and email 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):
|
|
if os.environ.get("STAGE", "prod") != "prod":
|
|
logger.info("Skipping roster sync because STAGE is not prod")
|
|
return {"skipped": "non_prod"}
|
|
|
|
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
|