From 899f07d09cdb1d6bf97867b0b5d971b74f969a0c Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 12 May 2026 11:24:41 -0400 Subject: [PATCH] Add unified Front integrations SAM stack Consolidates front-sla-monitor (Python SAM) and google-user-sync (JavaScript CDK) into a single Python SAM repo with two Lambdas: front-sla-monitor and front-user-sync. --- .github/dependabot.yml | 20 ++ .github/workflows/ci.yaml | 10 + .github/workflows/deploy.yaml | 22 ++ .gitignore | 5 + README.md | 153 +++++++++++ samconfig.toml.example | 18 ++ slack-app-manifest.yaml | 16 ++ src/sla_monitor/app.py | 421 +++++++++++++++++++++++++++++++ src/sla_monitor/requirements.txt | 1 + src/user_sync/app.py | 184 ++++++++++++++ src/user_sync/requirements.txt | 3 + template.yaml | 166 ++++++++++++ 12 files changed, 1019 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/deploy.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 samconfig.toml.example create mode 100644 slack-app-manifest.yaml create mode 100644 src/sla_monitor/app.py create mode 100644 src/sla_monitor/requirements.txt create mode 100644 src/user_sync/app.py create mode 100644 src/user_sync/requirements.txt create mode 100644 template.yaml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d61a4e2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/src/sla_monitor" + schedule: + interval: "weekly" + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + - package-ecosystem: "pip" + directory: "/src/user_sync" + schedule: + interval: "weekly" + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..3c66f09 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,10 @@ +name: CI +on: + pull_request: + branches: [main] + +jobs: + ci: + uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main + with: + source-dirs: "src" diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..98dd309 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,22 @@ +name: Deploy +on: + push: + branches: [main] + +permissions: + id-token: write + contents: read + +concurrency: + group: deploy + cancel-in-progress: false + +jobs: + deploy: + uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@main + with: + stack-name: front-integrations + 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/.gitignore b/.gitignore new file mode 100644 index 0000000..bbb6e3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.aws-sam/ +__pycache__/ +*.pyc +.env +samconfig.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce985a7 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# front-integrations + +Front platform integrations for Sea Haven Industries. Two scheduled Lambdas: + +1. **SLA Monitor** — checks Front conversations for SLA breaches and sends tiered Slack alerts +2. **User Sync** — pulls Google Workspace user profiles and syncs job title + phone to Front teammate custom fields + +## Architecture + +``` +EventBridge (every 15 min, 8 AM-5 PM ET, Mon-Fri) + | + v +front-sla-monitor (Python 3.12, arm64) + | + +-- Secrets Manager --> front-integrations/front-api-token + +-- Secrets Manager --> front-integrations/slack-bot-token + | + +-- GET Front API /inboxes --> filter to configured inboxes + +-- GET Front API /inboxes/{id}/conversations --> open conversations + | + +-- DynamoDB (front-sla-alerts) --> dedup + first-run-of-day detection + | + +-- Morning (first run) --> summary to #front-sla-alerts + +-- Tier 1 (1 hr) --> Slack DM assignee or #front-sla-alerts + +-- Tier 2 (1 day) --> Slack DM Adam + + +EventBridge (weekdays 6:00 AM ET) + | + v +front-user-sync (Python 3.12, arm64) + | + +-- Secrets Manager --> front-integrations/google-service-account + +-- Secrets Manager --> front-integrations/front-api-token + | + +-- GET Google Admin Directory API --> list users in target OUs + +-- PATCH Front API /teammates/alt:email:{email} --> update custom_fields +``` + +## SLA Rules + +| Tier | Threshold | Action | +|---|---|---| +| 1 | 1 business hour without reply | Slack DM the assignee, or post to #front-sla-alerts if unassigned | +| 2 | 1 business day without reply | Slack DM Adam | + +Business time counts weekday hours only (Mon-Fri, Eastern time). Alerts are only sent during business hours (8 AM-5 PM ET). Overnight breaches produce a single morning summary. + +## AWS Resources + +- **Stack:** `front-integrations` (SAM, us-east-1) +- **Lambda:** `front-sla-monitor` — Python 3.12, arm64, 256 MB, 300s timeout, 60-day log retention +- **Lambda:** `front-user-sync` — Python 3.12, arm64, 256 MB, 300s timeout, 60-day log retention +- **DynamoDB:** `front-sla-alerts` — alert history per conversation + monitor state, 7-day TTL +- **EventBridge:** SLA check every 15 min during business hours; user sync daily at 6 AM ET weekdays + +## Secrets (Secrets Manager) + +| Secret | Purpose | +|---|---| +| `front-integrations/front-api-token` | Front API token (shared by both Lambdas) | +| `front-integrations/slack-bot-token` | Slack Bot User OAuth Token for SLA alerts | +| `front-integrations/google-service-account` | Google Cloud service account JSON key | + +## Setup + +### 1. Create the Slack App + +1. Go to https://api.slack.com/apps and create **Front SLA Monitor** (see `slack-app-manifest.yaml`) +2. Add Bot Token Scopes: `chat:write`, `users:read`, `users:read.email` +3. Install to workspace, copy Bot User OAuth Token +4. Create `#front-sla-alerts` channel and invite the bot + +### 2. Create a Front API Token + +1. Front > Settings > Developers > API tokens +2. Create a token with conversation read + teammate read/write scope + +### 3. Set Up Google Workspace Service Account + +1. Enable **Admin SDK API** in Google Cloud Console +2. Create service account `front-directory-sync`, create JSON key +3. Enable Domain-Wide Delegation, copy Client ID +4. In Google Workspace Admin: Security > API Controls > Domain-Wide Delegation +5. Add Client ID with scope: `https://www.googleapis.com/auth/admin.directory.user.readonly` + +### 4. Create Custom Fields in Front + +1. Settings > Custom Fields > Teammates tab +2. Create: **Job Title** (String) and **Phone** (String) + +### 5. Store Secrets in AWS + +```bash +aws secretsmanager create-secret \ + --name "front-integrations/front-api-token" \ + --secret-string "YOUR_FRONT_API_TOKEN" \ + --region us-east-1 + +aws secretsmanager create-secret \ + --name "front-integrations/slack-bot-token" \ + --secret-string "xoxb-YOUR-SLACK-BOT-TOKEN" \ + --region us-east-1 + +aws secretsmanager create-secret \ + --name "front-integrations/google-service-account" \ + --secret-string file://path-to-service-account-key.json \ + --region us-east-1 +``` + +### 6. Deploy + +```bash +sam build +sam deploy --guided +``` + +Or push to `main` to trigger the GitHub Actions deploy workflow. + +### 7. GitHub Actions Secrets + +| Secret | Value | +|---|---| +| `AWS_DEPLOY_ROLE_ARN` | Org-wide OIDC deploy role (set after OIDC role is added) | +| `SAM_PARAMETER_OVERRIDES` | `FrontApiTokenSecretArn=arn:... SlackBotTokenSecretArn=arn:... GoogleServiceAccountSecretArn=arn:... SlackAlertChannel=CXXXXXXXXXX` | + +## Manual Testing + +```bash +aws lambda invoke --function-name front-sla-monitor --payload '{}' /dev/stdout --region us-east-1 +aws lambda invoke --function-name front-user-sync --payload '{}' /dev/stdout --region us-east-1 +``` + +## Configuration + +### SLA Monitor + +| Parameter | Default | Description | +|---|---|---| +| `AckSlaMinutes` | 60 | Business minutes before Tier 1 alert | +| `ActionSlaMinutes` | 1440 | Business minutes before Tier 2 alert | +| `AdamEmail` | adam@seahavenind.com | Tier 2 escalation recipient | +| `SlackAlertChannel` | — | Channel ID for broadcast alerts | +| `MonitorInboxes` | Triage,California,... | Inbox names to monitor (empty = all shared) | +| `SlaMonitorStartDate` | 2026-05-14 | Date monitoring begins | + +### User Sync + +| Parameter | Default | Description | +|---|---|---| +| `GoogleAdminEmail` | adam@seahavenind.com | Google Workspace admin to impersonate | +| `GoogleOrgUnits` | /Office/Scheduling,/Office/Operations | Org unit paths to sync | diff --git a/samconfig.toml.example b/samconfig.toml.example new file mode 100644 index 0000000..d6abfa6 --- /dev/null +++ b/samconfig.toml.example @@ -0,0 +1,18 @@ +version = 0.1 + +[default.deploy.parameters] +stack_name = "front-integrations" +resolve_s3 = true +s3_prefix = "front-integrations" +region = "us-east-1" +capabilities = "CAPABILITY_IAM" +confirm_changeset = true +parameter_overrides = [ + "FrontApiTokenSecretArn=arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:front-integrations/front-api-token-XXXXXX", + "SlackBotTokenSecretArn=arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:front-integrations/slack-bot-token-XXXXXX", + "GoogleServiceAccountSecretArn=arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:front-integrations/google-service-account-XXXXXX", + "SlackAlertChannel=CXXXXXXXXXX", +] + +[default.build.parameters] +use_container = false diff --git a/slack-app-manifest.yaml b/slack-app-manifest.yaml new file mode 100644 index 0000000..8c33177 --- /dev/null +++ b/slack-app-manifest.yaml @@ -0,0 +1,16 @@ +display_information: + name: Front SLA Monitor + description: Monitors Front conversations for SLA breaches and sends tiered Slack alerts + background_color: "#d32f2f" + +features: + bot_user: + display_name: Front SLA Monitor + always_online: true + +oauth_config: + scopes: + bot: + - chat:write + - users:read + - users:read.email diff --git a/src/sla_monitor/app.py b/src/sla_monitor/app.py new file mode 100644 index 0000000..d93d5f7 --- /dev/null +++ b/src/sla_monitor/app.py @@ -0,0 +1,421 @@ +import json +import logging +import os +import time +from datetime import datetime, timedelta, timezone +from urllib import error, parse, request +from zoneinfo import ZoneInfo + +import boto3 + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +EASTERN = ZoneInfo("America/New_York") +FRONT_BASE = "https://api2.frontapp.com" +SLACK_BASE = "https://slack.com/api" +RATE_LIMIT_DELAY = 0.6 +BH_START = 8 +BH_END = 17 + +_sm = boto3.client("secretsmanager") +_ddb = boto3.resource("dynamodb") +_table = None +_front_token = None +_slack_token = None +_slack_user_cache = {} + + +def _get_table(): + global _table + if _table is None: + _table = _ddb.Table(os.environ["TABLE_NAME"]) + return _table + + +def _get_front_token(): + global _front_token + if _front_token is None: + resp = _sm.get_secret_value(SecretId=os.environ["FRONT_SECRET_NAME"]) + _front_token = resp["SecretString"] + return _front_token + + +def _get_slack_token(): + global _slack_token + if _slack_token is None: + resp = _sm.get_secret_value(SecretId=os.environ["SLACK_SECRET_NAME"]) + _slack_token = resp["SecretString"] + return _slack_token + + +# --------------------------------------------------------------------------- +# Front API +# --------------------------------------------------------------------------- + +def _front_get(url_or_path, params=None): + if url_or_path.startswith("http"): + url = url_or_path + else: + url = f"{FRONT_BASE}{url_or_path}" + if params: + url += "?" + parse.urlencode(params, doseq=True) + + req = request.Request(url, headers={ + "Authorization": f"Bearer {_get_front_token()}", + "Accept": "application/json", + }) + + time.sleep(RATE_LIMIT_DELAY) + + try: + with request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + except error.HTTPError as e: + body = e.read().decode() if e.fp else "" + raise RuntimeError(f"Front GET {url_or_path} failed ({e.code}): {body}") + + +MAX_PAGES = 10 + + +def _front_paginate(path, params=None): + results = [] + data = _front_get(path, params) + results.extend(data.get("_results", [])) + pages = 1 + + while data.get("_pagination", {}).get("next") and pages < MAX_PAGES: + data = _front_get(data["_pagination"]["next"]) + results.extend(data.get("_results", [])) + pages += 1 + + return results + + +# --------------------------------------------------------------------------- +# Slack API +# --------------------------------------------------------------------------- + +def _slack_post(method, payload): + data = json.dumps(payload).encode() + req = request.Request(f"{SLACK_BASE}/{method}", data=data, headers={ + "Content-Type": "application/json; charset=utf-8", + "Authorization": f"Bearer {_get_slack_token()}", + }) + + try: + with request.urlopen(req) as resp: + result = json.loads(resp.read().decode()) + if not result.get("ok"): + raise RuntimeError(f"Slack {method}: {result.get('error')}") + return result + except error.HTTPError as e: + body = e.read().decode() if e.fp else "" + raise RuntimeError(f"Slack {method} HTTP {e.code}: {body}") + + +def _slack_get(method, params): + url = f"{SLACK_BASE}/{method}?" + parse.urlencode(params) + req = request.Request(url, headers={ + "Authorization": f"Bearer {_get_slack_token()}", + }) + + try: + with request.urlopen(req) as resp: + result = json.loads(resp.read().decode()) + if not result.get("ok"): + raise RuntimeError(f"Slack {method}: {result.get('error')}") + return result + except error.HTTPError as e: + body = e.read().decode() if e.fp else "" + raise RuntimeError(f"Slack {method} HTTP {e.code}: {body}") + + +def _resolve_slack_user(email): + if email in _slack_user_cache: + return _slack_user_cache[email] + try: + result = _slack_get("users.lookupByEmail", {"email": email}) + uid = result["user"]["id"] + _slack_user_cache[email] = uid + return uid + except RuntimeError: + logger.warning("Could not resolve Slack user for %s", email) + return None + + +def _send_slack(channel, blocks, text): + _slack_post("chat.postMessage", { + "channel": channel, + "blocks": blocks, + "text": text, + }) + + +# --------------------------------------------------------------------------- +# Business time calculation (weekdays only, Eastern time) +# --------------------------------------------------------------------------- + +def _business_minutes_elapsed(since_utc, now_utc): + since = since_utc.astimezone(EASTERN) + now = now_utc.astimezone(EASTERN) + + if since >= now: + return 0 + + total = 0.0 + current = since + + while current < now: + if current.weekday() < 5: + end_of_day = (current + timedelta(days=1)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + day_end = min(end_of_day, now) + total += (day_end - current).total_seconds() / 60 + + current = (current + timedelta(days=1)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + + return total + + +# --------------------------------------------------------------------------- +# DynamoDB dedup +# --------------------------------------------------------------------------- + +def _already_alerted(conv_id, tier): + resp = _get_table().get_item(Key={"conversationId": conv_id}) + item = resp.get("Item") + if not item: + return False + return f"tier{tier}AlertedAt" in item + + +def _record_alert(conv_id, tier): + now_iso = datetime.now(timezone.utc).isoformat() + ttl_epoch = int(time.time()) + 7 * 86400 + + _get_table().update_item( + Key={"conversationId": conv_id}, + UpdateExpression="SET #ttl = :ttl, #alert = :ts", + ExpressionAttributeNames={ + "#ttl": "ttl", + "#alert": f"tier{tier}AlertedAt", + }, + ExpressionAttributeValues={ + ":ttl": ttl_epoch, + ":ts": now_iso, + }, + ) + + +def _is_first_run_today(today_str): + resp = _get_table().get_item(Key={"conversationId": "_monitor_state"}) + return resp.get("Item", {}).get("lastRunDate", "") != today_str + + +def _record_run(today_str): + _get_table().update_item( + Key={"conversationId": "_monitor_state"}, + UpdateExpression="SET lastRunDate = :d", + ExpressionAttributeValues={":d": today_str}, + ) + + +# --------------------------------------------------------------------------- +# Slack message blocks +# --------------------------------------------------------------------------- + +def _tier1_blocks(conv, assignee_email=None): + subject = conv.get("subject", "No subject") + conv_id = conv.get("id", "") + link = f"https://app.frontapp.com/open/{conv_id}" + msg_at = conv.get("last_message", {}).get("created_at", 0) + ts = datetime.fromtimestamp(msg_at, tz=EASTERN).strftime("%b %d, %I:%M %p ET") + status = f"Assigned to {assignee_email}" if assignee_email else "Unassigned" + + return [ + {"type": "header", "text": {"type": "plain_text", "text": ":warning: SLA Breach: 1-Hour Acknowledgment"}}, + {"type": "section", "text": {"type": "mrkdwn", "text": ( + f"*<{link}|{subject}>*\n" + f"Last inbound: {ts}\n" + f"Status: {status}\n" + f"_No reply for over 1 business hour._" + )}}, + ] + + +def _tier2_blocks(conv): + subject = conv.get("subject", "No subject") + conv_id = conv.get("id", "") + link = f"https://app.frontapp.com/open/{conv_id}" + msg_at = conv.get("last_message", {}).get("created_at", 0) + ts = datetime.fromtimestamp(msg_at, tz=EASTERN).strftime("%b %d, %I:%M %p ET") + assignee = conv.get("assignee") + status = f"Assigned to {assignee['email']}" if assignee else "Unassigned" + + return [ + {"type": "header", "text": {"type": "plain_text", "text": ":rotating_light: SLA Breach: 1-Day Action Required"}}, + {"type": "section", "text": {"type": "mrkdwn", "text": ( + f"*<{link}|{subject}>*\n" + f"Last inbound: {ts}\n" + f"Status: {status}\n" + f"_No reply for over 1 business day. Immediate attention required._" + )}}, + ] + + +def _summary_blocks(tier1_breaches, tier2_breaches): + lines = [] + + if tier2_breaches: + lines.append("*:rotating_light: Over 1 Business Day Without Reply*") + for conv in tier2_breaches: + subject = conv.get("subject", "No subject") + link = f"https://app.frontapp.com/open/{conv.get('id', '')}" + assignee = conv.get("assignee") + who = assignee["email"] if assignee else "Unassigned" + lines.append(f"• <{link}|{subject}> — {who}") + lines.append("") + + if tier1_breaches: + lines.append("*:warning: Over 1 Business Hour Without Reply*") + for conv, email in tier1_breaches: + subject = conv.get("subject", "No subject") + link = f"https://app.frontapp.com/open/{conv.get('id', '')}" + who = email or "Unassigned" + lines.append(f"• <{link}|{subject}> — {who}") + + total = len(tier1_breaches) + len(tier2_breaches) + return [ + {"type": "header", "text": {"type": "plain_text", "text": f":sunrise: Morning SLA Summary — {total} breach{'es' if total != 1 else ''}"}}, + {"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}, + ] + + +# --------------------------------------------------------------------------- +# Handler +# --------------------------------------------------------------------------- + +def handler(event, context): + ack_threshold = int(os.environ["ACK_SLA_MINUTES"]) + action_threshold = int(os.environ["ACTION_SLA_MINUTES"]) + alert_channel = os.environ["SLACK_ALERT_CHANNEL"] + adam_email = os.environ["ADAM_EMAIL"] + start_date = os.environ.get("START_DATE", "") + + now = datetime.now(timezone.utc) + now_et = now.astimezone(EASTERN) + + if start_date: + start = datetime.strptime(start_date, "%Y-%m-%d").date() + if now_et.date() < start: + logger.info("Before start date %s, skipping", start_date) + return {"skipped": True, "reason": "before_start_date"} + + if now_et.hour < BH_START or now_et.hour >= BH_END: + logger.info("Outside business hours (%s ET), skipping", now_et.strftime("%H:%M")) + return {"skipped": True, "reason": "outside_business_hours"} + + today_str = now_et.strftime("%Y-%m-%d") + first_run = _is_first_run_today(today_str) + _record_run(today_str) + + monitor_names = os.environ.get("MONITOR_INBOXES", "").strip() + if monitor_names: + monitor_set = {n.strip().lower() for n in monitor_names.split(",")} + else: + monitor_set = None + + inboxes = _front_paginate("/inboxes") + shared = [i for i in inboxes if not i.get("is_private", False)] + + if monitor_set: + shared = [i for i in shared if i.get("name", "").lower() in monitor_set] + + logger.info("Monitoring %d inboxes: %s", len(shared), + ", ".join(i.get("name", "?") for i in shared)) + + tier1_breaches = [] + tier2_breaches = [] + seen = set() + + for inbox in shared: + inbox_id = inbox["id"] + inbox_name = inbox.get("name", inbox_id) + + try: + conversations = _front_paginate( + f"/inboxes/{inbox_id}/conversations", + params={"q[statuses][]": ["open", "unassigned"]}, + ) + except RuntimeError as e: + logger.error("Failed to fetch conversations for %s: %s", inbox_name, e) + continue + + logger.info("Inbox '%s': %d recent open conversations", inbox_name, len(conversations)) + + for conv in conversations: + if conv.get("id") in seen: + continue + seen.add(conv.get("id")) + try: + last_msg = conv.get("last_message") + if not last_msg or last_msg.get("type") != "inbound": + continue + + msg_dt = datetime.fromtimestamp(last_msg["created_at"], tz=timezone.utc) + elapsed = _business_minutes_elapsed(msg_dt, now) + conv_id = conv["id"] + assignee = conv.get("assignee") + + if elapsed >= action_threshold and not _already_alerted(conv_id, 2): + tier2_breaches.append(conv) + logger.info("Tier 2 breach: %s", conv_id) + + elif elapsed >= ack_threshold and not _already_alerted(conv_id, 1): + email = assignee["email"] if assignee and assignee.get("email") else None + tier1_breaches.append((conv, email)) + logger.info("Tier 1 breach: %s", conv_id) + + except Exception: + logger.exception("Error processing conversation %s", conv.get("id", "?")) + + total = len(tier1_breaches) + len(tier2_breaches) + + if first_run and total > 1: + logger.info("Morning summary: %d breaches accumulated overnight", total) + blocks = _summary_blocks(tier1_breaches, tier2_breaches) + _send_slack(alert_channel, blocks, f"Morning SLA Summary: {total} breaches") + if tier2_breaches: + adam_uid = _resolve_slack_user(adam_email) + if adam_uid: + _send_slack(adam_uid, blocks, f"Morning SLA Summary: {total} breaches") + else: + for conv in tier2_breaches: + adam_uid = _resolve_slack_user(adam_email) + target = adam_uid or alert_channel + _send_slack(target, _tier2_blocks(conv), + f"SLA Breach: {conv.get('subject', '')} - 1 day without reply") + + for conv, email in tier1_breaches: + if email: + uid = _resolve_slack_user(email) + target = uid or alert_channel + _send_slack(target, _tier1_blocks(conv, email), + f"SLA Breach: {conv.get('subject', '')} - 1 hour without reply") + else: + _send_slack(alert_channel, _tier1_blocks(conv), + f"SLA Breach: {conv.get('subject', '')} - unassigned, 1 hour without reply") + + for conv in tier2_breaches: + _record_alert(conv["id"], 2) + for conv, _ in tier1_breaches: + _record_alert(conv["id"], 1) + + result = {"tier1_alerts": len(tier1_breaches), "tier2_alerts": len(tier2_breaches)} + logger.info("Run complete: %s", result) + return result diff --git a/src/sla_monitor/requirements.txt b/src/sla_monitor/requirements.txt new file mode 100644 index 0000000..30ddf82 --- /dev/null +++ b/src/sla_monitor/requirements.txt @@ -0,0 +1 @@ +boto3 diff --git a/src/user_sync/app.py b/src/user_sync/app.py new file mode 100644 index 0000000..3ecabbb --- /dev/null +++ b/src/user_sync/app.py @@ -0,0 +1,184 @@ +import json +import logging +import os +import time +from urllib import error, parse, request + +import boto3 +from google.oauth2 import service_account +from googleapiclient.discovery import build + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +FRONT_BASE = "https://api2.frontapp.com" +SCOPES = ["https://www.googleapis.com/auth/admin.directory.user.readonly"] +RATE_LIMIT_DELAY = 0.2 + +_sm = boto3.client("secretsmanager") +_front_token = None +_directory_service = None + + +def _get_front_token(): + global _front_token + if _front_token is None: + resp = _sm.get_secret_value(SecretId=os.environ["FRONT_SECRET_NAME"]) + _front_token = resp["SecretString"] + return _front_token + + +def _get_directory_service(): + global _directory_service + if _directory_service is None: + resp = _sm.get_secret_value(SecretId=os.environ["GOOGLE_SECRET_NAME"]) + key_data = json.loads(resp["SecretString"]) + creds = service_account.Credentials.from_service_account_info( + key_data, + scopes=SCOPES, + subject=os.environ["GOOGLE_ADMIN_EMAIL"], + ) + _directory_service = build("admin", "directory_v1", credentials=creds) + return _directory_service + + +def _list_google_users(org_units): + service = _get_directory_service() + all_users = [] + seen = set() + + for ou in org_units: + logger.info("Fetching users from OU: %s", ou) + page_token = None + + while True: + result = service.users().list( + customer="my_customer", + maxResults=500, + projection="full", + orderBy="email", + query=f"orgUnitPath='{ou}'", + pageToken=page_token, + ).execute() + + for user in result.get("users", []): + email = user.get("primaryEmail", "") + if email not in seen: + seen.add(email) + all_users.append(user) + + page_token = result.get("nextPageToken") + if not page_token: + break + + logger.info(" Found %d users in %s", len([u for u in all_users if u.get("primaryEmail") in seen]), ou) + + return all_users + + +def _extract_user_fields(google_user): + email = google_user.get("primaryEmail", "") + + job_title = "" + orgs = google_user.get("organizations", []) + if orgs: + primary_org = next((o for o in orgs if o.get("primary")), orgs[0]) + job_title = primary_org.get("title", "") + + phone = "" + phones = google_user.get("phones", []) + if phones: + work_phone = next((p for p in phones if p.get("type") == "work" and p.get("value")), None) + primary_phone = next((p for p in phones if p.get("primary") and p.get("value")), None) + fallback = next((p for p in phones if p.get("value")), None) + phone = (work_phone or primary_phone or fallback or {}).get("value", "") + + return email, job_title, phone + + +def _front_request(method, url_path, body=None): + url = f"{FRONT_BASE}{url_path}" + headers = { + "Authorization": f"Bearer {_get_front_token()}", + "Accept": "application/json", + } + + data = None + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + + req = request.Request(url, data=data, headers=headers, method=method) + + try: + with request.urlopen(req) as resp: + if resp.status == 204: + return {"status": "updated"} + return json.loads(resp.read().decode()) + except error.HTTPError as e: + if e.code == 404: + return None + body_text = e.read().decode() if e.fp else "" + raise RuntimeError(f"Front {method} {url_path} failed ({e.code}): {body_text}") + + +def _update_front_teammate(email, custom_fields): + encoded = parse.quote(email, safe="") + path = f"/teammates/alt:email:{encoded}" + + teammate = _front_request("GET", path) + if teammate is None: + return "not_in_front" + + merged = {**teammate.get("custom_fields", {}), **custom_fields} + result = _front_request("PATCH", path, {"custom_fields": merged}) + + if result and result.get("status") == "updated": + return "updated" + return "updated" + + +def handler(event, context): + logger.info("Starting Front <- Google Directory sync") + + org_units = [ou.strip() for ou in os.environ["GOOGLE_OUS"].split(",") if ou.strip()] + logger.info("Syncing OUs: %s", ", ".join(org_units)) + + google_users = _list_google_users(org_units) + logger.info("Found %d total users across %d OUs", len(google_users), len(org_units)) + + summary = {"updated": 0, "not_in_front": 0, "no_data": 0, "errors": 0, "error_details": []} + + for user in google_users: + email, job_title, phone = _extract_user_fields(user) + + if not job_title and not phone: + logger.info("Skipping %s - no title or phone in Google", email) + summary["no_data"] += 1 + continue + + custom_fields = {} + if job_title: + custom_fields["Job Title"] = job_title + if phone: + custom_fields["Phone"] = phone + + try: + result = _update_front_teammate(email, custom_fields) + + if result == "updated": + logger.info("Updated %s: %s", email, json.dumps(custom_fields)) + summary["updated"] += 1 + elif result == "not_in_front": + logger.info("Skipped %s - not a Front teammate", email) + summary["not_in_front"] += 1 + + except Exception as e: + logger.error("Error updating %s: %s", email, e) + summary["errors"] += 1 + summary["error_details"].append({"email": email, "message": str(e)}) + + time.sleep(RATE_LIMIT_DELAY) + + logger.info("Sync summary: %s", json.dumps(summary)) + return {"statusCode": 200, "body": summary} diff --git a/src/user_sync/requirements.txt b/src/user_sync/requirements.txt new file mode 100644 index 0000000..064de2b --- /dev/null +++ b/src/user_sync/requirements.txt @@ -0,0 +1,3 @@ +boto3 +google-auth +google-api-python-client diff --git a/template.yaml b/template.yaml new file mode 100644 index 0000000..81f40aa --- /dev/null +++ b/template.yaml @@ -0,0 +1,166 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Front platform integrations — SLA monitoring and Google Workspace user sync + +Parameters: + FrontApiTokenSecretArn: + Type: String + Description: ARN of the Secrets Manager secret containing the Front API token + SlackBotTokenSecretArn: + Type: String + Description: ARN of the Secrets Manager secret containing the Slack bot token + GoogleServiceAccountSecretArn: + Type: String + Description: ARN of the Secrets Manager secret containing the Google service account JSON key + SlackAlertChannel: + Type: String + Description: Slack channel ID for the front-sla-alerts channel + AdamEmail: + Type: String + Default: adam@seahavenind.com + Description: Email address for Tier 2 escalation + AckSlaMinutes: + Type: Number + Default: 60 + Description: Business minutes before Tier 1 alert (1 hour) + ActionSlaMinutes: + Type: Number + Default: 1440 + Description: Business minutes before Tier 2 alert (1 business day) + MonitorInboxes: + Type: String + Default: "Triage,California,West Coast,Central,East Coast,Vendors" + Description: Comma-separated inbox names to monitor (empty = all shared) + SlaMonitorStartDate: + Type: String + Default: "2026-05-14" + Description: Date when SLA monitoring begins (YYYY-MM-DD, Eastern time) + GoogleAdminEmail: + Type: String + Default: adam@seahavenind.com + Description: Google Workspace admin email to impersonate for Directory API + GoogleOrgUnits: + Type: String + Default: "/Office/Scheduling,/Office/Operations" + Description: Comma-separated Google Workspace org unit paths to sync + +Globals: + Function: + Runtime: python3.12 + Timeout: 300 + MemorySize: 256 + Architectures: + - arm64 + +Resources: + # --------------------------------------------------------------------------- + # SLA Monitor + # --------------------------------------------------------------------------- + AlertsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: front-sla-alerts + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: conversationId + AttributeType: S + KeySchema: + - AttributeName: conversationId + KeyType: HASH + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + + SlaMonitorLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: /aws/lambda/front-sla-monitor + RetentionInDays: 60 + + SlaMonitorFunction: + Type: AWS::Serverless::Function + DependsOn: SlaMonitorLogGroup + Properties: + FunctionName: front-sla-monitor + Handler: app.handler + CodeUri: src/sla_monitor/ + Environment: + Variables: + FRONT_SECRET_NAME: !Ref FrontApiTokenSecretArn + SLACK_SECRET_NAME: !Ref SlackBotTokenSecretArn + SLACK_ALERT_CHANNEL: !Ref SlackAlertChannel + ADAM_EMAIL: !Ref AdamEmail + TABLE_NAME: !Ref AlertsTable + ACK_SLA_MINUTES: !Ref AckSlaMinutes + ACTION_SLA_MINUTES: !Ref ActionSlaMinutes + MONITOR_INBOXES: !Ref MonitorInboxes + START_DATE: !Ref SlaMonitorStartDate + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref AlertsTable + - Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: + - !Ref FrontApiTokenSecretArn + - !Ref SlackBotTokenSecretArn + Events: + SlaCheck: + Type: Schedule + Properties: + Schedule: cron(0/15 12-22 ? * MON-FRI *) + Description: Check Front conversations for SLA breaches every 15 min during business hours + Enabled: true + + # --------------------------------------------------------------------------- + # Google User Sync + # --------------------------------------------------------------------------- + UserSyncLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: /aws/lambda/front-user-sync + RetentionInDays: 60 + + UserSyncFunction: + Type: AWS::Serverless::Function + DependsOn: UserSyncLogGroup + Properties: + FunctionName: front-user-sync + Handler: app.handler + CodeUri: src/user_sync/ + Timeout: 300 + Environment: + Variables: + GOOGLE_SECRET_NAME: !Ref GoogleServiceAccountSecretArn + FRONT_SECRET_NAME: !Ref FrontApiTokenSecretArn + GOOGLE_ADMIN_EMAIL: !Ref GoogleAdminEmail + GOOGLE_OUS: !Ref GoogleOrgUnits + Policies: + - Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: + - !Ref GoogleServiceAccountSecretArn + - !Ref FrontApiTokenSecretArn + Events: + DailySync: + Type: Schedule + Properties: + Schedule: cron(0 11 ? * MON-FRI *) + Description: Sync Google Workspace user profiles to Front daily at 6 AM ET + Enabled: true + +Outputs: + SlaMonitorFunctionArn: + Description: Front SLA Monitor Lambda ARN + Value: !GetAtt SlaMonitorFunction.Arn + UserSyncFunctionArn: + Description: Front User Sync Lambda ARN + Value: !GetAtt UserSyncFunction.Arn + AlertsTableName: + Description: DynamoDB alerts table name + Value: !Ref AlertsTable