mirror of
https://github.com/Sea-Haven-Industries/front-integrations.git
synced 2026-09-30 04:53:11 +00:00
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.
This commit is contained in:
commit
899f07d09c
12 changed files with 1019 additions and 0 deletions
20
.github/dependabot.yml
vendored
Normal file
20
.github/dependabot.yml
vendored
Normal file
|
|
@ -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"
|
||||||
10
.github/workflows/ci.yaml
vendored
Normal file
10
.github/workflows/ci.yaml
vendored
Normal file
|
|
@ -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"
|
||||||
22
.github/workflows/deploy.yaml
vendored
Normal file
22
.github/workflows/deploy.yaml
vendored
Normal file
|
|
@ -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 }}
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.aws-sam/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
samconfig.toml
|
||||||
153
README.md
Normal file
153
README.md
Normal file
|
|
@ -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 |
|
||||||
18
samconfig.toml.example
Normal file
18
samconfig.toml.example
Normal file
|
|
@ -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
|
||||||
16
slack-app-manifest.yaml
Normal file
16
slack-app-manifest.yaml
Normal file
|
|
@ -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
|
||||||
421
src/sla_monitor/app.py
Normal file
421
src/sla_monitor/app.py
Normal file
|
|
@ -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
|
||||||
1
src/sla_monitor/requirements.txt
Normal file
1
src/sla_monitor/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
boto3
|
||||||
184
src/user_sync/app.py
Normal file
184
src/user_sync/app.py
Normal file
|
|
@ -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}
|
||||||
3
src/user_sync/requirements.txt
Normal file
3
src/user_sync/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
boto3
|
||||||
|
google-auth
|
||||||
|
google-api-python-client
|
||||||
166
template.yaml
Normal file
166
template.yaml
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue