mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 10:13:11 +00:00
Compare commits
41 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54ad5a7e34 | ||
|
|
3030b134fa | ||
|
|
9ca1ef48bd | ||
|
|
e9893a6f7b | ||
|
|
edfa34bfbf | ||
|
|
470e00affb | ||
|
|
7b5009fb55 | ||
|
|
b3fa705d8c | ||
|
|
5981776178 | ||
|
|
5a173e911e | ||
|
|
9dd1dfc4c1 | ||
|
|
01c4902985 | ||
|
|
8a23d06a64 | ||
|
|
e600dd47a3 | ||
|
|
682f272c5a | ||
|
|
b8079a1707 | ||
|
|
4988fbe706 | ||
|
|
c611fd5d2b | ||
|
|
df3f0c037d | ||
|
|
26e9716df4 | ||
|
|
be1160192c | ||
|
|
cbda46ba87 | ||
|
|
6c4018d6b8 | ||
|
|
1362a6cd90 | ||
|
|
d49c4bb4f2 | ||
|
|
593cab66ef | ||
|
|
26adb8e6c0 | ||
|
|
969ebf90de | ||
|
|
f1695cadfa | ||
|
|
ea2910906f | ||
|
|
0690767509 | ||
|
|
9d43e3be9d | ||
|
|
b53d856a75 | ||
|
|
9544dd696a | ||
|
|
7a513b30ca | ||
|
|
13350b72d0 | ||
|
|
dbef3bda52 | ||
|
|
2dbe99ef0b | ||
|
|
23bad9bee6 | ||
|
|
37f12421fd | ||
|
|
c52cfc2d0e |
125 changed files with 9922 additions and 2291 deletions
12
.dockerignore
Normal file
12
.dockerignore
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.venv
|
||||||
|
.cursor
|
||||||
|
terraform
|
||||||
|
tests
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
!src/slack-bot/CHANGELOG.md
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
38
.github/workflows/changelog-guard.yml
vendored
38
.github/workflows/changelog-guard.yml
vendored
|
|
@ -1,38 +0,0 @@
|
||||||
name: Changelog Guard
|
|
||||||
|
|
||||||
# Repo-specific PR check (in addition to the reusable ci.yaml). Enforces that
|
|
||||||
# CHANGELOG.md drives versioning correctly: a changelog edit must be a clean
|
|
||||||
# SemVer bump above the latest tag, and the in-package copy must stay in sync.
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
guard:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
fetch-tags: true
|
|
||||||
|
|
||||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Validate CHANGELOG + version bump
|
|
||||||
env:
|
|
||||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
||||||
run: |
|
|
||||||
if git diff --name-only "$BASE_SHA" HEAD | grep -qx 'CHANGELOG.md'; then
|
|
||||||
CHANGELOG_CHANGED=true
|
|
||||||
else
|
|
||||||
CHANGELOG_CHANGED=false
|
|
||||||
fi
|
|
||||||
PREV_TAG=$(git tag -l 'v*' --sort=-v:refname | head -1)
|
|
||||||
echo "CHANGELOG changed in PR: $CHANGELOG_CHANGED | latest tag: ${PREV_TAG:-none}"
|
|
||||||
CHANGELOG_CHANGED="$CHANGELOG_CHANGED" PREV_TAG="$PREV_TAG" \
|
|
||||||
python scripts/check_changelog.py
|
|
||||||
101
.github/workflows/ci.yaml
vendored
101
.github/workflows/ci.yaml
vendored
|
|
@ -1,15 +1,106 @@
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, hotfix/**, release/**]
|
||||||
merge_group:
|
merge_group:
|
||||||
|
push:
|
||||||
|
branches: [hotfix/**, release/**]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
autofix:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
|
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/ring-scheduler src/shared/shared tests"
|
presets: ruff,terraform
|
||||||
run-tests: true
|
terraform-version: "1.16.0"
|
||||||
|
|
||||||
|
lint:
|
||||||
|
needs: autofix
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
test:
|
||||||
|
needs: autofix
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install test dependencies
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r tests/requirements.txt
|
||||||
|
pip install -r src/slack-bot/requirements.txt
|
||||||
|
pip install -r src/weekly-post/requirements.txt
|
||||||
|
pip install -r src/shared/requirements.txt
|
||||||
|
pip install -r src/portal-api/requirements.txt
|
||||||
|
pip install -r requirements-api.txt
|
||||||
|
|
||||||
|
- name: Pytest
|
||||||
|
run: pytest
|
||||||
|
|
||||||
|
terraform:
|
||||||
|
needs: autofix
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
|
with:
|
||||||
|
terraform-version: "1.16.0"
|
||||||
|
|
||||||
|
openapi:
|
||||||
|
name: OpenAPI
|
||||||
|
needs: autofix
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
with:
|
||||||
|
node-version: "24.19.0"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install JavaScript tooling
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint OpenAPI
|
||||||
|
run: npm run openapi:lint
|
||||||
|
|
||||||
|
ci-complete:
|
||||||
|
name: ci-complete
|
||||||
|
needs: [autofix, lint, test, terraform, openapi]
|
||||||
|
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Require portions
|
||||||
|
env:
|
||||||
|
LINT: ${{ needs.lint.result }}
|
||||||
|
TEST: ${{ needs.test.result }}
|
||||||
|
TERRAFORM: ${{ needs.terraform.result }}
|
||||||
|
OPENAPI: ${{ needs.openapi.result }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test "${LINT}" = success
|
||||||
|
test "${TEST}" = success
|
||||||
|
test "${TERRAFORM}" = success
|
||||||
|
test "${OPENAPI}" = success
|
||||||
|
|
|
||||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -7,4 +7,4 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
review:
|
review:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
|
|
|
||||||
70
.github/workflows/deploy-api.yaml
vendored
Normal file
70
.github/workflows/deploy-api.yaml
vendored
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
name: Deploy API
|
||||||
|
|
||||||
|
# Fargate image CD (PLAT-216). GitHub Actions builds the Flask image, pushes to ECR,
|
||||||
|
# and registers a new task definition. Terraform owns the cluster, service,
|
||||||
|
# ALB, and ignores container_definitions / task_definition.
|
||||||
|
#
|
||||||
|
# push to main -> dev, at github.sha
|
||||||
|
# release: published -> prod, at the release tag
|
||||||
|
# workflow_dispatch -> chosen environment at a chosen ref
|
||||||
|
#
|
||||||
|
# Releases are cut by a human with `gh release create vX.Y.Z --target main`.
|
||||||
|
# Nothing here creates an HCP run.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "terraform/**"
|
||||||
|
- "docs/**"
|
||||||
|
- "*.md"
|
||||||
|
- ".github/workflows/ci.yaml"
|
||||||
|
- ".github/workflows/labeler.yml"
|
||||||
|
- ".github/workflows/dependency-review.yml"
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Target Environment"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options: [dev, prod]
|
||||||
|
ref:
|
||||||
|
description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref."
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-dev:
|
||||||
|
name: Deploy API to dev
|
||||||
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
environment: dev
|
||||||
|
ref: ${{ inputs.ref }}
|
||||||
|
ssm-prefix: /afterhours-shift-manager/deploy
|
||||||
|
docker-platform: linux/arm64
|
||||||
|
|
||||||
|
deploy-prod:
|
||||||
|
name: Deploy API to prod
|
||||||
|
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
environment: prod
|
||||||
|
ref: ${{ github.event.release.tag_name || inputs.ref }}
|
||||||
|
ssm-prefix: /afterhours-shift-manager/deploy
|
||||||
|
docker-platform: linux/arm64
|
||||||
|
ship-gate: true
|
||||||
120
.github/workflows/deploy.yaml
vendored
120
.github/workflows/deploy.yaml
vendored
|
|
@ -1,120 +0,0 @@
|
||||||
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@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
|
|
||||||
with:
|
|
||||||
stack-name: afterhours-shift-manager
|
|
||||||
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 }}
|
|
||||||
|
|
||||||
# Tag + announce a release once the deploy succeeds. This lives in the deploy
|
|
||||||
# workflow (gated on `needs: deploy`) rather than a separate workflow_run-
|
|
||||||
# triggered job on purpose: a push-to-main run is a trusted context, so
|
|
||||||
# checking out and running repo code with write/OIDC is safe here — unlike
|
|
||||||
# workflow_run, which CodeQL (rightly) flags for untrusted checkout + cache
|
|
||||||
# poisoning. Gating on `needs: deploy` still guarantees we never announce a
|
|
||||||
# version that isn't live, and the `deploy` concurrency group serializes
|
|
||||||
# releases. When the top CHANGELOG version already has a Release, this no-ops.
|
|
||||||
release:
|
|
||||||
needs: deploy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write # create the tag + GitHub Release
|
|
||||||
id-token: write # OIDC to assume the notifier-invoke role
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
fetch-tags: true
|
|
||||||
|
|
||||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Determine release
|
|
||||||
id: rel
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
TOP=$(python scripts/changelog_cli.py top-version CHANGELOG.md)
|
|
||||||
if [ -z "$TOP" ]; then
|
|
||||||
echo "No version entry in CHANGELOG.md — nothing to release."
|
|
||||||
echo "release=false" >> "$GITHUB_OUTPUT"; exit 0
|
|
||||||
fi
|
|
||||||
PREV=$(git tag -l 'v*' --sort=-v:refname | head -1)
|
|
||||||
PREV="${PREV:-v0.0.0}"
|
|
||||||
KIND=$(python scripts/changelog_cli.py bump-kind CHANGELOG.md "$PREV")
|
|
||||||
|
|
||||||
RELEASE_EXISTS=false
|
|
||||||
gh release view "v$TOP" >/dev/null 2>&1 && RELEASE_EXISTS=true
|
|
||||||
|
|
||||||
echo "version=$TOP" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "kind=$KIND" >> "$GITHUB_OUTPUT"
|
|
||||||
# Act only on a clean SemVer bump whose Release isn't published yet.
|
|
||||||
if [ "$KIND" != "none" ] && [ "$RELEASE_EXISTS" = "false" ]; then
|
|
||||||
echo "release=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "release=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "v$TOP: kind=$KIND release_exists=$RELEASE_EXISTS — no action."
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build release notes
|
|
||||||
if: ${{ steps.rel.outputs.release == 'true' }}
|
|
||||||
run: |
|
|
||||||
python scripts/changelog_cli.py payload CHANGELOG.md "${{ steps.rel.outputs.version }}" > payload.json
|
|
||||||
python -c "import json; print(json.load(open('payload.json'))['notes'])" > notes.md
|
|
||||||
|
|
||||||
# Announce BEFORE publishing the Release: the Release is the durable "done"
|
|
||||||
# marker (the step above skips once it exists), so announcing first keeps
|
|
||||||
# this retryable. Minor/major only, and only once the invoke-role variable
|
|
||||||
# has been bootstrapped (see README).
|
|
||||||
- name: Configure AWS credentials
|
|
||||||
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
|
|
||||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
|
||||||
with:
|
|
||||||
role-to-assume: ${{ vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN }}
|
|
||||||
aws-region: us-east-1
|
|
||||||
|
|
||||||
- name: Announce in Slack
|
|
||||||
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN != '' }}
|
|
||||||
run: |
|
|
||||||
aws lambda invoke \
|
|
||||||
--function-name afterhours-release-notifier \
|
|
||||||
--cli-binary-format raw-in-base64-out \
|
|
||||||
--payload file://payload.json \
|
|
||||||
--output json response.json > invoke-meta.json
|
|
||||||
# aws lambda invoke only emits a FunctionError key when the handler errored.
|
|
||||||
if grep -q '"FunctionError"' invoke-meta.json; then
|
|
||||||
echo "::error::release-notifier returned an error"; cat response.json; exit 1
|
|
||||||
fi
|
|
||||||
echo "Announced v${{ steps.rel.outputs.version }}."
|
|
||||||
|
|
||||||
- name: Warn if announcement skipped (not bootstrapped)
|
|
||||||
if: ${{ steps.rel.outputs.release == 'true' && steps.rel.outputs.kind != 'patch' && vars.RELEASE_NOTIFY_INVOKE_ROLE_ARN == '' }}
|
|
||||||
run: echo "::warning::RELEASE_NOTIFY_INVOKE_ROLE_ARN is unset — tagging + releasing but not announcing. Set the repo variable from the stack output."
|
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
|
||||||
if: ${{ steps.rel.outputs.release == 'true' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
# gh creates the tag at the deployed commit and the Release together.
|
|
||||||
gh release create "v${{ steps.rel.outputs.version }}" \
|
|
||||||
--repo "${{ github.repository }}" \
|
|
||||||
--title "v${{ steps.rel.outputs.version }}" \
|
|
||||||
--notes-file notes.md \
|
|
||||||
--target "${{ github.sha }}"
|
|
||||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
|
|
@ -10,4 +10,4 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
label:
|
label:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
|
node_modules/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.aws-sam/
|
.aws-sam/
|
||||||
|
|
@ -8,3 +9,6 @@ venv/
|
||||||
samconfig.toml
|
samconfig.toml
|
||||||
output.json
|
output.json
|
||||||
.idea/
|
.idea/
|
||||||
|
build/
|
||||||
|
terraform/.terraform/
|
||||||
|
terraform/build/
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
extends: .github
|
|
||||||
32
.redocly.yaml
Normal file
32
.redocly.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Same Redocly recommended ruleset as internal-portal (DEV-223 / DEV-289).
|
||||||
|
# Recommended operation-2xx-response does not count 302. Login-style redirects
|
||||||
|
# succeed with 302, so that rule is replaced by operation-2xx-or-3xx-response
|
||||||
|
# at error. operation-4xx-response is promoted to error so missing 4xx fails CI.
|
||||||
|
extends:
|
||||||
|
- recommended
|
||||||
|
|
||||||
|
rules:
|
||||||
|
operation-2xx-response: off
|
||||||
|
operation-4xx-response: error
|
||||||
|
rule/operation-2xx-or-3xx-response:
|
||||||
|
subject:
|
||||||
|
type: Responses
|
||||||
|
message: Operation must define a 2XX or 3XX response.
|
||||||
|
severity: error
|
||||||
|
assertions:
|
||||||
|
requireAny:
|
||||||
|
- "200"
|
||||||
|
- "201"
|
||||||
|
- "202"
|
||||||
|
- "204"
|
||||||
|
- "301"
|
||||||
|
- "302"
|
||||||
|
- "303"
|
||||||
|
- "307"
|
||||||
|
- "308"
|
||||||
|
- "2XX"
|
||||||
|
- "3XX"
|
||||||
|
|
||||||
|
apis:
|
||||||
|
afterhours@v1:
|
||||||
|
root: openapi.yaml
|
||||||
|
|
@ -12,10 +12,6 @@ Use one of: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `rele
|
||||||
- **Body sections** (exactly, in order): `Summary`, `Validation`, `Tests`, `Notes`. Use "None." under Notes when empty.
|
- **Body sections** (exactly, in order): `Summary`, `Validation`, `Tests`, `Notes`. Use "None." under Notes when empty.
|
||||||
- State verifiable facts only. Do not justify changes by citing the handbook. No AI-attribution footers.
|
- State verifiable facts only. Do not justify changes by citing the handbook. No AI-attribution footers.
|
||||||
|
|
||||||
## Security and Cross-Review
|
|
||||||
- Sensitive surfaces (payment flows, authentication, secrets handling, untrusted input) require security review.
|
|
||||||
- IAM role, policy, or resource-permission changes require cross-family review. Lambda handler signature changes alone do not.
|
|
||||||
|
|
||||||
## CI and Workflow References
|
## CI and Workflow References
|
||||||
- CI must pass before merge.
|
- CI must pass before merge.
|
||||||
- Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment.
|
- Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment.
|
||||||
|
|
|
||||||
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -10,6 +10,27 @@ fine and still supported.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v1.17.0 — September 25, 2026
|
||||||
|
|
||||||
|
**The work week now runs Sunday through Saturday, matching payroll.** The Monday
|
||||||
|
7am schedule post and pay summary use that week. A Saturday night shift (5pm
|
||||||
|
Saturday through 8am Sunday) stays in the Saturday week. Sunday day and Sunday
|
||||||
|
night open the next week. The first pay close after this change skips any date
|
||||||
|
already sent to Flex, so that Sunday is not paid twice.
|
||||||
|
|
||||||
|
## v1.16.0 — September 21, 2026
|
||||||
|
|
||||||
|
**After Hours is available in the employee portal, and Slack still works.** Employees
|
||||||
|
can pick up, drop, and swap shifts from `internal.seahaven.com`, and admins can
|
||||||
|
override coverage, open or clear a shift, manage holidays, and approve late
|
||||||
|
pickups there. Swap and late-pickup still send Slack DMs. Slack App Home admin
|
||||||
|
modals are unchanged.
|
||||||
|
|
||||||
|
Portal identity is the roster email on Paychex `PUT /roster` (optional so existing
|
||||||
|
syncs keep working). Admin access is still the Slack IDs in `admin_users` after
|
||||||
|
that lookup. A new `afterhours-portal-api` Lambda serves `GET/POST/DELETE /api/shifts`
|
||||||
|
on the existing HTTP API with Cognito ID-token auth.
|
||||||
|
|
||||||
## v1.15.0 — September 2, 2026
|
## v1.15.0 — September 2, 2026
|
||||||
|
|
||||||
**Monday pay totals now queue to Flex payroll posting.** The weekly post still
|
**Monday pay totals now queue to Flex payroll posting.** The weekly post still
|
||||||
|
|
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY src/shared/requirements.txt /tmp/shared-requirements.txt
|
||||||
|
COPY src/slack-bot/requirements.txt /tmp/slack-bot-requirements.txt
|
||||||
|
COPY src/weekly-post/requirements.txt /tmp/weekly-post-requirements.txt
|
||||||
|
COPY src/portal-api/requirements.txt /tmp/portal-api-requirements.txt
|
||||||
|
COPY requirements-api.txt /tmp/requirements-api.txt
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
-r /tmp/shared-requirements.txt \
|
||||||
|
-r /tmp/slack-bot-requirements.txt \
|
||||||
|
-r /tmp/weekly-post-requirements.txt \
|
||||||
|
-r /tmp/portal-api-requirements.txt \
|
||||||
|
-r /tmp/requirements-api.txt
|
||||||
|
|
||||||
|
COPY src /app/src
|
||||||
|
|
||||||
|
ARG GIT_SHA=dev
|
||||||
|
ENV PYTHONPATH=/app/src:/app/src/shared:/app/src/slack-bot \
|
||||||
|
GIT_SHA=${GIT_SHA} \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app/src
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["python", "-m", "server.entrypoint"]
|
||||||
194
README.md
194
README.md
|
|
@ -1,7 +1,7 @@
|
||||||
# After-Hours Shift Manager
|
# After-Hours Shift Manager
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
|
@ -11,10 +11,14 @@ Slack bot for managing after-hours on-call shifts at Sea Haven Industries. Emplo
|
||||||
|
|
||||||
A recurring weekly schedule assigns employees to after-hours phone duty. Weekend shifts are split into Day (8am-5pm) and Night (5pm-8am). Any unassigned shift shows as **Available** in Slack with a pickup button. When someone picks up or drops a shift for today, the 3CX queue is updated immediately. Future changes take effect when the ring scheduler runs at 8am daily and 5pm on weekends.
|
A recurring weekly schedule assigns employees to after-hours phone duty. Weekend shifts are split into Day (8am-5pm) and Night (5pm-8am). Any unassigned shift shows as **Available** in Slack with a pickup button. When someone picks up or drops a shift for today, the 3CX queue is updated immediately. Future changes take effect when the ring scheduler runs at 8am daily and 5pm on weekends.
|
||||||
|
|
||||||
|
The work week runs Sunday through Saturday, matching payroll. A Saturday night shift (5pm Saturday through 8am Sunday) stays in the Saturday week. Sunday day and Sunday night open the next week. The Monday 7am post shows the week that started the day before, plus the following week, and the pay summary covers the previous Sunday through Saturday.
|
||||||
|
|
||||||
The weekly schedule post is updated live when shifts change, and the previous week's post is automatically deleted when the new one goes out.
|
The weekly schedule post is updated live when shifts change, and the previous week's post is automatically deleted when the new one goes out.
|
||||||
|
|
||||||
The bot also has an **About** page: open the bot in Slack and click its **Home** tab to see what it does, the full command list, and the latest "What's New" (see [Releases & Versioning](#releases--versioning)).
|
The bot also has an **About** page: open the bot in Slack and click its **Home** tab to see what it does, the full command list, and the latest "What's New" (see [Releases & Versioning](#releases--versioning)).
|
||||||
|
|
||||||
|
Employees can do the same pick, drop, swap, and admin work from the internal portal After Hours pages. Slack DMs for swaps and late pickups still go out. App Home admin modals stay as they are.
|
||||||
|
|
||||||
## Slack Commands
|
## Slack Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|
@ -26,7 +30,7 @@ The bot also has an **About** page: open the bot in Slack and click its **Home**
|
||||||
| `/oncall swap <date> @person` | Request a swap — the other person gets an Accept/Decline DM and the shift only moves once they accept |
|
| `/oncall swap <date> @person` | Request a swap — the other person gets an Accept/Decline DM and the shift only moves once they accept |
|
||||||
| `/oncall register <ext>` | Link your Slack account to your phone extension |
|
| `/oncall register <ext>` | Link your Slack account to your phone extension |
|
||||||
| `/oncall roster` | Show all employees and their link status |
|
| `/oncall roster` | Show all employees and their link status |
|
||||||
| `/oncall pay` | Show last week's bonus pay summary |
|
| `/oncall pay` | Show last week's bonus pay summary (Sunday through Saturday) |
|
||||||
| `/oncall rate` | Show current shift pay rates |
|
| `/oncall rate` | Show current shift pay rates |
|
||||||
| `/oncall rate default <amount>` | Set the default per-shift rate |
|
| `/oncall rate default <amount>` | Set the default per-shift rate |
|
||||||
| `/oncall rate <ext> <amount>` | Set a per-person shift rate |
|
| `/oncall rate <ext> <amount>` | Set a per-person shift rate |
|
||||||
|
|
@ -56,37 +60,44 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Runtime**: Python 3.12 on AWS Lambda (arm64)
|
- **Runtime**: Python 3.12 on ECS Fargate (arm64) plus dual-run Lambdas until cutover. seahaven-prod `011934824531`, seahaven-dev `710827005802`
|
||||||
|
- **VPC**: This stack owns `10.70.0.0/16`. Meals and portal Fargate attach with HCP `existing_vpc_id` / `existing_public_subnet_ids` from outputs `vpc_id` and `public_subnet_ids`.
|
||||||
|
- **HTTP contract**: `openapi.yaml`, linted in CI with `npm run openapi:lint` (Redocly `extends: recommended`, same as internal-portal and meal-order-manager).
|
||||||
- **Data**: DynamoDB single-table (`afterhours-shifts`)
|
- **Data**: DynamoDB single-table (`afterhours-shifts`)
|
||||||
- **IaC**: AWS SAM (`template.yaml`) with shared Lambda Layer
|
- **IaC**: HCP Terraform workspaces tagged `app:afterhours-shift-manager` (`afterhours-shift-manager-dev` / `-prod`) plus GitHub Actions `deploy-api.yaml` (image). Terraform does not package `src/`.
|
||||||
- **Slack**: Slack Bolt framework with `/oncall` slash command
|
- **Slack**: Slack Bolt on `POST /slack/events`
|
||||||
- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI
|
- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI
|
||||||
- **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`)
|
- **Secrets**: AWS Secrets Manager (`afterhours-shift-manager/*`)
|
||||||
|
|
||||||
### Lambda Functions
|
### Leftover zip handlers (packaging only)
|
||||||
|
|
||||||
| Function | Trigger | Purpose |
|
Fargate is the live path. These `src/*/app.py` zip sources remain for `scripts/package_lambdas.py` only. Leftover Lambda IAM roles were removed after Paychex dropped AfterhoursWeeklyPostSend (PLAT-218).
|
||||||
|
|
||||||
|
| Handler | Former trigger | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `afterhours-shift-manager` | API Gateway (POST /slack/events) | Slack bot — handles `/oncall` commands and interactive buttons |
|
| `afterhours-shift-manager` | API Gateway (POST /slack/events) | Slack bot — handles `/oncall` commands and interactive buttons |
|
||||||
| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email |
|
| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts the Sunday-Saturday schedule and the previous week's pay summary |
|
||||||
| `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX |
|
| `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX |
|
||||||
|
| `afterhours-portal-api` | API Gateway (ANY /api/shifts, ANY /api/shifts/{proxy+}) | Cognito-authenticated employee/admin shift API for the internal portal |
|
||||||
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift |
|
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift |
|
||||||
| `afterhours-holiday-router` | EventBridge Scheduler (per-holiday one-off: 8am activate / 5pm deactivate ET) | Repoints the IVR to the holiday queue and sets queue agents for a holiday day shift; reverts at 5pm (see [Holidays](#holidays)) |
|
| `afterhours-holiday-router` | EventBridge Scheduler (per-holiday one-off: 8am activate / 5pm deactivate ET) | Repoints the IVR to the holiday queue and sets queue agents for a holiday day shift; reverts at 5pm (see [Holidays](#holidays)) |
|
||||||
| `afterhours-release-notifier` | Invoked by the Deploy workflow's release job on minor/major releases | Posts a "What's New" announcement to the shift channel |
|
|
||||||
|
|
||||||
### Project Layout
|
### Project Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
slack-bot/ Slack Bolt Lambda (handler + app); ships CHANGELOG.md for App Home
|
server/ Flask + gunicorn + SQS worker (Fargate)
|
||||||
weekly-post/ Monday schedule + pay post
|
slack-bot/ Slack Bolt app; leftover zip source for packaging; ships CHANGELOG.md for App Home
|
||||||
roster-sync/ Daily 3CX roster sync
|
weekly-post/ Monday 7am schedule post and prior Sunday-Saturday pay (leftover zip source)
|
||||||
ring-scheduler/ 3CX queue routing updates
|
roster-sync/ Daily 3CX roster sync (leftover zip source for packaging)
|
||||||
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
|
roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard (leftover zip source for packaging)
|
||||||
release-notifier/ Posts release announcements to Slack
|
portal-api/ Cognito employee/admin shift API for the internal portal (leftover zip source for packaging)
|
||||||
shared/ Lambda Layer (schedule, blocks, changelog, 3CX client, secrets)
|
ring-scheduler/ 3CX queue routing updates (leftover zip source for packaging)
|
||||||
scripts/ changelog CLI + CI guard + in-package copy sync
|
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (leftover zip source for packaging)
|
||||||
tests/ pytest suite (mirrors src/, one dir per Lambda + shared)
|
shared/ Bundled into leftover function zips and the Fargate image
|
||||||
|
terraform/ HCP Terraform (ECS/ALB, API, DDB, IAM, leftover zip packaging locals, schedules)
|
||||||
|
scripts/ in-package changelog copy sync + cutover
|
||||||
|
tests/ pytest suite (mirrors src/, one dir per leftover zip handler + shared + server)
|
||||||
```
|
```
|
||||||
|
|
||||||
### DynamoDB Schema
|
### DynamoDB Schema
|
||||||
|
|
@ -95,14 +106,14 @@ Single table with `PK` / `SK` keys:
|
||||||
|
|
||||||
| PK | SK | Description |
|
| PK | SK | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ROSTER` | `<extension>` | Employee: name, extension, slack_user_id |
|
| `ROSTER` | `<extension>` | Employee: name, extension, slack_user_id, optional email |
|
||||||
| `WEEKLY` | `<DayName>` | Default weekly schedule: extension, name |
|
| `WEEKLY` | `<DayName>` | Default weekly schedule: extension, name |
|
||||||
| `OVERRIDE` | `<YYYY-MM-DD>` | Date override from pickup/drop (or `OPEN`) |
|
| `OVERRIDE` | `<YYYY-MM-DD>` | Date override from pickup/drop (or `OPEN`) |
|
||||||
| `SWAP` | `<YYYY-MM-DD>` | Pending/verified swap request: requester, target, status, `expires_at` (TTL) |
|
| `SWAP` | `<YYYY-MM-DD>` | Pending/verified swap request: requester, target, status, `expires_at` (TTL) |
|
||||||
| `HOLIDAY` | `<YYYY-MM-DD>` | Holiday day shift (one per date): `slots` (int), `assignees` (MAP keyed by extension — `{"114": {name, claimed_at}}`), `multiplier` (Decimal, defaults to `CONFIG.holiday_multiplier` = 1.5, overridable per holiday), `label`, `created_at`, `created_by`, `activated` (bool), `schedule_names` (list) |
|
| `HOLIDAY` | `<YYYY-MM-DD>` | Holiday day shift (one per date): `slots` (int), `assignees` (MAP keyed by extension — `{"114": {name, claimed_at}}`), `multiplier` (Decimal, defaults to `CONFIG.holiday_multiplier` = 1.5, overridable per holiday), `label`, `created_at`, `created_by`, `activated` (bool), `schedule_names` (list) |
|
||||||
| `PICKUP_REQUEST` | `<YYYY-MM-DD>[-DAY]#<ext>` | Pending late-pickup awaiting admin approval: `requester_ext`, `requester_name`, `requester_slack`, `shift_type`, `is_holiday`, `status`, `created_at`, `expires_at` (TTL = shift end) |
|
| `PICKUP_REQUEST` | `<YYYY-MM-DD>[-DAY]#<ext>` | Pending late-pickup awaiting admin approval: `requester_ext`, `requester_name`, `requester_slack`, `shift_type`, `is_holiday`, `status`, `created_at`, `expires_at` (TTL = shift end) |
|
||||||
| `SCHEDULE_POST` | `<channel_id>` | Current schedule message timestamp |
|
| `SCHEDULE_POST` | `<channel_id>` | Current schedule message timestamp |
|
||||||
| `PAY` | `<YYYY-MM-DD>` | Weekly pay record (Monday date key) |
|
| `PAY` | `<YYYY-MM-DD>` | Weekly pay record (Sunday date key; older rows may use Monday) |
|
||||||
| `CONFIG` | `CONFIG` | Settings: shift_rate, fallback_extension, admin_users, `ring_group`, `holiday_multiplier` (default holiday pay multiplier, 1.5), `holiday_queue` (3CX queue repointed during holidays, default 802), `ivr_number` (3CX IVR repointed during holidays, default 800), `captured_ivr_routes` (original IVR routes saved at holiday activation, restored at deactivation) |
|
| `CONFIG` | `CONFIG` | Settings: shift_rate, fallback_extension, admin_users, `ring_group`, `holiday_multiplier` (default holiday pay multiplier, 1.5), `holiday_queue` (3CX queue repointed during holidays, default 802), `ivr_number` (3CX IVR repointed during holidays, default 800), `captured_ivr_routes` (original IVR routes saved at holiday activation, restored at deactivation) |
|
||||||
|
|
||||||
Weekend day-shift rows use a `-DAY` suffix on the SK (e.g. `OVERRIDE` / `2026-04-05-DAY`). The table has TTL enabled on `expires_at` so abandoned pending swaps and pickup requests self-clean.
|
Weekend day-shift rows use a `-DAY` suffix on the SK (e.g. `OVERRIDE` / `2026-04-05-DAY`). The table has TTL enabled on `expires_at` so abandoned pending swaps and pickup requests self-clean.
|
||||||
|
|
@ -131,7 +142,7 @@ Map updates on the record (see above) so the slot count can't be oversubscribed.
|
||||||
creates two **one-off EventBridge Scheduler** schedules for that date —
|
creates two **one-off EventBridge Scheduler** schedules for that date —
|
||||||
`holiday-activate-<YYYYMMDD>` at 08:00 ET and `holiday-deactivate-<YYYYMMDD>` at
|
`holiday-activate-<YYYYMMDD>` at 08:00 ET and `holiday-deactivate-<YYYYMMDD>` at
|
||||||
17:00 ET — whose names are stored on the record's `schedule_names`. Scheduler
|
17:00 ET — whose names are stored on the record's `schedule_names`. Scheduler
|
||||||
assumes `HolidaySchedulerExecutionRole` to invoke `afterhours-holiday-router`:
|
assumes `afterhours-shift-manager-holiday-scheduler` to invoke `afterhours-holiday-router`:
|
||||||
|
|
||||||
- **Activate (08:00):** capture both IVR `ivr_number` (800) routes — key-0 **and**
|
- **Activate (08:00):** capture both IVR `ivr_number` (800) routes — key-0 **and**
|
||||||
no-input/timeout — into `CONFIG.captured_ivr_routes` (skipped if they already
|
no-input/timeout — into `CONFIG.captured_ivr_routes` (skipped if they already
|
||||||
|
|
@ -177,6 +188,46 @@ A slot claimed after the shift has started always needs an admin to approve it.
|
||||||
| `afterhours-shift-manager/3cx-domain` | 3CX FQDN (e.g. `company.3cx.us`) |
|
| `afterhours-shift-manager/3cx-domain` | 3CX FQDN (e.g. `company.3cx.us`) |
|
||||||
| `afterhours-shift-manager/3cx-client-id` | 3CX OAuth2 client ID |
|
| `afterhours-shift-manager/3cx-client-id` | 3CX OAuth2 client ID |
|
||||||
| `afterhours-shift-manager/3cx-client-secret` | 3CX OAuth2 client secret |
|
| `afterhours-shift-manager/3cx-client-secret` | 3CX OAuth2 client secret |
|
||||||
|
| `afterhours-shift-manager/roster-api-token` | Bearer token for PUT/DELETE `/roster`. Duplicate the same value into the seahaven-prod secret `paychex-integrations/afterhours-roster-token`. |
|
||||||
|
|
||||||
|
### Roster HTTP API
|
||||||
|
|
||||||
|
Identity hire/offboard in `paychex-integrations` calls this API. It is a separate Lambda on the same HTTP API as Slack (`POST /slack/events` is unchanged).
|
||||||
|
|
||||||
|
| Method | Path | Body | Success |
|
||||||
|
|---|---|---|---|
|
||||||
|
| PUT | `/roster` | `{"name","extension","slack_user_id"}` plus optional `"email"` | 200 `{"ok":true}` |
|
||||||
|
| DELETE | `/roster/{extension}` | none | 204 empty body, including when the row is already gone |
|
||||||
|
|
||||||
|
Header: `Authorization: Bearer {token}`. Missing or wrong token is 401. Invalid JSON or fields is 400. A secret-read failure is 503.
|
||||||
|
|
||||||
|
`email` is optional so existing Paychex syncs keep working. Portal identity looks up that email (case-insensitive) against the signed-in Google account. Without it, the portal shows an unlinked roster message. Admin access is still the Slack IDs in `admin_users` after the email lookup.
|
||||||
|
|
||||||
|
### Portal HTTP API
|
||||||
|
|
||||||
|
The internal portal SPA calls this API with the Cognito ID token from `GET /api/auth/meals-token`. CORS allows `https://internal.seahaven.com`, `https://internal.dev.seahaven.com`, and local Vite.
|
||||||
|
|
||||||
|
| Method | Path | Who |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/shifts?week=this\|next` | Linked employee; unlinked Google accounts get `{linked:false}` |
|
||||||
|
| POST | `/api/shifts/pick` | Employee |
|
||||||
|
| POST | `/api/shifts/drop` | Employee |
|
||||||
|
| POST | `/api/shifts/swap` | Employee |
|
||||||
|
| POST | `/api/shifts/swaps/{date}/{shiftType}/accept\|decline` | Swap target |
|
||||||
|
| POST | `/api/shifts/admin/override` | Admin |
|
||||||
|
| POST | `/api/shifts/admin/open` | Admin |
|
||||||
|
| POST | `/api/shifts/admin/clear` | Admin |
|
||||||
|
| POST | `/api/shifts/admin/holidays` | Admin |
|
||||||
|
| DELETE | `/api/shifts/admin/holidays/{date}` | Admin |
|
||||||
|
| POST | `/api/shifts/admin/pickups/{date}/{shiftType}/{extension}/approve\|deny` | Admin |
|
||||||
|
|
||||||
|
Set portal `VITE_SHIFTS_API_BASE` to Terraform output `api_origin`. Set HCP variables `portal_cognito_issuer` and `portal_cognito_audience` (and `portal_cognito_extra_trust` if the portal has separate dev and prod pools).
|
||||||
|
|
||||||
|
Set processor `AFTERHOURS_BASE_URL` to the Terraform output `api_origin` (HCP variable `afterhours_base_url` on `paychex-integrations-prod`). That value is the API origin only. Do not append `/roster`. Flip it at cutover after DynamoDB is copied, not before.
|
||||||
|
|
||||||
|
Daily `afterhours-roster-sync` still owns the 3CX `DEFAULT` group at 6am ET: rows absent from that group are deleted. Hire is safe because 3CX create (into `DEFAULT`) happens before the roster PUT. An HTTP-only row that is not in that group will be removed on the next sync. Sync preserves `slack_user_id` on existing rows and does not overwrite a just-created API row's Slack id.
|
||||||
|
|
||||||
|
Token rotation is a maintenance-window action. The Lambda caches the token per execution environment. Update `afterhours-shift-manager/roster-api-token` and `paychex-integrations/afterhours-roster-token` together, then recycle `afterhours-roster-api`. Updating only one copy, or recycling environments out of order, causes 401s until both sides match.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|
@ -186,31 +237,22 @@ The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This pr
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Merges to `main` are automatically deployed via **GitHub Actions** using reusable SAM workflows from the Sea Haven org.
|
Infrastructure is applied by HCP Terraform workspace `afterhours-shift-manager-prod` (VCS on `main`, working directory `terraform/`, file trigger `terraform/**` only). The Flask image is shipped by `.github/workflows/deploy-api.yaml`.
|
||||||
|
|
||||||
For manual deploys:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sam build
|
|
||||||
sam deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring & Alarms
|
## Monitoring & Alarms
|
||||||
|
|
||||||
All CloudWatch alarms are defined in `template.yaml` and notify the shared
|
All CloudWatch alarms are defined in `terraform/alarms.tf` and notify the shared
|
||||||
`site-alerts` SNS topic (→ AWS Chatbot → Slack). None set `OKActions` — recovery
|
`site-alerts` SNS topic (→ AWS Chatbot → Slack). None set `OKActions` — recovery
|
||||||
is not paged. Alarm names follow the in-template convention `Lambda-<Metric>-<fn>`
|
is not paged. Alarm names follow `ALB-<Metric>-afterhours-shift-manager` and
|
||||||
(e.g. `Lambda-Errors-afterhours-ring-scheduler`).
|
`DDB-<Metric>-afterhours-shifts`.
|
||||||
|
|
||||||
**Lambda alarms** (all six functions: `afterhours-shift-manager`,
|
**ALB alarms**:
|
||||||
`afterhours-weekly-post`, `afterhours-roster-sync`, `afterhours-ring-scheduler`,
|
|
||||||
`afterhours-holiday-router`, `afterhours-release-notifier`):
|
|
||||||
|
|
||||||
| Alarm | Metric | Condition | Notes |
|
| Alarm | Metric | Condition |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| `Lambda-Errors-<fn>` | `Errors` (Sum) | `>= 1` over one 5-min period | `TreatMissingData: notBreaching` |
|
| `ALB-5xx-afterhours-shift-manager` | `HTTPCode_Target_5XX_Count` (Sum) | `> 0` over one 5-min period |
|
||||||
| `Lambda-Duration-<fn>` | `Duration` (Maximum, ms) | `>= ~80% of timeout`, 2 of 3 5-min periods | Thresholds: 24000 ms (30s-timeout fns) / 48000 ms (60s-timeout fns) |
|
| `ALB-Latency-afterhours-shift-manager` | `TargetResponseTime` (p99, s) | `>= 3` s, 2 of 3 5-min periods |
|
||||||
| `Lambda-Throttles-<fn>` | `Throttles` (Sum) | `>= 1` over one 5-min period | `TreatMissingData: notBreaching` |
|
| `ALB-UnhealthyHost-afterhours-shift-manager` | `UnHealthyHostCount` (Maximum) | `> 0`, 3 of 3 1-min periods |
|
||||||
|
|
||||||
**DynamoDB alarm** (`afterhours-shifts` table):
|
**DynamoDB alarm** (`afterhours-shifts` table):
|
||||||
|
|
||||||
|
|
@ -225,54 +267,24 @@ transition normally. `ThrottledRequests` and `SystemErrors` are intentionally
|
||||||
**not** alarmed: AWS emits them only at `TableName`+`Operation` granularity, so a
|
**not** alarmed: AWS emits them only at `TableName`+`Operation` granularity, so a
|
||||||
`TableName`-only alarm would sit permanently in `INSUFFICIENT_DATA`.
|
`TableName`-only alarm would sit permanently in `INSUFFICIENT_DATA`.
|
||||||
|
|
||||||
**API Gateway alarms** (implicit HTTP API `ServerlessHttpApi`, `AWS/ApiGateway`
|
**API Gateway alarms** were removed with the Fargate cutover (PLAT-216).
|
||||||
v2 metrics, `ApiId` dimension):
|
|
||||||
|
|
||||||
| Alarm | Metric | Condition |
|
|
||||||
|---|---|---|
|
|
||||||
| `ApiGateway-4xx-<apiId>` | `4xx` (Sum) | `>= 5` over one 5-min period |
|
|
||||||
| `ApiGateway-5xx-<apiId>` | `5xx` (Sum) | `>= 1` over one 5-min period |
|
|
||||||
| `ApiGateway-Latency-<apiId>` | `Latency` (p99, ms) | `>= 3000` ms, 2 of 3 5-min periods |
|
|
||||||
|
|
||||||
> Duration and API latency thresholds are starting points and may be tuned after
|
|
||||||
> observing real traffic.
|
|
||||||
|
|
||||||
## Releases & Versioning
|
## Releases & Versioning
|
||||||
|
|
||||||
The bot is versioned with SemVer, driven entirely by **`CHANGELOG.md`** — it is
|
Prod is a human GitHub Release:
|
||||||
the single source of truth for both the version number and the human-readable
|
|
||||||
notes. There is no separate tagging tool.
|
|
||||||
|
|
||||||
**To cut a release**, in your feature PR add a new `## vX.Y.Z — Month D, YYYY`
|
```bash
|
||||||
section at the top of `CHANGELOG.md` (plain language, written for on-call staff),
|
gh release create vX.Y.Z --target main --generate-notes
|
||||||
bumping per SemVer, then run `python scripts/sync_changelog.py` to update the
|
```
|
||||||
in-package copy. The `Changelog Guard` PR check enforces that the bump is a clean
|
|
||||||
single SemVer step above the latest tag and that the two copies match.
|
|
||||||
|
|
||||||
On the **deploy-then-merge** path, once the merge's Deploy succeeds, the Deploy
|
That tag applies prod infra in HCP and queues `deploy-api.yaml` behind the prod
|
||||||
workflow's `release` job (`needs: deploy`) tags the new version, publishes a
|
Environment. Merge to `main` deploys **dev**. Nothing in GitHub Actions creates
|
||||||
GitHub Release with the notes, and — for **minor and major** bumps only (patches
|
the Release.
|
||||||
stay silent) — invokes `afterhours-release-notifier` to post a "What's New"
|
|
||||||
message in the shift channel. The **App Home** tab ("About" page on the bot)
|
|
||||||
always shows the current version's notes, read from the CHANGELOG that ships in
|
|
||||||
the slack-bot package.
|
|
||||||
|
|
||||||
> The release job lives inside the Deploy workflow (gated on `needs: deploy`)
|
**App Home "What's New"** still reads **`CHANGELOG.md`**. That file is product
|
||||||
> rather than a separate `workflow_run`-triggered workflow. A push-to-main run is
|
copy, not the prod tag driver. After editing the root file, run
|
||||||
> a trusted context, so checking out and running repo code with write/OIDC is safe
|
`python scripts/sync_changelog.py` so `src/slack-bot/CHANGELOG.md` stays in
|
||||||
> — whereas `workflow_run` is flagged by CodeQL for untrusted checkout. Gating on
|
sync. Pytest fails if the two copies drift.
|
||||||
> `needs: deploy` still guarantees we never announce a version that isn't live.
|
|
||||||
|
|
||||||
**One-time setup (per environment):** after the first deploy creates the
|
|
||||||
`ReleaseNotifyInvokeRole`, copy its ARN from the `ReleaseNotifyInvokeRoleArn` stack
|
|
||||||
output into the repo **variable** `RELEASE_NOTIFY_INVOKE_ROLE_ARN` (Settings →
|
|
||||||
Secrets and variables → Actions → Variables). Until it's set, releases still tag
|
|
||||||
and publish but skip the Slack announcement (with a warning).
|
|
||||||
|
|
||||||
> **Convention note (deliberate deviation).** The Sea Haven handbook says internal
|
|
||||||
> SAM stacks generally need no versioning and that tags are applied manually. This
|
|
||||||
> bot is versioned by owner choice (it has staff-facing release notes) and tagged
|
|
||||||
> automatically by the Deploy workflow's release job. This is intentional — not drift.
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|
@ -285,12 +297,26 @@ python -m venv .venv && source .venv/bin/activate
|
||||||
pip install -r tests/requirements.txt # test-only deps
|
pip install -r tests/requirements.txt # test-only deps
|
||||||
pip install -r src/slack-bot/requirements.txt \
|
pip install -r src/slack-bot/requirements.txt \
|
||||||
-r src/weekly-post/requirements.txt \
|
-r src/weekly-post/requirements.txt \
|
||||||
-r src/shared/requirements.txt # runtime deps the imports need
|
-r src/shared/requirements.txt \
|
||||||
|
-r src/portal-api/requirements.txt \
|
||||||
|
-r requirements-api.txt
|
||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
Each Lambda has its own `app.py`, so the per-package `conftest.py` loads each one
|
Each Lambda has its own `app.py`, so the per-package `conftest.py` loads each one
|
||||||
under a unique module name (importlib mode) to avoid collisions. CI runs the same
|
under a unique module name (importlib mode) to avoid collisions. CI runs the same
|
||||||
suite on every PR via the org `ci-python-sam` workflow (`run-tests: true`).
|
suite on every PR via pytest plus `terraform fmt` / `init -backend=false` /
|
||||||
|
`validate` and `npm run openapi:lint`.
|
||||||
|
|
||||||
|
Local Fargate process (needs the same DynamoDB table and Secrets Manager names
|
||||||
|
the Lambdas use, plus `JOBS_QUEUE_URL` to consume jobs):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PYTHONPATH=src:src/shared:src/slack-bot
|
||||||
|
export STAGE=local GIT_SHA=dev
|
||||||
|
python -m server.entrypoint
|
||||||
|
```
|
||||||
|
|
||||||
|
Health is `GET /api/health` on port 8080.
|
||||||
|
|
||||||
See [SETUP.md](SETUP.md) for full deployment and Slack app creation instructions.
|
See [SETUP.md](SETUP.md) for full deployment and Slack app creation instructions.
|
||||||
|
|
|
||||||
129
SETUP.md
129
SETUP.md
|
|
@ -45,32 +45,89 @@ aws secretsmanager create-secret \
|
||||||
aws secretsmanager create-secret \
|
aws secretsmanager create-secret \
|
||||||
--name afterhours-shift-manager/3cx-client-secret \
|
--name afterhours-shift-manager/3cx-client-secret \
|
||||||
--secret-string "YOUR-3CX-CLIENT-SECRET"
|
--secret-string "YOUR-3CX-CLIENT-SECRET"
|
||||||
|
|
||||||
|
# Roster HTTP API bearer token (plain string). Duplicate the same value into
|
||||||
|
# seahaven-prod as paychex-integrations/afterhours-roster-token. Generate the
|
||||||
|
# token into a temp file, pass --secret-string file://..., then delete the file.
|
||||||
|
# Never paste the value into chat, Terraform, or a PR.
|
||||||
|
aws secretsmanager create-secret \
|
||||||
|
--name afterhours-shift-manager/roster-api-token \
|
||||||
|
--secret-string file://./roster-api-token.tmp
|
||||||
```
|
```
|
||||||
|
|
||||||
> The Slack **channel ID** is not a secret — it's passed as the `ShiftChannel`
|
Create `afterhours-shift-manager/roster-api-token` **before** the first deploy that
|
||||||
> deploy parameter in step 3, not stored in Secrets Manager or SSM.
|
includes `afterhours-roster-api`, or live PUT/DELETE calls return 503.
|
||||||
|
|
||||||
## 3. Deploy the Stack
|
Rotation is coordinated: write the new value to both
|
||||||
|
`afterhours-shift-manager/roster-api-token` and
|
||||||
|
`paychex-integrations/afterhours-roster-token`, then recycle
|
||||||
|
`afterhours-roster-api` so cached execution environments pick it up. Updating
|
||||||
|
only one copy causes 401s. The identity processor `AFTERHOURS_BASE_URL` is the
|
||||||
|
Terraform output `api_origin` (origin only, no `/roster` suffix). Flip that HCP
|
||||||
|
variable on `paychex-integrations-prod` at cutover after DynamoDB is copied.
|
||||||
|
|
||||||
```bash
|
Daily roster-sync still removes DynamoDB rows that are not in the 3CX `DEFAULT`
|
||||||
# Build and deploy. ShiftChannel is the Slack channel ID for schedule posts
|
group. Hire stays safe because 3CX create lands the extension in that group
|
||||||
# (right-click the channel in Slack → Copy link → the ID is the last segment).
|
before the identity processor PUTs `/roster`.
|
||||||
sam build
|
|
||||||
sam deploy --guided \
|
|
||||||
--stack-name afterhours-shift-manager \
|
|
||||||
--region us-east-1 \
|
|
||||||
--parameter-overrides ShiftChannel=C0XXXXXXX QueueNumber=801
|
|
||||||
|
|
||||||
# Note the SlackBotApiUrl output — you'll need it for step 4
|
> The Slack **channel ID** is not a secret. It is Terraform variable
|
||||||
```
|
> `shift_channel` (default `C0APATP612N`), not stored in Secrets Manager.
|
||||||
|
|
||||||
|
## 3. HCP Terraform and GitHub Environment
|
||||||
|
|
||||||
|
Workspaces tagged `app:afterhours-shift-manager`:
|
||||||
|
`afterhours-shift-manager-prod` in `seahaven-prod` (account `011934824531`) and
|
||||||
|
`afterhours-shift-manager-dev` in `seahaven-dev` (account `710827005802`).
|
||||||
|
Create the dev workspace before any Slack URL flip. HCP variable `environment`
|
||||||
|
is `prod` or `dev`.
|
||||||
|
|
||||||
|
First apply uses the hcptf-bootstrap window (exact `StringEquals` trust, never
|
||||||
|
`StringLike`). Creating `afterhours-shift-manager-ecs-task-boundary` is
|
||||||
|
`iam:CreatePolicy` and needs that window.
|
||||||
|
|
||||||
|
1. Create the HCP workspace. Auto-apply off. No project-level variable set.
|
||||||
|
Working directory `terraform`. File trigger prefix `terraform/**` only.
|
||||||
|
Speculative plans on. VCS on `main`.
|
||||||
|
2. From `seahaven-org-baseline`:
|
||||||
|
`scripts/create-hcptf-bootstrap-roles.sh --account prod --allow-workspace afterhours-shift-manager-prod`
|
||||||
|
3. Point workspace `TFC_AWS_APPLY_ROLE_ARN` / `TFC_AWS_PLAN_ROLE_ARN` at
|
||||||
|
`hcptf-bootstrap` / `hcptf-bootstrap-plan`. Set `TFC_AWS_PROVIDER_AUTH=true`.
|
||||||
|
4. One manual apply with `schedules_enabled=false`. This creates the scoped
|
||||||
|
`hcptf-*` roles, the Lambda boundary, and the rest of the stack. If 3CX
|
||||||
|
secrets already exist from seahaven-door-unlock-api, import those three
|
||||||
|
names instead of creating them:
|
||||||
|
`terraform import 'aws_secretsmanager_secret.this["afterhours-shift-manager/3cx-domain"]' afterhours-shift-manager/3cx-domain`
|
||||||
|
(and the client-id / client-secret names). Do not overwrite 3CX values.
|
||||||
|
5. Retarget `TFC_AWS_*` to `hcptf-afterhours-shift-manager` /
|
||||||
|
`hcptf-afterhours-shift-manager-plan`. Re-run the create script with no
|
||||||
|
`--allow-workspace`.
|
||||||
|
6. Second manual apply as the scoped role. Then seal auto-apply on.
|
||||||
|
|
||||||
|
GitHub Environment `prod`: reviewers, branch policy `main` and `v*`, Environment
|
||||||
|
variable `DEPLOY_ROLE_ARN` = Terraform output `github_deploy_role_arn`.
|
||||||
|
GitHub Environment `dev`: no reviewers, `DEPLOY_ROLE_ARN` from the seahaven-dev
|
||||||
|
apply of the same output. Set `checkcomponents_queue_url` and
|
||||||
|
`checkcomponents_queue_arn` empty on the dev workspace.
|
||||||
|
|
||||||
|
Function zips: Actions → Deploy on push to `main`, or `workflow_dispatch`.
|
||||||
|
Image CD: Actions → Deploy API (`deploy-api.yaml`). Keep `schedules_enabled=false`
|
||||||
|
and `ecs_schedules_enabled=false` until the Fargate cutover below.
|
||||||
|
|
||||||
|
HCP outputs to copy: `slack_request_url`, `api_origin`, `fargate_origin`,
|
||||||
|
`holiday_scheduler_role_arn`, `github_deploy_role_arn`, `jobs_queue_arn`,
|
||||||
|
`ecs_task_role_arn`. Paychex checkcomponents allows `afterhours-shift-manager-api`;
|
||||||
|
leftover weekly-post principal is gone.
|
||||||
|
|
||||||
## 4. Set the Slack Request URL
|
## 4. Set the Slack Request URL
|
||||||
|
|
||||||
After deploy, copy the `SlackBotApiUrl` from the SAM output. Go back to your Slack app settings:
|
Reuse the existing Slack app. After the zip deploy, copy `slack_request_url`
|
||||||
|
from HCP outputs:
|
||||||
|
|
||||||
- **Slash Commands** → edit `/oncall` → set **Request URL** to the output URL
|
- **Slash Commands** → edit `/oncall` → set **Request URL** to that URL
|
||||||
- **Interactivity & Shortcuts** → set **Request URL** to the same URL
|
- **Interactivity & Shortcuts** → set **Request URL** to the same URL
|
||||||
|
|
||||||
|
Do this in the cutover window, not before DynamoDB is copied.
|
||||||
|
|
||||||
## 5. Seed the Schedule
|
## 5. Seed the Schedule
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -94,6 +151,48 @@ To have the 3CX scheduler read overrides from DynamoDB (so Slack-driven changes
|
||||||
|
|
||||||
Without this step, the Slack bot still works — it invokes the 3CX scheduler Lambda directly for same-day changes. Future-date overrides would only take effect if the scheduler reads DynamoDB.
|
Without this step, the Slack bot still works — it invokes the 3CX scheduler Lambda directly for same-day changes. Future-date overrides would only take effect if the scheduler reads DynamoDB.
|
||||||
|
|
||||||
|
## 8. Prod cutover (PLAT-74)
|
||||||
|
|
||||||
|
Avoid Monday 06:00-08:00 ET and any holiday 08:00/17:00 ET window. Dry-run the
|
||||||
|
scripts first (`--execute` is required for writes).
|
||||||
|
|
||||||
|
1. Merge this repo's PR (SAM CD is gone). First HCP apply is the bootstrap
|
||||||
|
window above with `schedules_enabled=false`.
|
||||||
|
2. Copy DynamoDB `afterhours-shifts` mgmt → prod. Verify item counts:
|
||||||
|
`python scripts/cutover/copy_dynamodb.py --src-profile mgmt --dst-profile prod`
|
||||||
|
then `--execute`.
|
||||||
|
3. Confirm secrets in prod. `copy_secrets.py` writes Slack bot token, Slack
|
||||||
|
signing secret, and roster-api-token into empty Terraform shells and skips
|
||||||
|
dest names that already have a value. It never writes 3CX secrets:
|
||||||
|
`python scripts/cutover/copy_secrets.py --src-profile mgmt --dst-profile prod`
|
||||||
|
then `--execute`. Strip trailing newlines is built in.
|
||||||
|
4. GHA `workflow_dispatch` (or the merge deploy; re-run if it raced apply) to
|
||||||
|
overwrite stubs.
|
||||||
|
5. Recreate outstanding future `holiday-activate-*` / `holiday-deactivate-*`
|
||||||
|
in prod against the new router ARN and scheduler role:
|
||||||
|
`python scripts/cutover/recreate_holiday_schedules.py --src-profile mgmt --dst-profile prod`
|
||||||
|
6. Instant cut: Slack Request URL → prod `/slack/events`; Paychex HCP variable
|
||||||
|
`afterhours_base_url` on `paychex-integrations-prod` → prod `api_origin`;
|
||||||
|
`schedules_enabled=true` via a terraform-only merge; disable mgmt EventBridge.
|
||||||
|
Smoke: Slack `/oncall`, roster PUT/DELETE, weekly-post SendMessage (or
|
||||||
|
simulate-principal-policy plus one smoke message), ring-scheduler invoke,
|
||||||
|
holiday GetSchedule.
|
||||||
|
7. Seal auto-apply on. Delete the mgmt SAM stack. Remove the mgmt SQS principal
|
||||||
|
from `paychex-checkcomponents`. Update Confluence AWS Architecture Map and
|
||||||
|
check PLAT-71 item 4.
|
||||||
|
|
||||||
|
Do not dual-run 3CX writers. Do not flip `afterhours_base_url` before DynamoDB
|
||||||
|
is copied.
|
||||||
|
|
||||||
|
## 9. Fargate cutover (PLAT-216)
|
||||||
|
|
||||||
|
Completed 2026-09-21. Live path is ECS Fargate behind
|
||||||
|
`https://afterhours.seahaven.com` (dev: `https://afterhours.dev.seahaven.com`).
|
||||||
|
Slack Request URL, Paychex `AFTERHOURS_BASE_URL`, and portal `VITE_SHIFTS_API_BASE`
|
||||||
|
point at those hosts. Jobs use EventBridge Scheduler → SQS (`ecs_schedules_enabled=true`,
|
||||||
|
Lambda EventBridge `schedules_enabled=false`). Public DNS is out of band in the
|
||||||
|
mgmt `seahaven.com` zone and the external-dev `dev.seahaven.com` zone.
|
||||||
|
|
||||||
## Commands Reference
|
## Commands Reference
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|
|
||||||
12
docs/portal-module-spec.md
Normal file
12
docs/portal-module-spec.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Locked 2026-09-21 against Adam's answers.
|
||||||
|
|
||||||
|
Employee self-service plus admin After Hours in `internal-portal`. Slack stays.
|
||||||
|
|
||||||
|
Decisions:
|
||||||
|
|
||||||
|
- After Hours is a primary nav item (`/shifts`, `/shifts/admin`).
|
||||||
|
- Identity is roster `email` from Paychex `PUT /roster` (optional for backward compatibility; portal mapping needs it). Admin remains `get_admin_users()` Slack IDs after that lookup.
|
||||||
|
- Swap and late-pickup are in-portal pending actions and still DM on Slack.
|
||||||
|
- Slack App Home admin modals stay as-is.
|
||||||
|
|
||||||
|
Tickets: DEV-286 (epic), DEV-287 (API), DEV-288 (portal UI).
|
||||||
777
openapi.yaml
Normal file
777
openapi.yaml
Normal file
|
|
@ -0,0 +1,777 @@
|
||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: After Hours Shift Manager
|
||||||
|
version: 0.1.0
|
||||||
|
description: >
|
||||||
|
Flask HTTP API on ECS Fargate. Slack `/oncall` stays on POST /slack/events
|
||||||
|
and is not part of this contract. The internal portal SPA calls /api/shifts
|
||||||
|
with a Cognito ID token from GET /api/auth/meals-token. Paychex calls
|
||||||
|
PUT/DELETE /roster with a shared bearer token. Error shape for portal
|
||||||
|
routes is `{ error: { code, message } }`. Health matches the portal BFF
|
||||||
|
`{ stage, sha }`. Lint with the same Redocly `extends: recommended` config
|
||||||
|
as internal-portal and meal-order-manager.
|
||||||
|
contact:
|
||||||
|
name: Sea Haven Engineering
|
||||||
|
license:
|
||||||
|
name: Proprietary
|
||||||
|
identifier: LicenseRef-SeaHaven
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: /
|
||||||
|
description: After Hours ALB origin (VITE_SHIFTS_API_BASE)
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Runtime
|
||||||
|
description: Unauthenticated health
|
||||||
|
- name: Roster
|
||||||
|
description: Paychex hire and offboard
|
||||||
|
- name: Shifts
|
||||||
|
description: Employee and admin After Hours for the portal SPA
|
||||||
|
|
||||||
|
security: []
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/api/health:
|
||||||
|
get:
|
||||||
|
operationId: getHealth
|
||||||
|
tags: [Runtime]
|
||||||
|
summary: Runtime health
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Process is up
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Health"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/roster:
|
||||||
|
put:
|
||||||
|
operationId: putRoster
|
||||||
|
tags: [Roster]
|
||||||
|
summary: Upsert a roster row
|
||||||
|
security:
|
||||||
|
- rosterBearer: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RosterUpsert"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Row written
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RosterOk"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/RosterError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/RosterError"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/RosterError"
|
||||||
|
|
||||||
|
/roster/{extension}:
|
||||||
|
delete:
|
||||||
|
operationId: deleteRoster
|
||||||
|
tags: [Roster]
|
||||||
|
summary: Remove a roster row
|
||||||
|
security:
|
||||||
|
- rosterBearer: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Extension"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Gone, including when the row was already missing
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/RosterError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/RosterError"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/RosterError"
|
||||||
|
|
||||||
|
/api/shifts:
|
||||||
|
get:
|
||||||
|
operationId: getShifts
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Week snapshot for the signed-in employee
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
parameters:
|
||||||
|
- name: week
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [this, next]
|
||||||
|
default: this
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Linked snapshot or unlinked Google account
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ShiftsSnapshot"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/api/shifts/pick:
|
||||||
|
post:
|
||||||
|
operationId: pickShift
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Pick up a shift or request late pickup
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ShiftDateBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/MutationOk"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"503":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/api/shifts/drop:
|
||||||
|
post:
|
||||||
|
operationId: dropShift
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Drop a held shift
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ShiftDateBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/MutationOk"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/api/shifts/swap:
|
||||||
|
post:
|
||||||
|
operationId: requestSwap
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Request a swap
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SwapBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/MutationOk"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/api/shifts/swaps/{date}/{shiftType}/accept:
|
||||||
|
post:
|
||||||
|
operationId: acceptSwap
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Accept a pending swap
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ShiftDate"
|
||||||
|
- $ref: "#/components/parameters/ShiftType"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/MutationOk"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/api/shifts/swaps/{date}/{shiftType}/decline:
|
||||||
|
post:
|
||||||
|
operationId: declineSwap
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Decline a pending swap
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ShiftDate"
|
||||||
|
- $ref: "#/components/parameters/ShiftType"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/MutationOk"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/api/shifts/admin/override:
|
||||||
|
post:
|
||||||
|
operationId: adminOverride
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Assign a shift
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AdminOverrideBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/MutationOk"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/api/shifts/admin/open:
|
||||||
|
post:
|
||||||
|
operationId: adminOpen
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Mark a shift open
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ShiftDateBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/MutationOk"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/api/shifts/admin/clear:
|
||||||
|
post:
|
||||||
|
operationId: adminClear
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Clear an override
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ShiftDateBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/MutationOk"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/api/shifts/admin/holidays:
|
||||||
|
post:
|
||||||
|
operationId: adminHolidayAdd
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Schedule a holiday day shift
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/HolidayBody"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/MutationOk"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/api/shifts/admin/holidays/{date}:
|
||||||
|
delete:
|
||||||
|
operationId: adminHolidayRemove
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Remove a holiday
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ShiftDate"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/MutationOk"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/api/shifts/admin/pickups/{date}/{shiftType}/{extension}/approve:
|
||||||
|
post:
|
||||||
|
operationId: adminPickupApprove
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Approve a late pickup
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ShiftDate"
|
||||||
|
- $ref: "#/components/parameters/ShiftType"
|
||||||
|
- $ref: "#/components/parameters/Extension"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/MutationOk"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
/api/shifts/admin/pickups/{date}/{shiftType}/{extension}/deny:
|
||||||
|
post:
|
||||||
|
operationId: adminPickupDeny
|
||||||
|
tags: [Shifts]
|
||||||
|
summary: Deny a late pickup
|
||||||
|
security:
|
||||||
|
- portalCognito: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ShiftDate"
|
||||||
|
- $ref: "#/components/parameters/ShiftType"
|
||||||
|
- $ref: "#/components/parameters/Extension"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/MutationOk"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/PortalError"
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
portalCognito:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: Portal Cognito ID token
|
||||||
|
rosterBearer:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
description: Shared Paychex roster token
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
ShiftDate:
|
||||||
|
name: date
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
ShiftType:
|
||||||
|
name: shiftType
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [day, night]
|
||||||
|
Extension:
|
||||||
|
name: extension
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
|
||||||
|
responses:
|
||||||
|
MutationOk:
|
||||||
|
description: Mutation applied
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MutationResult"
|
||||||
|
PortalError:
|
||||||
|
description: Portal JSON error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PortalErrorEnvelope"
|
||||||
|
RosterError:
|
||||||
|
description: Roster string error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RosterErrorBody"
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
Health:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [stage, sha]
|
||||||
|
properties:
|
||||||
|
stage:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
description: Workspace stage (`dev`, `prod`, or `local`)
|
||||||
|
sha:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
description: Git SHA or `unknown` locally
|
||||||
|
|
||||||
|
PortalErrorEnvelope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [error]
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [code, message]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
|
||||||
|
RosterErrorBody:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [error]
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
|
||||||
|
RosterUpsert:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [name, extension, slack_user_id]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
extension:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
slack_user_id:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
|
||||||
|
RosterOk:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [ok]
|
||||||
|
properties:
|
||||||
|
ok:
|
||||||
|
type: boolean
|
||||||
|
const: true
|
||||||
|
|
||||||
|
ShiftDateBody:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [date]
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
shiftType:
|
||||||
|
type: string
|
||||||
|
enum: [day, night, holiday]
|
||||||
|
|
||||||
|
SwapBody:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [date, targetExtension]
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
shiftType:
|
||||||
|
type: string
|
||||||
|
enum: [day, night]
|
||||||
|
targetExtension:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
note:
|
||||||
|
type: string
|
||||||
|
description: Optional. Stored after trim, and the trimmed value must be 500 characters or fewer.
|
||||||
|
|
||||||
|
AdminOverrideBody:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [date, extension]
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
extension:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
shiftType:
|
||||||
|
type: string
|
||||||
|
enum: [day, night]
|
||||||
|
|
||||||
|
HolidayBody:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [date, slots, label]
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
slots:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
multiplier:
|
||||||
|
type: [number, string, "null"]
|
||||||
|
|
||||||
|
MutationResult:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [ok]
|
||||||
|
properties:
|
||||||
|
ok:
|
||||||
|
type: boolean
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
latePickup:
|
||||||
|
type: boolean
|
||||||
|
repointed:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
RosterPerson:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [extension, name, email]
|
||||||
|
properties:
|
||||||
|
extension:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
slackUserId:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
ShiftAssignee:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [extension, name]
|
||||||
|
properties:
|
||||||
|
extension:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
ShiftSlot:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- kind
|
||||||
|
- shiftType
|
||||||
|
- label
|
||||||
|
- slots
|
||||||
|
- openSlots
|
||||||
|
- multiplier
|
||||||
|
- assignees
|
||||||
|
- mine
|
||||||
|
- canPick
|
||||||
|
- canDrop
|
||||||
|
- latePickup
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum: [holiday, override, available, weekly]
|
||||||
|
shiftType:
|
||||||
|
type: string
|
||||||
|
enum: [day, night]
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
slots:
|
||||||
|
type: integer
|
||||||
|
openSlots:
|
||||||
|
type: integer
|
||||||
|
multiplier:
|
||||||
|
type: number
|
||||||
|
assignees:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ShiftAssignee"
|
||||||
|
mine:
|
||||||
|
type: boolean
|
||||||
|
canPick:
|
||||||
|
type: boolean
|
||||||
|
canDrop:
|
||||||
|
type: boolean
|
||||||
|
latePickup:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
ShiftDay:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [date, dayName, slots]
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
dayName:
|
||||||
|
type: string
|
||||||
|
slots:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ShiftSlot"
|
||||||
|
|
||||||
|
PendingSwap:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- date
|
||||||
|
- shiftType
|
||||||
|
- requesterExt
|
||||||
|
- requesterName
|
||||||
|
- targetExt
|
||||||
|
- targetName
|
||||||
|
- incoming
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
shiftType:
|
||||||
|
type: string
|
||||||
|
enum: [day, night]
|
||||||
|
requesterExt:
|
||||||
|
type: string
|
||||||
|
requesterName:
|
||||||
|
type: string
|
||||||
|
targetExt:
|
||||||
|
type: string
|
||||||
|
targetName:
|
||||||
|
type: string
|
||||||
|
incoming:
|
||||||
|
type: boolean
|
||||||
|
note:
|
||||||
|
type: string
|
||||||
|
maxLength: 500
|
||||||
|
|
||||||
|
PendingPickup:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [date, shiftType, requesterExt, requesterName, isHoliday]
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
shiftType:
|
||||||
|
type: string
|
||||||
|
enum: [day, night]
|
||||||
|
requesterExt:
|
||||||
|
type: string
|
||||||
|
requesterName:
|
||||||
|
type: string
|
||||||
|
isHoliday:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
HolidaySummary:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [date, label, slots, multiplier]
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
slots:
|
||||||
|
type: integer
|
||||||
|
multiplier:
|
||||||
|
type: number
|
||||||
|
|
||||||
|
ShiftsSnapshot:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [linked, isAdmin]
|
||||||
|
properties:
|
||||||
|
linked:
|
||||||
|
type: boolean
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
week:
|
||||||
|
type: string
|
||||||
|
enum: [this, next]
|
||||||
|
weekStart:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
me:
|
||||||
|
$ref: "#/components/schemas/RosterPerson"
|
||||||
|
isAdmin:
|
||||||
|
type: boolean
|
||||||
|
days:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ShiftDay"
|
||||||
|
pendingSwaps:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/PendingSwap"
|
||||||
|
pendingPickups:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/PendingPickup"
|
||||||
|
upcomingHolidays:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/HolidaySummary"
|
||||||
|
roster:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/RosterPerson"
|
||||||
30
package-lock.json
generated
Normal file
30
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "afterhours-shift-manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "afterhours-shift-manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@redocly/cli": "2.54.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redocly/cli": {
|
||||||
|
"version": "2.54.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.54.2.tgz",
|
||||||
|
"integrity": "sha512-YQ53kSQV/zpYSdY3WiQvf7JxuVLD8jTEX37YOY6MS+G3tyHDCeONpUkAt6qr9n2/nmGm6m+A+PB/mGFvhkt1uQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"openapi": "bin/cli.js",
|
||||||
|
"redocly": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0 || >=20.19.0 <21.0.0",
|
||||||
|
"npm": ">=10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
package.json
Normal file
11
package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"name": "afterhours-shift-manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@redocly/cli": "2.54.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,6 @@
|
||||||
# `src/shared` on the path makes the `shared` layer package importable as it is at
|
# `src/shared` on the path makes the `shared` layer package importable as it is at
|
||||||
# runtime. Each Lambda's own `app.py` is loaded under a unique name by the
|
# runtime. Each Lambda's own `app.py` is loaded under a unique name by the
|
||||||
# per-package conftest (importlib mode) to avoid the four-`app.py` collision.
|
# per-package conftest (importlib mode) to avoid the four-`app.py` collision.
|
||||||
pythonpath = ["src/shared"]
|
pythonpath = ["src", "src/shared"]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "--import-mode=importlib"
|
addopts = "--import-mode=importlib"
|
||||||
|
|
|
||||||
2
requirements-api.txt
Normal file
2
requirements-api.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
flask==3.1.3
|
||||||
|
gunicorn==26.2.0
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
version = 0.1
|
|
||||||
|
|
||||||
[default.deploy.parameters]
|
|
||||||
stack_name = "afterhours-shift-manager"
|
|
||||||
resolve_s3 = true
|
|
||||||
s3_prefix = "afterhours-shift-manager"
|
|
||||||
region = "us-east-1"
|
|
||||||
capabilities = "CAPABILITY_IAM"
|
|
||||||
confirm_changeset = true
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Thin CLI over ``shared.changelog`` for the release workflow.
|
|
||||||
|
|
||||||
Keeps all changelog parsing in one tested module instead of inline shell/Python
|
|
||||||
in the workflow. Reads the changelog file given as an argument.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
changelog_cli.py top-version <file> # newest entry's version
|
|
||||||
changelog_cli.py bump-kind <file> <prev> # major|minor|patch|none vs <prev>
|
|
||||||
changelog_cli.py payload <file> <version> # JSON {version, notes, date_label}
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src" / "shared"))
|
|
||||||
|
|
||||||
from shared.changelog import bump_kind, entry_for, top_version # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
|
||||||
if len(argv) < 3:
|
|
||||||
sys.exit(__doc__)
|
|
||||||
cmd, path = argv[1], argv[2]
|
|
||||||
text = pathlib.Path(path).read_text()
|
|
||||||
|
|
||||||
if cmd == "top-version":
|
|
||||||
sys.stdout.write(top_version(text) or "")
|
|
||||||
elif cmd == "bump-kind":
|
|
||||||
prev = argv[3].lstrip("v")
|
|
||||||
top = top_version(text)
|
|
||||||
sys.stdout.write((bump_kind(top, prev) or "none") if top else "none")
|
|
||||||
elif cmd == "payload":
|
|
||||||
version = argv[3].lstrip("v")
|
|
||||||
entry = entry_for(text, version)
|
|
||||||
sys.stdout.write(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"version": version,
|
|
||||||
"notes": entry.body if entry else "",
|
|
||||||
"date_label": entry.date_label if entry else "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
sys.exit(f"unknown command: {cmd}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main(sys.argv))
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""CI guard: validate CHANGELOG.md versioning and the in-package copy.
|
|
||||||
|
|
||||||
Run on pull requests. Two checks:
|
|
||||||
|
|
||||||
1. If CHANGELOG.md changed in the PR, its top version must be a clean
|
|
||||||
single-step SemVer bump above the latest ``v*`` tag. (Non-changing PRs —
|
|
||||||
dependabot bumps, docs — are not version-checked, so they don't release.)
|
|
||||||
2. ``src/slack-bot/CHANGELOG.md`` (the copy that ships with the bot and feeds the
|
|
||||||
App Home "What's New" tab) must match the canonical root CHANGELOG.md.
|
|
||||||
|
|
||||||
The workflow passes context via env: ``PREV_TAG`` (latest tag) and
|
|
||||||
``CHANGELOG_CHANGED`` ("true"/"false"). Run locally it assumes the changelog
|
|
||||||
changed so the bump is validated.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src" / "shared"))
|
|
||||||
|
|
||||||
from shared.changelog import bump_kind, top_version # noqa: E402
|
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
PKG_COPY = ROOT / "src" / "slack-bot" / "CHANGELOG.md"
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate(
|
|
||||||
top: str | None, prev_tag: str, changelog_changed: bool, copies_match: bool
|
|
||||||
) -> list[str]:
|
|
||||||
"""Return a list of problems (empty == pass). Pure, for unit testing."""
|
|
||||||
problems = []
|
|
||||||
if not copies_match:
|
|
||||||
problems.append(
|
|
||||||
"src/slack-bot/CHANGELOG.md is out of sync with CHANGELOG.md — "
|
|
||||||
"run scripts/sync_changelog.py"
|
|
||||||
)
|
|
||||||
if changelog_changed:
|
|
||||||
if top is None:
|
|
||||||
problems.append("CHANGELOG.md changed but has no version entry at the top")
|
|
||||||
else:
|
|
||||||
prev = (prev_tag or "v0.0.0").lstrip("v")
|
|
||||||
if bump_kind(top, prev) is None:
|
|
||||||
problems.append(
|
|
||||||
f"top version v{top} is not a clean single-step SemVer bump "
|
|
||||||
f"above the latest tag v{prev} (expected one of "
|
|
||||||
f"inc-major / inc-minor / inc-patch)"
|
|
||||||
)
|
|
||||||
return problems
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
root_text = (ROOT / "CHANGELOG.md").read_text()
|
|
||||||
copies_match = PKG_COPY.exists() and PKG_COPY.read_text() == root_text
|
|
||||||
|
|
||||||
problems = evaluate(
|
|
||||||
top=top_version(root_text),
|
|
||||||
prev_tag=os.environ.get("PREV_TAG", ""),
|
|
||||||
changelog_changed=os.environ.get("CHANGELOG_CHANGED", "true") == "true",
|
|
||||||
copies_match=copies_match,
|
|
||||||
)
|
|
||||||
if problems:
|
|
||||||
for problem in problems:
|
|
||||||
print(f"::error::{problem}")
|
|
||||||
return 1
|
|
||||||
print("CHANGELOG guard passed.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
101
scripts/cutover/copy_dynamodb.py
Normal file
101
scripts/cutover/copy_dynamodb.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Copy afterhours-shifts from mgmt to prod. Dry-run unless --execute."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
TABLE = "afterhours-shifts"
|
||||||
|
SRC_ACCOUNT = "328440206208"
|
||||||
|
DST_ACCOUNT = "011934824531"
|
||||||
|
|
||||||
|
|
||||||
|
def _client(profile: str, region: str):
|
||||||
|
session = boto3.Session(profile_name=profile, region_name=region)
|
||||||
|
return session.client("dynamodb")
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_all(client, *, consistent: bool = False):
|
||||||
|
items = []
|
||||||
|
kwargs = {"TableName": TABLE, "ConsistentRead": consistent}
|
||||||
|
while True:
|
||||||
|
resp = client.scan(**kwargs)
|
||||||
|
items.extend(resp.get("Items", []))
|
||||||
|
start = resp.get("LastEvaluatedKey")
|
||||||
|
if not start:
|
||||||
|
return items
|
||||||
|
kwargs["ExclusiveStartKey"] = start
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_write_all(client, items, *, sleep=time.sleep, max_attempts: int = 8) -> int:
|
||||||
|
"""Put every item. Retry UnprocessedItems with backoff. Raise if they remain."""
|
||||||
|
pending = [{"PutRequest": {"Item": item}} for item in items]
|
||||||
|
written = 0
|
||||||
|
while pending:
|
||||||
|
chunk, pending = pending[:25], pending[25:]
|
||||||
|
to_send = chunk
|
||||||
|
attempts = 0
|
||||||
|
while to_send:
|
||||||
|
attempts += 1
|
||||||
|
if attempts > max_attempts:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"batch_write_item left {len(to_send)} unprocessed after {max_attempts} attempts"
|
||||||
|
)
|
||||||
|
resp = client.batch_write_item(RequestItems={TABLE: to_send})
|
||||||
|
unprocessed = resp.get("UnprocessedItems", {}).get(TABLE, [])
|
||||||
|
written += len(to_send) - len(unprocessed)
|
||||||
|
to_send = unprocessed
|
||||||
|
if to_send:
|
||||||
|
sleep(min(0.1 * (2 ** (attempts - 1)), 5.0))
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--src-profile", required=True)
|
||||||
|
parser.add_argument("--dst-profile", required=True)
|
||||||
|
parser.add_argument("--region", default="us-east-1")
|
||||||
|
parser.add_argument("--execute", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
src = _client(args.src_profile, args.region)
|
||||||
|
dst = _client(args.dst_profile, args.region)
|
||||||
|
src_id = (
|
||||||
|
boto3.Session(profile_name=args.src_profile)
|
||||||
|
.client("sts")
|
||||||
|
.get_caller_identity()["Account"]
|
||||||
|
)
|
||||||
|
dst_id = (
|
||||||
|
boto3.Session(profile_name=args.dst_profile)
|
||||||
|
.client("sts")
|
||||||
|
.get_caller_identity()["Account"]
|
||||||
|
)
|
||||||
|
if src_id != SRC_ACCOUNT:
|
||||||
|
print(f"src account {src_id} is not mgmt {SRC_ACCOUNT}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if dst_id != DST_ACCOUNT:
|
||||||
|
print(f"dst account {dst_id} is not prod {DST_ACCOUNT}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
items = _scan_all(src, consistent=True)
|
||||||
|
dst_count = dst.describe_table(TableName=TABLE)["Table"]["ItemCount"]
|
||||||
|
print(f"src items={len(items)} dst describe ItemCount={dst_count}")
|
||||||
|
if not args.execute:
|
||||||
|
print("dry-run; pass --execute to BatchWriteItem")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
written = _batch_write_all(dst, items)
|
||||||
|
after = _scan_all(dst, consistent=True)
|
||||||
|
print(f"wrote={written} dst_scan={len(after)}")
|
||||||
|
if len(after) != len(items):
|
||||||
|
print("item counts differ after copy", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
141
scripts/cutover/copy_secrets.py
Normal file
141
scripts/cutover/copy_secrets.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Copy afterhours secrets mgmt → prod. Dry-run unless --execute.
|
||||||
|
|
||||||
|
Terraform creates empty secret shells. Slack, signing, and roster tokens are
|
||||||
|
written into those shells when the dest has no current string value. Populated
|
||||||
|
dest values are left alone. 3CX secrets are verified only and never written.
|
||||||
|
Strips trailing newlines. Never prints secret values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
SRC_ACCOUNT = "328440206208"
|
||||||
|
DST_ACCOUNT = "011934824531"
|
||||||
|
|
||||||
|
COPY = [
|
||||||
|
"afterhours-shift-manager/slack-bot-token",
|
||||||
|
"afterhours-shift-manager/slack-signing-secret",
|
||||||
|
"afterhours-shift-manager/roster-api-token",
|
||||||
|
]
|
||||||
|
|
||||||
|
VERIFY_ONLY = [
|
||||||
|
"afterhours-shift-manager/3cx-domain",
|
||||||
|
"afterhours-shift-manager/3cx-client-id",
|
||||||
|
"afterhours-shift-manager/3cx-client-secret",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Describe succeeds on a Terraform shell; GetSecretValue fails until a version exists.
|
||||||
|
_NO_VALUE_CODES = frozenset({"ResourceNotFoundException", "InvalidRequestException"})
|
||||||
|
|
||||||
|
|
||||||
|
def _client(profile: str, region: str):
|
||||||
|
return boto3.Session(profile_name=profile, region_name=region).client(
|
||||||
|
"secretsmanager"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _account(profile: str) -> str:
|
||||||
|
return (
|
||||||
|
boto3.Session(profile_name=profile)
|
||||||
|
.client("sts")
|
||||||
|
.get_caller_identity()["Account"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def secret_string(client, name: str) -> str | None:
|
||||||
|
"""Return the current SecretString, or None if the secret does not exist.
|
||||||
|
|
||||||
|
An empty string means the secret exists (Terraform shell) but has no usable
|
||||||
|
current version.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client.describe_secret(SecretId=name)
|
||||||
|
except ClientError as exc:
|
||||||
|
if exc.response["Error"]["Code"] == "ResourceNotFoundException":
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
payload = client.get_secret_value(SecretId=name)
|
||||||
|
except ClientError as exc:
|
||||||
|
if exc.response["Error"]["Code"] in _NO_VALUE_CODES:
|
||||||
|
return ""
|
||||||
|
raise
|
||||||
|
value = payload.get("SecretString")
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def copy_secrets(src, dst, *, execute: bool) -> int:
|
||||||
|
rc = 0
|
||||||
|
|
||||||
|
for name in VERIFY_ONLY:
|
||||||
|
value = secret_string(dst, name)
|
||||||
|
if value is None:
|
||||||
|
print(
|
||||||
|
f"missing prod secret {name} (expected from PLAT-76)", file=sys.stderr
|
||||||
|
)
|
||||||
|
rc = 1
|
||||||
|
elif not value.strip():
|
||||||
|
print(
|
||||||
|
f"empty prod 3cx secret {name} (do not overwrite from mgmt)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
rc = 1
|
||||||
|
else:
|
||||||
|
print(f"keep existing prod secret {name}")
|
||||||
|
|
||||||
|
for name in COPY:
|
||||||
|
src_value = secret_string(src, name)
|
||||||
|
if src_value is None or not src_value.strip():
|
||||||
|
print(f"missing mgmt secret {name}", file=sys.stderr)
|
||||||
|
rc = 1
|
||||||
|
continue
|
||||||
|
dest_value = secret_string(dst, name)
|
||||||
|
if dest_value is None:
|
||||||
|
print(f"missing prod secret shell {name}", file=sys.stderr)
|
||||||
|
rc = 1
|
||||||
|
continue
|
||||||
|
if dest_value.strip():
|
||||||
|
print(f"skip populated prod secret {name}")
|
||||||
|
continue
|
||||||
|
print(f"would copy {name}")
|
||||||
|
if not execute:
|
||||||
|
continue
|
||||||
|
value = src_value.rstrip("\n")
|
||||||
|
dst.put_secret_value(SecretId=name, SecretString=value)
|
||||||
|
print(f"wrote {name} ({len(value)} chars)")
|
||||||
|
|
||||||
|
if not execute:
|
||||||
|
print("dry-run; pass --execute to PutSecretValue")
|
||||||
|
return rc
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--src-profile", required=True)
|
||||||
|
parser.add_argument("--dst-profile", required=True)
|
||||||
|
parser.add_argument("--region", default="us-east-1")
|
||||||
|
parser.add_argument("--execute", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if _account(args.src_profile) != SRC_ACCOUNT:
|
||||||
|
print("src profile is not mgmt", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if _account(args.dst_profile) != DST_ACCOUNT:
|
||||||
|
print("dst profile is not prod", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
src = _client(args.src_profile, args.region)
|
||||||
|
dst = _client(args.dst_profile, args.region)
|
||||||
|
return copy_secrets(src, dst, execute=args.execute)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
149
scripts/cutover/recreate_holiday_schedules.py
Normal file
149
scripts/cutover/recreate_holiday_schedules.py
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Recreate future holiday-* EventBridge Scheduler schedules in prod.
|
||||||
|
|
||||||
|
Reads outstanding holiday-activate-* / holiday-deactivate-* from mgmt and
|
||||||
|
creates the same names in prod targeting the prod router ARN and scheduler
|
||||||
|
role. Dry-run unless --execute.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
SRC_ACCOUNT = "328440206208"
|
||||||
|
DST_ACCOUNT = "011934824531"
|
||||||
|
PROD_ROUTER_ARN = (
|
||||||
|
"arn:aws:lambda:us-east-1:011934824531:function:afterhours-holiday-router"
|
||||||
|
)
|
||||||
|
PROD_ROLE_ARN = "arn:aws:iam::011934824531:role/tf-managed/afterhours-shift-manager-holiday-scheduler"
|
||||||
|
PREFIXES = ("holiday-activate-", "holiday-deactivate-")
|
||||||
|
_AT = re.compile(r"^at\((\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _client(profile: str, region: str):
|
||||||
|
return boto3.Session(profile_name=profile, region_name=region).client("scheduler")
|
||||||
|
|
||||||
|
|
||||||
|
def _account(profile: str) -> str:
|
||||||
|
return (
|
||||||
|
boto3.Session(profile_name=profile)
|
||||||
|
.client("sts")
|
||||||
|
.get_caller_identity()["Account"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_when(detail: dict) -> datetime | None:
|
||||||
|
"""UTC instant the one-off schedule fires, or None if it cannot be parsed."""
|
||||||
|
expr = (detail.get("ScheduleExpression") or "").strip()
|
||||||
|
tzname = detail.get("ScheduleExpressionTimezone") or "America/New_York"
|
||||||
|
match = _AT.match(expr)
|
||||||
|
if match:
|
||||||
|
naive = datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S")
|
||||||
|
return naive.replace(tzinfo=ZoneInfo(tzname)).astimezone(timezone.utc)
|
||||||
|
at = detail.get("EndDate") or detail.get("StartDate")
|
||||||
|
if at is None:
|
||||||
|
return None
|
||||||
|
if at.tzinfo is None:
|
||||||
|
return at.replace(tzinfo=timezone.utc)
|
||||||
|
return at.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_holiday(client):
|
||||||
|
names = []
|
||||||
|
token = None
|
||||||
|
while True:
|
||||||
|
kwargs = {"GroupName": "default"}
|
||||||
|
if token:
|
||||||
|
kwargs["NextToken"] = token
|
||||||
|
resp = client.list_schedules(**kwargs)
|
||||||
|
for item in resp.get("Schedules", []):
|
||||||
|
name = item.get("Name", "")
|
||||||
|
if name.startswith(PREFIXES):
|
||||||
|
names.append(name)
|
||||||
|
token = resp.get("NextToken")
|
||||||
|
if not token:
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--src-profile", required=True)
|
||||||
|
parser.add_argument("--dst-profile", required=True)
|
||||||
|
parser.add_argument("--region", default="us-east-1")
|
||||||
|
parser.add_argument("--execute", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if _account(args.src_profile) != SRC_ACCOUNT:
|
||||||
|
print("src profile is not mgmt", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if _account(args.dst_profile) != DST_ACCOUNT:
|
||||||
|
print("dst profile is not prod", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
src = _client(args.src_profile, args.region)
|
||||||
|
dst = _client(args.dst_profile, args.region)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
created = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for name in _list_holiday(src):
|
||||||
|
detail = src.get_schedule(Name=name, GroupName="default")
|
||||||
|
expr = detail.get("ScheduleExpression", "")
|
||||||
|
tzname = detail.get("ScheduleExpressionTimezone", "America/New_York")
|
||||||
|
when = schedule_when(detail)
|
||||||
|
if when is not None and when < now:
|
||||||
|
print(f"skip past {name} expr={expr}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
payload = {
|
||||||
|
"Name": name,
|
||||||
|
"GroupName": "default",
|
||||||
|
"ScheduleExpression": expr,
|
||||||
|
"ScheduleExpressionTimezone": tzname,
|
||||||
|
"FlexibleTimeWindow": {"Mode": "OFF"},
|
||||||
|
"Target": {
|
||||||
|
"Arn": PROD_ROUTER_ARN,
|
||||||
|
"RoleArn": PROD_ROLE_ARN,
|
||||||
|
"Input": detail.get("Target", {}).get("Input", ""),
|
||||||
|
},
|
||||||
|
"ActionAfterCompletion": detail.get("ActionAfterCompletion", "DELETE"),
|
||||||
|
}
|
||||||
|
if detail.get("EndDate"):
|
||||||
|
payload["EndDate"] = detail["EndDate"]
|
||||||
|
print(f"would create {name} expr={expr} tz={tzname}")
|
||||||
|
if not args.execute:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
dst.create_schedule(**payload)
|
||||||
|
created += 1
|
||||||
|
except ClientError as exc:
|
||||||
|
code = exc.response["Error"]["Code"]
|
||||||
|
if code == "ConflictException":
|
||||||
|
print(f"exists {name}")
|
||||||
|
elif code == "ValidationException":
|
||||||
|
print(
|
||||||
|
f"skip invalid {name}: {exc.response['Error'].get('Message', code)}"
|
||||||
|
)
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
print(f"failed {name}: {code}", file=sys.stderr)
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"created={created} skipped_past={skipped} failed={failed} execute={args.execute}"
|
||||||
|
)
|
||||||
|
if not args.execute:
|
||||||
|
print("dry-run; pass --execute to CreateSchedule")
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
181
scripts/cutover/retarget_holiday_schedules_to_sqs.py
Normal file
181
scripts/cutover/retarget_holiday_schedules_to_sqs.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Retarget outstanding holiday-* Scheduler one-offs from Lambda to jobs SQS.
|
||||||
|
|
||||||
|
Lists holiday-activate-* / holiday-deactivate-* in the destination account and
|
||||||
|
updates Target to the jobs queue with Input
|
||||||
|
``{"event":"holiday","action":"activate|deactivate","date":"YYYY-MM-DD"}``.
|
||||||
|
Dry-run unless --execute. Does not create schedules that are already past.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
PROD_ACCOUNT = "011934824531"
|
||||||
|
DEV_ACCOUNT = "710827005802"
|
||||||
|
HOLIDAY_SCHEDULER_ROLE = "afterhours-shift-manager-holiday-scheduler"
|
||||||
|
PREFIXES = ("holiday-activate-", "holiday-deactivate-")
|
||||||
|
_AT = re.compile(r"^at\((\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\)$")
|
||||||
|
_DATE = re.compile(r"(\d{4}-\d{2}-\d{2})")
|
||||||
|
|
||||||
|
|
||||||
|
def _client(profile: str, region: str):
|
||||||
|
return boto3.Session(profile_name=profile, region_name=region).client("scheduler")
|
||||||
|
|
||||||
|
|
||||||
|
def _account(profile: str) -> str:
|
||||||
|
return (
|
||||||
|
boto3.Session(profile_name=profile)
|
||||||
|
.client("sts")
|
||||||
|
.get_caller_identity()["Account"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_when(detail: dict) -> datetime | None:
|
||||||
|
expr = (detail.get("ScheduleExpression") or "").strip()
|
||||||
|
tzname = detail.get("ScheduleExpressionTimezone") or "America/New_York"
|
||||||
|
match = _AT.match(expr)
|
||||||
|
if match:
|
||||||
|
naive = datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S")
|
||||||
|
return naive.replace(tzinfo=ZoneInfo(tzname)).astimezone(timezone.utc)
|
||||||
|
at = detail.get("EndDate") or detail.get("StartDate")
|
||||||
|
if at is None:
|
||||||
|
return None
|
||||||
|
if at.tzinfo is None:
|
||||||
|
return at.replace(tzinfo=timezone.utc)
|
||||||
|
return at.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def action_and_date(name: str, existing_input: str) -> tuple[str, str]:
|
||||||
|
action = "deactivate" if name.startswith("holiday-deactivate-") else "activate"
|
||||||
|
date = ""
|
||||||
|
if existing_input:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(existing_input)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed = {}
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
date = str(parsed.get("date") or "")
|
||||||
|
if parsed.get("action") in {"activate", "deactivate"}:
|
||||||
|
action = parsed["action"]
|
||||||
|
if not date:
|
||||||
|
compact = name.split("-")[-1]
|
||||||
|
if len(compact) == 8 and compact.isdigit():
|
||||||
|
date = f"{compact[0:4]}-{compact[4:6]}-{compact[6:8]}"
|
||||||
|
if not date:
|
||||||
|
match = _DATE.search(existing_input or "")
|
||||||
|
if match:
|
||||||
|
date = match.group(1)
|
||||||
|
if not date:
|
||||||
|
raise ValueError(f"cannot derive date from {name}")
|
||||||
|
return action, date
|
||||||
|
|
||||||
|
|
||||||
|
def holiday_scheduler_role_arn(account: str) -> str:
|
||||||
|
return f"arn:aws:iam::{account}:role/tf-managed/{HOLIDAY_SCHEDULER_ROLE}"
|
||||||
|
|
||||||
|
|
||||||
|
def holiday_sqs_input(action: str, date: str) -> str:
|
||||||
|
return json.dumps({"event": "holiday", "action": action, "date": date})
|
||||||
|
|
||||||
|
|
||||||
|
def _list_holiday(client):
|
||||||
|
names = []
|
||||||
|
token = None
|
||||||
|
while True:
|
||||||
|
kwargs = {"GroupName": "default"}
|
||||||
|
if token:
|
||||||
|
kwargs["NextToken"] = token
|
||||||
|
resp = client.list_schedules(**kwargs)
|
||||||
|
for item in resp.get("Schedules", []):
|
||||||
|
name = item.get("Name", "")
|
||||||
|
if name.startswith(PREFIXES):
|
||||||
|
names.append(name)
|
||||||
|
token = resp.get("NextToken")
|
||||||
|
if not token:
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--profile", required=True)
|
||||||
|
parser.add_argument("--region", default="us-east-1")
|
||||||
|
parser.add_argument("--queue-arn", required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--role-arn",
|
||||||
|
default="",
|
||||||
|
help="Scheduler execution role. Empty uses the tf-managed holiday role in the caller account.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--execute", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
account = _account(args.profile)
|
||||||
|
if account not in {PROD_ACCOUNT, DEV_ACCOUNT}:
|
||||||
|
print("profile is not seahaven-prod or seahaven-dev", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
role_arn = args.role_arn.strip() or holiday_scheduler_role_arn(account)
|
||||||
|
|
||||||
|
client = _client(args.profile, args.region)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for name in _list_holiday(client):
|
||||||
|
detail = client.get_schedule(Name=name, GroupName="default")
|
||||||
|
expr = detail.get("ScheduleExpression", "")
|
||||||
|
when = schedule_when(detail)
|
||||||
|
if when is not None and when < now:
|
||||||
|
print(f"skip past {name} expr={expr}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
action, date = action_and_date(
|
||||||
|
name, detail.get("Target", {}).get("Input", "")
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"skip {exc}", file=sys.stderr)
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
payload = holiday_sqs_input(action, date)
|
||||||
|
print(f"would retarget {name} action={action} date={date}")
|
||||||
|
if not args.execute:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
client.update_schedule(
|
||||||
|
Name=name,
|
||||||
|
GroupName="default",
|
||||||
|
ScheduleExpression=expr,
|
||||||
|
ScheduleExpressionTimezone=detail.get(
|
||||||
|
"ScheduleExpressionTimezone", "America/New_York"
|
||||||
|
),
|
||||||
|
FlexibleTimeWindow={"Mode": "OFF"},
|
||||||
|
ActionAfterCompletion=detail.get("ActionAfterCompletion", "DELETE"),
|
||||||
|
Target={
|
||||||
|
"Arn": args.queue_arn,
|
||||||
|
"RoleArn": role_arn,
|
||||||
|
"Input": payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
except ClientError as exc:
|
||||||
|
code = exc.response["Error"]["Code"]
|
||||||
|
print(f"failed {name}: {code}", file=sys.stderr)
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print(f"updated={updated} skipped={skipped} failed={failed} execute={args.execute}")
|
||||||
|
if not args.execute:
|
||||||
|
print("dry-run; pass --execute to UpdateSchedule")
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
142
scripts/package_lambdas.py
Normal file
142
scripts/package_lambdas.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build Lambda zips with src/shared bundled in. Used by deploy.yaml.
|
||||||
|
|
||||||
|
Each zip is functions/<name>/<sha>.zip on S3. GIT_SHA is written to
|
||||||
|
shared/build_info.py inside the zip so Sentry release is the commit, not a
|
||||||
|
runtime env var Terraform would own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
# Keys match terraform/locals.tf local.functions.
|
||||||
|
FUNCTIONS = {
|
||||||
|
"slack_bot": ROOT / "src" / "slack-bot",
|
||||||
|
"weekly_post": ROOT / "src" / "weekly-post",
|
||||||
|
"roster_sync": ROOT / "src" / "roster-sync",
|
||||||
|
"roster_api": ROOT / "src" / "roster-api",
|
||||||
|
"ring_scheduler": ROOT / "src" / "ring-scheduler",
|
||||||
|
"holiday_router": ROOT / "src" / "holiday-router",
|
||||||
|
"portal_api": ROOT / "src" / "portal-api",
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_INSTALL_PREFIXES = ("boto3", "botocore")
|
||||||
|
SKIP_COPY_NAMES = {"requirements.txt", "__pycache__"}
|
||||||
|
|
||||||
|
|
||||||
|
def _req_lines(path: Path) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
if not path.is_file():
|
||||||
|
return lines
|
||||||
|
for raw in path.read_text().splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
lower = line.lower()
|
||||||
|
if any(lower.startswith(prefix) for prefix in SKIP_INSTALL_PREFIXES):
|
||||||
|
continue
|
||||||
|
lines.append(line)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_tree(src: Path, dest: Path) -> None:
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
for item in src.iterdir():
|
||||||
|
if item.name in SKIP_COPY_NAMES or item.name.endswith(".pyc"):
|
||||||
|
continue
|
||||||
|
target = dest / item.name
|
||||||
|
if item.is_dir():
|
||||||
|
if item.name == "__pycache__":
|
||||||
|
continue
|
||||||
|
shutil.copytree(
|
||||||
|
item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
shutil.copy2(item, target)
|
||||||
|
|
||||||
|
|
||||||
|
def build_function(name: str, src: Path, git_sha: str, out_dir: Path) -> Path:
|
||||||
|
with tempfile.TemporaryDirectory(prefix=f"afterhours-{name}-") as tmp:
|
||||||
|
dest = Path(tmp)
|
||||||
|
_copy_tree(src, dest)
|
||||||
|
shared_src = ROOT / "src" / "shared" / "shared"
|
||||||
|
_copy_tree(shared_src, dest / "shared")
|
||||||
|
(dest / "shared" / "build_info.py").write_text(
|
||||||
|
f'"""Pinned at zip time by scripts/package_lambdas.py."""\n\nGIT_SHA = "{git_sha}"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
reqs = _req_lines(src / "requirements.txt") + _req_lines(
|
||||||
|
ROOT / "src" / "shared" / "requirements.txt"
|
||||||
|
)
|
||||||
|
# Preserve order, drop duplicates.
|
||||||
|
seen: set[str] = set()
|
||||||
|
unique: list[str] = []
|
||||||
|
for line in reqs:
|
||||||
|
if line not in seen:
|
||||||
|
seen.add(line)
|
||||||
|
unique.append(line)
|
||||||
|
if unique:
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--disable-pip-version-check",
|
||||||
|
"--no-compile",
|
||||||
|
"--python-version",
|
||||||
|
"3.12",
|
||||||
|
"--platform",
|
||||||
|
"manylinux2014_aarch64",
|
||||||
|
"--only-binary=:all:",
|
||||||
|
"--target",
|
||||||
|
str(dest),
|
||||||
|
*unique,
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
zip_path = out_dir / f"{name}.zip"
|
||||||
|
if zip_path.exists():
|
||||||
|
zip_path.unlink()
|
||||||
|
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for dirpath, dirnames, filenames in os.walk(dest):
|
||||||
|
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
||||||
|
for filename in filenames:
|
||||||
|
if filename.endswith(".pyc"):
|
||||||
|
continue
|
||||||
|
full = Path(dirpath) / filename
|
||||||
|
rel = full.relative_to(dest)
|
||||||
|
zf.write(full, rel.as_posix())
|
||||||
|
return zip_path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--git-sha", required=True)
|
||||||
|
parser.add_argument("--out-dir", type=Path, default=ROOT / "build" / "packages")
|
||||||
|
parser.add_argument("--only", nargs="*", default=())
|
||||||
|
args = parser.parse_args()
|
||||||
|
selected = args.only or list(FUNCTIONS)
|
||||||
|
missing = [name for name in selected if name not in FUNCTIONS]
|
||||||
|
if missing:
|
||||||
|
print(f"unknown function keys: {missing}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
for name in selected:
|
||||||
|
path = build_function(name, FUNCTIONS[name], args.git_sha, args.out_dir)
|
||||||
|
print(path)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Copy the canonical root CHANGELOG.md into the slack-bot package.
|
"""Copy the canonical root CHANGELOG.md into the slack-bot package.
|
||||||
|
|
||||||
The bot's App Home "What's New" tab reads CHANGELOG.md from its own deployment
|
The bot's App Home "What's New" tab reads CHANGELOG.md next to app.py in the
|
||||||
package ($LAMBDA_TASK_ROOT), so a copy must live under src/slack-bot/ (the
|
Fargate image, so a copy must live under src/slack-bot/. The root file is the
|
||||||
function's CodeUri). The root file is the single source of truth; run this after
|
single source of truth; run this after editing it. Pytest fails if the two drift.
|
||||||
editing it. CI's check_changelog.py fails if the two drift.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,6 @@
|
||||||
A holiday is a day-only shift (08:00-17:00 ET), one HOLIDAY record per date.
|
A holiday is a day-only shift (08:00-17:00 ET), one HOLIDAY record per date.
|
||||||
At 08:00 this Lambda is invoked with ``{"action": "activate", "date": ...}`` and
|
At 08:00 this Lambda is invoked with ``{"action": "activate", "date": ...}`` and
|
||||||
at 17:00 with ``{"action": "deactivate", ...}``.
|
at 17:00 with ``{"action": "deactivate", ...}``.
|
||||||
|
|
||||||
Activate:
|
|
||||||
* Capture both IVR 800 routes (key-0 and no-input/timeout) into
|
|
||||||
``CONFIG.captured_ivr_routes`` — but only if they are NOT already pointed at
|
|
||||||
the holiday queue (so a re-run never overwrites the real originals).
|
|
||||||
* Set queue 802's agents to the holiday's assignees, or ``[FALLBACK_EXTENSION]``
|
|
||||||
("100") when no slots are filled. Membership is set ONCE here.
|
|
||||||
* Repoint BOTH IVR 800 routes to the holiday queue (802).
|
|
||||||
* Mark the holiday ``activated = True``.
|
|
||||||
Idempotent: no-op if the record is gone or already activated.
|
|
||||||
|
|
||||||
Deactivate:
|
|
||||||
* Restore both IVR routes from ``CONFIG.captured_ivr_routes`` — only the routes
|
|
||||||
that currently point at the queue are reverted (defensive against manual
|
|
||||||
changes); clear the captured routes afterwards.
|
|
||||||
* Clear queue 802's agents.
|
|
||||||
* Mark the holiday ``activated = False``.
|
|
||||||
Idempotent: no-op if the record is gone or not activated.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -28,7 +10,8 @@ import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import shared.sentry_init # noqa: F401
|
import shared.sentry_init # noqa: F401
|
||||||
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
from shared.holiday_flow import activate, deactivate
|
||||||
|
from shared.schedule import ShiftSchedule
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
from shared.three_cx_client import ThreeCXClient
|
from shared.three_cx_client import ThreeCXClient
|
||||||
|
|
||||||
|
|
@ -46,124 +29,19 @@ def _make_client() -> ThreeCXClient:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _holiday_extensions(holiday: dict) -> list[str]:
|
|
||||||
"""Assignee extensions for the holiday, or the fallback when none claimed."""
|
|
||||||
assignees = holiday.get("assignees", {}) or {}
|
|
||||||
extensions = list(assignees.keys())
|
|
||||||
return extensions or [FALLBACK_EXTENSION]
|
|
||||||
|
|
||||||
|
|
||||||
def _activate(schedule: ShiftSchedule, date: str) -> dict:
|
def _activate(schedule: ShiftSchedule, date: str) -> dict:
|
||||||
holiday = schedule.get_holiday(date)
|
return activate(schedule, date, client_factory=_make_client)
|
||||||
if holiday is None:
|
|
||||||
logger.info("No holiday record for %s — nothing to activate", date)
|
|
||||||
return {"action": "activate", "date": date, "skipped": "no_record"}
|
|
||||||
if holiday.get("activated"):
|
|
||||||
logger.info("Holiday %s already activated — no-op", date)
|
|
||||||
return {"action": "activate", "date": date, "skipped": "already_active"}
|
|
||||||
|
|
||||||
queue_number = schedule.get_holiday_queue()
|
|
||||||
ivr_number = schedule.get_ivr_number()
|
|
||||||
extensions = _holiday_extensions(holiday)
|
|
||||||
|
|
||||||
client = _make_client()
|
|
||||||
|
|
||||||
# Capture the live IVR routes BEFORE repointing — but guard against storing
|
|
||||||
# holiday-state routes: if both routes already target the queue, a prior
|
|
||||||
# activation is in effect, so keep whatever originals we already captured.
|
|
||||||
ivr = client.get_ivr(ivr_number)
|
|
||||||
ivr_id = ivr["Id"]
|
|
||||||
current = client.extract_ivr_routes(ivr)
|
|
||||||
already_queue = str(current.get("key0")) == str(queue_number) and str(
|
|
||||||
current.get("timeout")
|
|
||||||
) == str(queue_number)
|
|
||||||
if already_queue:
|
|
||||||
logger.info(
|
|
||||||
"IVR %s already points at queue %s — not re-capturing routes",
|
|
||||||
ivr_number,
|
|
||||||
queue_number,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
schedule.set_captured_ivr_routes(current)
|
|
||||||
|
|
||||||
# Set queue membership ONCE, then repoint both IVR routes to the queue.
|
|
||||||
queue = client.get_queue(queue_number)
|
|
||||||
client.set_queue_agents(queue["Id"], extensions)
|
|
||||||
|
|
||||||
client.set_ivr_routes(ivr_id, key0_dn=queue_number, timeout_dn=queue_number)
|
|
||||||
|
|
||||||
schedule.set_holiday_activated(date, True)
|
|
||||||
logger.info(
|
|
||||||
"Activated holiday %s: queue %s agents=%s, IVR %s -> queue",
|
|
||||||
date,
|
|
||||||
queue_number,
|
|
||||||
extensions,
|
|
||||||
ivr_number,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"action": "activate",
|
|
||||||
"date": date,
|
|
||||||
"queue": str(queue_number),
|
|
||||||
"ivr": str(ivr_number),
|
|
||||||
"agents": extensions,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _deactivate(schedule: ShiftSchedule, date: str) -> dict:
|
def _deactivate(schedule: ShiftSchedule, date: str) -> dict:
|
||||||
holiday = schedule.get_holiday(date)
|
return deactivate(schedule, date, client_factory=_make_client)
|
||||||
if holiday is None:
|
|
||||||
logger.info("No holiday record for %s — nothing to deactivate", date)
|
|
||||||
return {"action": "deactivate", "date": date, "skipped": "no_record"}
|
|
||||||
if not holiday.get("activated"):
|
|
||||||
logger.info("Holiday %s not activated — no-op", date)
|
|
||||||
return {"action": "deactivate", "date": date, "skipped": "not_active"}
|
|
||||||
|
|
||||||
queue_number = schedule.get_holiday_queue()
|
|
||||||
ivr_number = schedule.get_ivr_number()
|
|
||||||
captured = schedule.get_captured_ivr_routes() or {}
|
|
||||||
|
|
||||||
client = _make_client()
|
|
||||||
|
|
||||||
ivr = client.get_ivr(ivr_number)
|
|
||||||
ivr_id = ivr["Id"]
|
|
||||||
live = client.extract_ivr_routes(ivr)
|
|
||||||
|
|
||||||
# Only restore a route if it currently points at the queue; otherwise leave
|
|
||||||
# whatever destination it has now (it was changed outside this flow).
|
|
||||||
def _restore(which: str) -> str | None:
|
|
||||||
live_dn = live.get(which)
|
|
||||||
if str(live_dn) == str(queue_number):
|
|
||||||
# Restore the captured pre-holiday DN; if it was lost, keep the live
|
|
||||||
# value rather than blanking the IVR destination.
|
|
||||||
return captured.get(which) or live_dn
|
|
||||||
return None # not pointing at the holiday queue — leave it untouched
|
|
||||||
|
|
||||||
client.set_ivr_routes(
|
|
||||||
ivr_id,
|
|
||||||
key0_dn=_restore("key0"),
|
|
||||||
timeout_dn=_restore("timeout"),
|
|
||||||
)
|
|
||||||
|
|
||||||
queue = client.get_queue(queue_number)
|
|
||||||
client.set_queue_agents(queue["Id"], [])
|
|
||||||
|
|
||||||
schedule.set_captured_ivr_routes(None)
|
|
||||||
schedule.set_holiday_activated(date, False)
|
|
||||||
logger.info(
|
|
||||||
"Deactivated holiday %s: restored IVR %s, cleared queue %s",
|
|
||||||
date,
|
|
||||||
ivr_number,
|
|
||||||
queue_number,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"action": "deactivate",
|
|
||||||
"date": date,
|
|
||||||
"queue": str(queue_number),
|
|
||||||
"ivr": str(ivr_number),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
def handler(event, context):
|
||||||
|
if os.environ.get("STAGE", "prod") != "prod":
|
||||||
|
logger.info("Skipping holiday router because STAGE is not prod")
|
||||||
|
return {"skipped": "non_prod"}
|
||||||
|
|
||||||
action = event.get("action")
|
action = event.get("action")
|
||||||
date = event.get("date")
|
date = event.get("date")
|
||||||
logger.info("Holiday router invoked: action=%s date=%s", action, date)
|
logger.info("Holiday router invoked: action=%s date=%s", action, date)
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
boto3>=1.43.82
|
boto3>=1.43.99
|
||||||
requests>=2.34.2
|
requests>=2.34.2
|
||||||
|
|
|
||||||
93
src/portal-api/app.py
Normal file
93
src/portal-api/app.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""HTTP API v2 handler — Cognito-authenticated employee/admin shift API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import shared.sentry_init # noqa: F401
|
||||||
|
from shared.portal_http import cors_headers, encode_body, handle
|
||||||
|
from shared.portal_ops import ActionError
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
CORS_ORIGINS = {
|
||||||
|
"https://internal.seahaven.com",
|
||||||
|
"https://internal.dev.seahaven.com",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _header(event: dict, name: str) -> str:
|
||||||
|
headers = event.get("headers") or {}
|
||||||
|
if not isinstance(headers, dict):
|
||||||
|
return ""
|
||||||
|
target = name.lower()
|
||||||
|
for key, value in headers.items():
|
||||||
|
if str(key).lower() == target:
|
||||||
|
return "" if value is None else str(value)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _route(event: dict) -> tuple[str, str]:
|
||||||
|
request_context = event.get("requestContext") or {}
|
||||||
|
http = request_context.get("http") or {}
|
||||||
|
method = str(http.get("method") or event.get("httpMethod") or "").upper()
|
||||||
|
path = str(event.get("rawPath") or http.get("path") or event.get("path") or "")
|
||||||
|
route_key = event.get("routeKey")
|
||||||
|
if isinstance(route_key, str) and " " in route_key:
|
||||||
|
rk_method, rk_path = route_key.split(" ", 1)
|
||||||
|
method = method or rk_method.upper()
|
||||||
|
path = path or rk_path
|
||||||
|
return method, path.rstrip("/") or "/"
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_body(event: dict) -> bytes | str | None:
|
||||||
|
body = event.get("body")
|
||||||
|
if body is None:
|
||||||
|
return None
|
||||||
|
if event.get("isBase64Encoded"):
|
||||||
|
if isinstance(body, bytes):
|
||||||
|
body = body.decode("ascii")
|
||||||
|
return base64.b64decode(body)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def handler(event, context):
|
||||||
|
try:
|
||||||
|
method, path = _route(event)
|
||||||
|
status, headers, payload = handle(
|
||||||
|
method=method,
|
||||||
|
path=path,
|
||||||
|
origin=_header(event, "origin"),
|
||||||
|
authorization=_header(event, "authorization"),
|
||||||
|
query=event.get("queryStringParameters")
|
||||||
|
if isinstance(event.get("queryStringParameters"), dict)
|
||||||
|
else {},
|
||||||
|
body=_raw_body(event),
|
||||||
|
)
|
||||||
|
if status == 204:
|
||||||
|
return {"statusCode": 204, "headers": headers, "body": ""}
|
||||||
|
return {
|
||||||
|
"statusCode": status,
|
||||||
|
"headers": headers,
|
||||||
|
"body": encode_body(payload),
|
||||||
|
}
|
||||||
|
except ActionError as exc:
|
||||||
|
logger.exception("portal api action error")
|
||||||
|
return {
|
||||||
|
"statusCode": exc.status,
|
||||||
|
"headers": cors_headers(_header(event, "origin")),
|
||||||
|
"body": encode_body({"error": {"code": exc.code, "message": exc.message}}),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
logger.exception("portal api unexpected failure")
|
||||||
|
return {
|
||||||
|
"statusCode": 500,
|
||||||
|
"headers": cors_headers(_header(event, "origin")),
|
||||||
|
"body": encode_body(
|
||||||
|
{"error": {"code": "INTERNAL", "message": "Internal error"}}
|
||||||
|
),
|
||||||
|
}
|
||||||
3
src/portal-api/requirements.txt
Normal file
3
src/portal-api/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
PyJWT[crypto]==2.14.0
|
||||||
|
boto3>=1.43.99
|
||||||
|
requests>=2.34.2
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"""Lambda handler — announces a new release to the shift channel.
|
|
||||||
|
|
||||||
Invoked by the release workflow (``.github/workflows/release.yaml``) once a
|
|
||||||
minor or major version has been tagged *and* the new code has deployed
|
|
||||||
successfully. The event carries the version and notes already extracted from
|
|
||||||
CHANGELOG.md by the workflow, so this function never reads the changelog file
|
|
||||||
itself (it ships only in the slack-bot package, not here).
|
|
||||||
|
|
||||||
Event shape (the frozen contract between release.yaml and this function):
|
|
||||||
{"version": "1.10.0", "notes": "<markdown>", "date_label": "June 11, 2026"}
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
from slack_sdk import WebClient
|
|
||||||
|
|
||||||
import shared.sentry_init # noqa: F401
|
|
||||||
from shared.blocks import build_release_announcement_blocks
|
|
||||||
from shared.secrets import get_secret
|
|
||||||
|
|
||||||
logger = logging.getLogger()
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
|
||||||
event = event or {}
|
|
||||||
version = event.get("version")
|
|
||||||
notes = event.get("notes")
|
|
||||||
date_label = event.get("date_label", "")
|
|
||||||
|
|
||||||
if not version or not notes:
|
|
||||||
raise ValueError("event requires non-empty 'version' and 'notes'")
|
|
||||||
|
|
||||||
bot_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"])
|
|
||||||
channel_id = os.environ["SHIFT_CHANNEL"]
|
|
||||||
slack = WebClient(token=bot_token)
|
|
||||||
|
|
||||||
blocks = build_release_announcement_blocks(version, notes, date_label)
|
|
||||||
result = slack.chat_postMessage(
|
|
||||||
channel=channel_id,
|
|
||||||
blocks=blocks,
|
|
||||||
text=f"What's New — v{version}",
|
|
||||||
)
|
|
||||||
logger.info("Announced v%s to %s (ts=%s)", version, channel_id, result["ts"])
|
|
||||||
return {"announced": True, "version": version, "ts": result["ts"]}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
slack_sdk>=3.44.0,<4.0
|
|
||||||
boto3>=1.43.82
|
|
||||||
|
|
@ -27,6 +27,10 @@ EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
def handler(event, context):
|
||||||
|
if os.environ.get("STAGE", "prod") != "prod":
|
||||||
|
logger.info("Skipping 3CX queue scheduler because STAGE is not prod")
|
||||||
|
return {"skipped": "non_prod"}
|
||||||
|
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
current_hour = now.hour
|
current_hour = now.hour
|
||||||
day_name = now.strftime("%A")
|
day_name = now.strftime("%A")
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
boto3>=1.43.82
|
boto3>=1.43.99
|
||||||
requests>=2.34.2
|
requests>=2.34.2
|
||||||
|
|
|
||||||
124
src/roster-api/app.py
Normal file
124
src/roster-api/app.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""HTTP API v2 handler — Bearer-authenticated roster PUT/DELETE.
|
||||||
|
|
||||||
|
PUT /roster upsert name, extension, slack_user_id, optional email
|
||||||
|
DELETE /roster/{extension} delete the row (204 even if it was already gone)
|
||||||
|
|
||||||
|
Auth is an app-level Bearer token stored in Secrets Manager. Do not log the
|
||||||
|
Authorization header, the token, or the request body.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import shared.sentry_init # noqa: F401
|
||||||
|
from shared.roster_http import (
|
||||||
|
AuthError,
|
||||||
|
SecretUnavailable,
|
||||||
|
authorize_bearer,
|
||||||
|
remove,
|
||||||
|
upsert,
|
||||||
|
validate_extension,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_response(status: int, body: dict[str, Any] | None = None) -> dict:
|
||||||
|
if status == 204:
|
||||||
|
return {"statusCode": 204, "headers": {}, "body": ""}
|
||||||
|
payload = {} if body is None else body
|
||||||
|
return {
|
||||||
|
"statusCode": status,
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"body": json.dumps(payload, separators=(",", ":")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _header(event: dict, name: str) -> str:
|
||||||
|
headers = event.get("headers") or {}
|
||||||
|
if not isinstance(headers, dict):
|
||||||
|
return ""
|
||||||
|
target = name.lower()
|
||||||
|
for key, value in headers.items():
|
||||||
|
if str(key).lower() == target:
|
||||||
|
return "" if value is None else str(value)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _route(event: dict) -> tuple[str, str]:
|
||||||
|
request_context = event.get("requestContext") or {}
|
||||||
|
http = request_context.get("http") or {}
|
||||||
|
method = str(http.get("method") or event.get("httpMethod") or "").upper()
|
||||||
|
path = str(event.get("rawPath") or http.get("path") or event.get("path") or "")
|
||||||
|
route_key = event.get("routeKey")
|
||||||
|
if isinstance(route_key, str) and " " in route_key:
|
||||||
|
rk_method, rk_path = route_key.split(" ", 1)
|
||||||
|
method = method or rk_method.upper()
|
||||||
|
path = path or rk_path
|
||||||
|
return method, path
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_body(event: dict) -> bytes | None:
|
||||||
|
body = event.get("body")
|
||||||
|
if body is None:
|
||||||
|
return b""
|
||||||
|
try:
|
||||||
|
if event.get("isBase64Encoded"):
|
||||||
|
if isinstance(body, bytes):
|
||||||
|
body = body.decode("ascii")
|
||||||
|
return base64.b64decode(body, validate=True)
|
||||||
|
if isinstance(body, bytes):
|
||||||
|
return body
|
||||||
|
return str(body).encode("utf-8")
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_extension(event: dict) -> str:
|
||||||
|
params = event.get("pathParameters") or {}
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
raise TypeError("extension")
|
||||||
|
raw = params.get("extension")
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
raise TypeError("extension")
|
||||||
|
return validate_extension(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def handler(event, context):
|
||||||
|
try:
|
||||||
|
method, path = _route(event)
|
||||||
|
logger.info("roster api %s %s", method, path)
|
||||||
|
try:
|
||||||
|
authorize_bearer(_header(event, "authorization"))
|
||||||
|
except AuthError:
|
||||||
|
return _json_response(401, {"error": "unauthorized"})
|
||||||
|
except SecretUnavailable:
|
||||||
|
return _json_response(503, {"error": "service unavailable"})
|
||||||
|
|
||||||
|
if method == "PUT" and (path == "/roster" or path.rstrip("/") == "/roster"):
|
||||||
|
raw = _raw_body(event)
|
||||||
|
if raw is None:
|
||||||
|
return _json_response(400, {"error": "invalid request"})
|
||||||
|
try:
|
||||||
|
upsert(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _json_response(400, {"error": "invalid request"})
|
||||||
|
return _json_response(200, {"ok": True})
|
||||||
|
|
||||||
|
if method == "DELETE" and path.startswith("/roster/"):
|
||||||
|
try:
|
||||||
|
extension = _delete_extension(event)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _json_response(400, {"error": "invalid request"})
|
||||||
|
remove(extension)
|
||||||
|
return _json_response(204)
|
||||||
|
|
||||||
|
return _json_response(405, {"error": "method not allowed"})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("roster api unexpected failure")
|
||||||
|
return _json_response(500, {"error": "internal error"})
|
||||||
1
src/roster-api/requirements.txt
Normal file
1
src/roster-api/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
boto3>=1.43.99
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Runs daily via EventBridge. Pulls members from the configured 3CX group,
|
Runs daily via EventBridge. Pulls members from the configured 3CX group,
|
||||||
filters to Extension type only (excludes RingGroups, IVRs, Voicemail, etc.),
|
filters to Extension type only (excludes RingGroups, IVRs, Voicemail, etc.),
|
||||||
and syncs to DynamoDB. Preserves existing slack_user_id links.
|
and syncs to DynamoDB. Preserves existing slack_user_id and email links.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -24,6 +24,10 @@ EXCLUDE_NAMES = {"Voicemail", "IVR", "Fax"}
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
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)
|
now = datetime.now(EASTERN)
|
||||||
|
|
||||||
# DST guard — two EventBridge rules fire, only one is at 6am ET
|
# DST guard — two EventBridge rules fire, only one is at 6am ET
|
||||||
|
|
@ -89,14 +93,20 @@ def handler(event, context):
|
||||||
)
|
)
|
||||||
updated.append(f"Ext {number}: {existing['name']} -> {name}")
|
updated.append(f"Ext {number}: {existing['name']} -> {name}")
|
||||||
else:
|
else:
|
||||||
schedule.table.put_item(
|
# UpdateItem so a stale get_roster snapshot cannot PutItem-overwrite
|
||||||
Item={
|
# a roster-API row that landed after the read and wipe its Slack id.
|
||||||
"PK": "ROSTER",
|
schedule.table.update_item(
|
||||||
"SK": number,
|
Key={"PK": "ROSTER", "SK": number},
|
||||||
"name": name,
|
UpdateExpression=(
|
||||||
"extension": number,
|
"SET #n = :name, extension = :ext, "
|
||||||
"slack_user_id": "",
|
"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}")
|
added.append(f"Ext {number}: {name}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
boto3>=1.43.82
|
boto3>=1.43.99
|
||||||
requests>=2.34.2
|
requests>=2.34.2
|
||||||
|
|
|
||||||
1
src/server/__init__.py
Normal file
1
src/server/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Always-on Flask process for afterhours-shift-manager."""
|
||||||
134
src/server/app.py
Normal file
134
src/server/app.py
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"""Production Flask app for afterhours-shift-manager.
|
||||||
|
|
||||||
|
Local: PYTHONPATH=src:src/shared python3 -m server.app
|
||||||
|
Prod: gunicorn server.wsgi:app
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, Response, jsonify, request
|
||||||
|
|
||||||
|
import shared.sentry_init # noqa: F401
|
||||||
|
from shared.portal_http import encode_body, handle as portal_handle
|
||||||
|
from shared.roster_http import (
|
||||||
|
AuthError,
|
||||||
|
SecretUnavailable,
|
||||||
|
authorize_bearer,
|
||||||
|
remove,
|
||||||
|
upsert,
|
||||||
|
validate_extension,
|
||||||
|
)
|
||||||
|
from shared.secrets import get_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SLACK_BOT_DIR = Path(__file__).resolve().parents[1] / "slack-bot"
|
||||||
|
if str(_SLACK_BOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SLACK_BOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _slack_handler():
|
||||||
|
from slack_bolt.adapter.flask import SlackRequestHandler
|
||||||
|
|
||||||
|
from app import create_app as create_bolt_app
|
||||||
|
|
||||||
|
bot_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"])
|
||||||
|
signing_secret = get_secret(os.environ["SLACK_SIGNING_SECRET"])
|
||||||
|
schedule_channel = os.environ["SHIFT_CHANNEL"]
|
||||||
|
bolt_app = create_bolt_app(
|
||||||
|
bot_token, signing_secret, schedule_channel=schedule_channel
|
||||||
|
)
|
||||||
|
return SlackRequestHandler(bolt_app)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> Flask:
|
||||||
|
app = Flask(__name__)
|
||||||
|
slack_handler = None
|
||||||
|
|
||||||
|
def _get_slack_handler():
|
||||||
|
nonlocal slack_handler
|
||||||
|
if slack_handler is None:
|
||||||
|
slack_handler = _slack_handler()
|
||||||
|
return slack_handler
|
||||||
|
|
||||||
|
@app.route("/api/health")
|
||||||
|
def health():
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"stage": os.environ.get("STAGE", "local"),
|
||||||
|
"sha": os.environ.get("GIT_SHA", "dev"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/slack/events", methods=["POST"])
|
||||||
|
def slack_events():
|
||||||
|
if os.environ.get("STAGE", "prod") != "prod":
|
||||||
|
return jsonify({"error": "slack_disabled"}), 404
|
||||||
|
return _get_slack_handler().handle(request)
|
||||||
|
|
||||||
|
@app.route("/api/shifts", methods=["GET", "POST", "DELETE", "OPTIONS"])
|
||||||
|
@app.route("/api/shifts/<path:rest>", methods=["GET", "POST", "DELETE", "OPTIONS"])
|
||||||
|
def portal(rest: str | None = None):
|
||||||
|
status, headers, payload = portal_handle(
|
||||||
|
method=request.method,
|
||||||
|
path=request.path,
|
||||||
|
origin=request.headers.get("Origin", ""),
|
||||||
|
authorization=request.headers.get("Authorization", ""),
|
||||||
|
query=request.args.to_dict(flat=True),
|
||||||
|
body=request.get_data(),
|
||||||
|
)
|
||||||
|
if status == 204:
|
||||||
|
return Response(b"", status=204, headers=headers)
|
||||||
|
return Response(
|
||||||
|
encode_body(payload),
|
||||||
|
status=status,
|
||||||
|
headers=headers,
|
||||||
|
mimetype="application/json",
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/roster", methods=["PUT"])
|
||||||
|
def roster_put():
|
||||||
|
try:
|
||||||
|
authorize_bearer(request.headers.get("Authorization", ""))
|
||||||
|
except AuthError:
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
except SecretUnavailable:
|
||||||
|
return jsonify({"error": "service unavailable"}), 503
|
||||||
|
try:
|
||||||
|
upsert(request.get_data())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "invalid request"}), 400
|
||||||
|
return jsonify({"ok": True}), 200
|
||||||
|
|
||||||
|
@app.route("/roster/<extension>", methods=["DELETE"])
|
||||||
|
def roster_delete(extension: str):
|
||||||
|
try:
|
||||||
|
authorize_bearer(request.headers.get("Authorization", ""))
|
||||||
|
except AuthError:
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
except SecretUnavailable:
|
||||||
|
return jsonify({"error": "service unavailable"}), 503
|
||||||
|
try:
|
||||||
|
remove(validate_extension(extension))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "invalid request"}), 400
|
||||||
|
return Response(b"", status=204)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
71
src/server/entrypoint.py
Normal file
71
src/server/entrypoint.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Gunicorn + SQS worker in one task. Stay a parent so SIGTERM reaches both."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
worker = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "server.worker"],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
gunicorn = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"gunicorn",
|
||||||
|
"--bind",
|
||||||
|
"0.0.0.0:8080",
|
||||||
|
"--workers",
|
||||||
|
os.environ.get("GUNICORN_WORKERS", "2"),
|
||||||
|
"--threads",
|
||||||
|
"2",
|
||||||
|
"--timeout",
|
||||||
|
"120",
|
||||||
|
"--graceful-timeout",
|
||||||
|
"30",
|
||||||
|
"--access-logfile",
|
||||||
|
"-",
|
||||||
|
"--error-logfile",
|
||||||
|
"-",
|
||||||
|
"server.wsgi:app",
|
||||||
|
],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
def shutdown(signum: int, _frame) -> None:
|
||||||
|
for proc in (gunicorn, worker):
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.send_signal(signum)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, shutdown)
|
||||||
|
signal.signal(signal.SIGINT, shutdown)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
g_code = gunicorn.poll()
|
||||||
|
w_code = worker.poll()
|
||||||
|
if g_code is not None:
|
||||||
|
if worker.poll() is None:
|
||||||
|
worker.terminate()
|
||||||
|
try:
|
||||||
|
worker.wait(timeout=30)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
worker.kill()
|
||||||
|
sys.exit(g_code)
|
||||||
|
if w_code is not None:
|
||||||
|
if gunicorn.poll() is None:
|
||||||
|
gunicorn.terminate()
|
||||||
|
try:
|
||||||
|
gunicorn.wait(timeout=30)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
gunicorn.kill()
|
||||||
|
sys.exit(w_code or 1)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
79
src/server/jobs.py
Normal file
79
src/server/jobs.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""In-process job dispatch. SQS when JOBS_QUEUE_URL is set; otherwise run inline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from shared.holiday_flow import activate, deactivate
|
||||||
|
from shared.schedule import ShiftSchedule
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SRC = Path(__file__).resolve().parents[1]
|
||||||
|
_sqs = None
|
||||||
|
_handlers: dict[str, object] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
global _sqs
|
||||||
|
if _sqs is None:
|
||||||
|
_sqs = boto3.client("sqs")
|
||||||
|
return _sqs
|
||||||
|
|
||||||
|
|
||||||
|
def _load_lambda_app(dirname: str):
|
||||||
|
if dirname in _handlers:
|
||||||
|
return _handlers[dirname]
|
||||||
|
path = _SRC / dirname / "app.py"
|
||||||
|
name = f"afterhours_{dirname.replace('-', '_')}"
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"cannot load {path}")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[name] = mod
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
_handlers[dirname] = mod
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_job(payload: dict) -> None:
|
||||||
|
queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip()
|
||||||
|
body = json.dumps(payload, default=str)
|
||||||
|
if not queue_url:
|
||||||
|
run_job(payload)
|
||||||
|
return
|
||||||
|
_client().send_message(QueueUrl=queue_url, MessageBody=body)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_holiday(payload: dict) -> dict:
|
||||||
|
action = payload.get("action")
|
||||||
|
date = payload.get("date")
|
||||||
|
if not date:
|
||||||
|
return {"error": True, "reason": "missing_date"}
|
||||||
|
schedule = ShiftSchedule()
|
||||||
|
if action == "activate":
|
||||||
|
return activate(schedule, date)
|
||||||
|
if action == "deactivate":
|
||||||
|
return deactivate(schedule, date)
|
||||||
|
return {"error": True, "reason": "unknown_action", "action": action}
|
||||||
|
|
||||||
|
|
||||||
|
def run_job(payload: dict) -> dict:
|
||||||
|
event_type = payload.get("event", "")
|
||||||
|
if event_type == "holiday":
|
||||||
|
return _run_holiday(payload)
|
||||||
|
forced = {**payload, "force": True}
|
||||||
|
if event_type == "weekly_post":
|
||||||
|
return _load_lambda_app("weekly-post").handler(forced, None)
|
||||||
|
if event_type == "roster_sync":
|
||||||
|
return _load_lambda_app("roster-sync").handler(forced, None)
|
||||||
|
if event_type in {"ring_scheduler_daily", "ring_scheduler_weekend"}:
|
||||||
|
return _load_lambda_app("ring-scheduler").handler(forced, None)
|
||||||
|
raise ValueError(f"unknown job event {event_type!r}")
|
||||||
58
src/server/worker.py
Normal file
58
src/server/worker.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""SQS long-poll consumer. One process per task, not per gunicorn worker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from server.jobs import run_job
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
|
||||||
|
|
||||||
|
_running = True
|
||||||
|
|
||||||
|
|
||||||
|
def _stop(_signum, _frame) -> None:
|
||||||
|
global _running
|
||||||
|
_running = False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
signal.signal(signal.SIGTERM, _stop)
|
||||||
|
signal.signal(signal.SIGINT, _stop)
|
||||||
|
|
||||||
|
queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip()
|
||||||
|
if not queue_url:
|
||||||
|
logger.info("JOBS_QUEUE_URL unset; worker idle")
|
||||||
|
while _running:
|
||||||
|
time.sleep(1)
|
||||||
|
return
|
||||||
|
sqs = boto3.client("sqs")
|
||||||
|
logger.info("Polling jobs queue")
|
||||||
|
while _running:
|
||||||
|
resp = sqs.receive_message(
|
||||||
|
QueueUrl=queue_url,
|
||||||
|
MaxNumberOfMessages=1,
|
||||||
|
WaitTimeSeconds=20,
|
||||||
|
VisibilityTimeout=180,
|
||||||
|
)
|
||||||
|
for msg in resp.get("Messages", []):
|
||||||
|
receipt = msg["ReceiptHandle"]
|
||||||
|
try:
|
||||||
|
payload = json.loads(msg["Body"])
|
||||||
|
result = run_job(payload)
|
||||||
|
logger.info("job result %s", result)
|
||||||
|
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("job failed; leaving message for retry")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
5
src/server/wsgi.py
Normal file
5
src/server/wsgi.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Gunicorn entrypoint."""
|
||||||
|
|
||||||
|
from server.app import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
boto3>=1.43.82
|
boto3>=1.43.99
|
||||||
requests>=2.34.2
|
requests>=2.34.2
|
||||||
sentry-sdk==2.68.1
|
sentry-sdk==2.69.2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from shared.schedule import WEEKEND_DAYS
|
from shared.schedule import WEEKEND_DAYS, week_start
|
||||||
|
|
||||||
EASTERN = ZoneInfo("America/New_York")
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
|
@ -32,39 +32,6 @@ def markdown_to_mrkdwn(text: str) -> str:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def build_release_announcement_blocks(
|
|
||||||
version: str, notes: str, date_label: str = ""
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Build the channel post announcing a new minor/major release.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
version: SemVer string without the ``v`` prefix, e.g. ``"1.10.0"``.
|
|
||||||
notes: the changelog entry body in Markdown.
|
|
||||||
date_label: human date, e.g. ``"June 11, 2026"`` (optional).
|
|
||||||
"""
|
|
||||||
body = markdown_to_mrkdwn(notes.strip())
|
|
||||||
if len(body) > _SECTION_LIMIT:
|
|
||||||
body = (
|
|
||||||
body[:_SECTION_LIMIT].rstrip() + "\n\n_…see the full changelog for more._"
|
|
||||||
)
|
|
||||||
|
|
||||||
blocks: list[dict] = [
|
|
||||||
{
|
|
||||||
"type": "header",
|
|
||||||
"text": {"type": "plain_text", "text": f"What's New — v{version}"},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
if date_label:
|
|
||||||
blocks.append(
|
|
||||||
{
|
|
||||||
"type": "context",
|
|
||||||
"elements": [{"type": "mrkdwn", "text": f"Released {date_label}"}],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": body}})
|
|
||||||
return blocks
|
|
||||||
|
|
||||||
|
|
||||||
SHIFT_LABELS = {
|
SHIFT_LABELS = {
|
||||||
"day": "Day (8am–5pm)",
|
"day": "Day (8am–5pm)",
|
||||||
"night": "Night (5pm–8am)",
|
"night": "Night (5pm–8am)",
|
||||||
|
|
@ -149,11 +116,11 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
schedule: ShiftSchedule instance
|
schedule: ShiftSchedule instance
|
||||||
start_date: Monday of the first week to show. Defaults to current week's Monday.
|
start_date: Sunday of the first week to show. Defaults to current week's Sunday.
|
||||||
"""
|
"""
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
if start_date is None:
|
if start_date is None:
|
||||||
start_date = now - timedelta(days=now.weekday()) # Monday of this week
|
start_date = week_start(now)
|
||||||
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
today_str = now.strftime("%Y-%m-%d")
|
today_str = now.strftime("%Y-%m-%d")
|
||||||
|
|
@ -287,8 +254,15 @@ def build_shift_change_message(
|
||||||
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
|
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
|
||||||
|
|
||||||
|
|
||||||
|
def _mrkdwn_text(value: str) -> str:
|
||||||
|
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
def build_swap_request_blocks(
|
def build_swap_request_blocks(
|
||||||
requester_slack: str, date_str: str, shift_type: str = "night"
|
requester_slack: str,
|
||||||
|
date_str: str,
|
||||||
|
shift_type: str = "night",
|
||||||
|
note: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Build the interactive Accept / Decline message DMed to a swap target."""
|
"""Build the interactive Accept / Decline message DMed to a swap target."""
|
||||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
|
|
@ -299,15 +273,18 @@ def build_swap_request_blocks(
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
action_suffix = "_day" if shift_type == "day" else ""
|
action_suffix = "_day" if shift_type == "day" else ""
|
||||||
|
text = (
|
||||||
|
f"<@{requester_slack}> wants you to cover the "
|
||||||
|
f"*{day_label}*{type_label} shift. Accept to take it on."
|
||||||
|
)
|
||||||
|
if note:
|
||||||
|
text += f"\n\nNote: {_mrkdwn_text(note)}"
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"type": "section",
|
"type": "section",
|
||||||
"text": {
|
"text": {
|
||||||
"type": "mrkdwn",
|
"type": "mrkdwn",
|
||||||
"text": (
|
"text": text,
|
||||||
f"<@{requester_slack}> wants you to cover the "
|
|
||||||
f"*{day_label}*{type_label} shift. Accept to take it on."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -344,9 +321,9 @@ def build_holiday_added_blocks(
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Build the channel post announcing a newly scheduled holiday.
|
"""Build the channel post announcing a newly scheduled holiday.
|
||||||
|
|
||||||
Mirrors :func:`build_release_announcement_blocks`: a header, an optional
|
A header, an optional context line, then a section describing the holiday
|
||||||
context line, then a section describing the holiday day-shift (08:00–17:00
|
day-shift (08:00–17:00 ET), its open slots, and the pay multiplier so people
|
||||||
ET), its open slots, and the pay multiplier so people know to pick it up.
|
know to pick it up.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
date_str: the holiday date, ``YYYY-MM-DD``.
|
date_str: the holiday date, ``YYYY-MM-DD``.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
"""Parse CHANGELOG.md — the single source of truth for version and release notes.
|
"""Parse CHANGELOG.md for Slack App Home "What's New".
|
||||||
|
|
||||||
These are pure, text-in helpers shared by three consumers so the parsing rules
|
The root CHANGELOG.md is the source of truth. ``scripts/sync_changelog.py``
|
||||||
live in exactly one place:
|
copies it into ``src/slack-bot/CHANGELOG.md`` so the Fargate image can read it
|
||||||
|
next to ``app.py``. GitHub Releases are cut separately with
|
||||||
* the Slack App Home tab — renders the newest entry ("What's New in vX.Y.Z"),
|
``gh release create``; the changelog is not the prod tag driver.
|
||||||
* the release workflow — pulls the notes for the tag it is about to create,
|
|
||||||
* the CI changelog guard — validates the top version is a clean SemVer bump.
|
|
||||||
|
|
||||||
The parser tolerates the file's leading preamble (prose before the first ``##``
|
The parser tolerates the file's leading preamble (prose before the first ``##``
|
||||||
header), date-only historical headers like ``## May 1, 2026 — …`` (no version),
|
header), date-only historical headers like ``## May 1, 2026 — …`` (no version),
|
||||||
|
|
@ -49,10 +47,6 @@ def _version_key(version: str) -> tuple[int, int, int]:
|
||||||
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
||||||
|
|
||||||
|
|
||||||
def _normalize(version: str) -> str:
|
|
||||||
return version.lstrip("v")
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_rules(body: str) -> str:
|
def _strip_rules(body: str) -> str:
|
||||||
"""Drop ``---`` thematic-break lines from the body's edges.
|
"""Drop ``---`` thematic-break lines from the body's edges.
|
||||||
|
|
||||||
|
|
@ -95,42 +89,3 @@ def latest_entry(text: str) -> Entry | None:
|
||||||
if entry.versions:
|
if entry.versions:
|
||||||
return entry
|
return entry
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def top_version(text: str) -> str | None:
|
|
||||||
"""The version of the newest entry — what a new release tags against."""
|
|
||||||
entry = latest_entry(text)
|
|
||||||
return entry.version if entry else None
|
|
||||||
|
|
||||||
|
|
||||||
def entry_for(text: str, version: str) -> Entry | None:
|
|
||||||
"""The entry whose header includes ``version`` (``v`` prefix optional)."""
|
|
||||||
target = _normalize(version)
|
|
||||||
for entry in parse_changelog(text):
|
|
||||||
if any(_normalize(v) == target for v in entry.versions):
|
|
||||||
return entry
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bump_kind(new: str, prev: str) -> str | None:
|
|
||||||
"""Classify ``new`` relative to ``prev`` as a single clean SemVer step.
|
|
||||||
|
|
||||||
Returns ``"major"``, ``"minor"``, or ``"patch"`` for an exact one-step
|
|
||||||
increment, or None for anything else (skip, multi-step, or downgrade). Note a
|
|
||||||
minor bump resets patch to 0 (``1.9.2 -> 1.10.0``), so this compares whole
|
|
||||||
tuples rather than counting changed components.
|
|
||||||
"""
|
|
||||||
nmaj, nmin, npat = _version_key(new)
|
|
||||||
pmaj, pmin, ppat = _version_key(prev)
|
|
||||||
if (nmaj, nmin, npat) == (pmaj + 1, 0, 0):
|
|
||||||
return "major"
|
|
||||||
if (nmaj, nmin, npat) == (pmaj, pmin + 1, 0):
|
|
||||||
return "minor"
|
|
||||||
if (nmaj, nmin, npat) == (pmaj, pmin, ppat + 1):
|
|
||||||
return "patch"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_bump(new: str, prev: str) -> bool:
|
|
||||||
"""True iff ``new`` is exactly one SemVer step above ``prev``."""
|
|
||||||
return bump_kind(new, prev) is not None
|
|
||||||
|
|
|
||||||
175
src/shared/shared/cognito.py
Normal file
175
src/shared/shared/cognito.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
"""Verification for portal Cognito ID tokens (environment-configured trust)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from jwt import PyJWKClient
|
||||||
|
from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError, PyJWTError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ALLOWED_EMAIL_DOMAINS = {"seahavenind.com", "seahaven.com"}
|
||||||
|
_COGNITO_ISSUER_RE = re.compile(
|
||||||
|
r"^https://cognito-idp\.[a-z0-9-]+\.amazonaws\.com/[A-Za-z0-9_-]+$"
|
||||||
|
)
|
||||||
|
_UNVERIFIED_DECODE = {
|
||||||
|
"verify_signature": False,
|
||||||
|
"verify_exp": False,
|
||||||
|
"verify_aud": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CognitoVerificationUnavailable(RuntimeError):
|
||||||
|
"""Trusted Cognito configuration or JWKS could not be loaded."""
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_cognito_token(token: str) -> bool:
|
||||||
|
try:
|
||||||
|
claims = jwt.decode(
|
||||||
|
token,
|
||||||
|
options=_UNVERIFIED_DECODE,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
)
|
||||||
|
except PyJWTError:
|
||||||
|
return False
|
||||||
|
issuer = claims.get("iss")
|
||||||
|
return (
|
||||||
|
isinstance(issuer, str)
|
||||||
|
and bool(_COGNITO_ISSUER_RE.fullmatch(issuer.rstrip("/")))
|
||||||
|
and claims.get("token_use") == "id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def _jwk_client(issuer: str) -> PyJWKClient:
|
||||||
|
return PyJWKClient(
|
||||||
|
f"{issuer}/.well-known/jwks.json",
|
||||||
|
cache_keys=True,
|
||||||
|
lifespan=300,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_cognito_id_token(token: str) -> dict | None:
|
||||||
|
"""Verify a portal token and return trusted identity claims."""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
trusted = _trusted_clients()
|
||||||
|
except CognitoVerificationUnavailable:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to load Cognito verification configuration: %s", exc)
|
||||||
|
raise CognitoVerificationUnavailable from exc
|
||||||
|
|
||||||
|
if not trusted:
|
||||||
|
logger.error("Cognito verification is not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
unverified = jwt.decode(
|
||||||
|
token,
|
||||||
|
options=_UNVERIFIED_DECODE,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
)
|
||||||
|
except PyJWTError as exc:
|
||||||
|
logger.warning("Cognito token rejected: %s", type(exc).__name__)
|
||||||
|
return None
|
||||||
|
|
||||||
|
issuer = unverified.get("iss")
|
||||||
|
if not isinstance(issuer, str):
|
||||||
|
logger.warning("Cognito token rejected: missing issuer")
|
||||||
|
return None
|
||||||
|
issuer = issuer.rstrip("/")
|
||||||
|
audience = trusted.get(issuer)
|
||||||
|
if not audience:
|
||||||
|
logger.warning("Cognito token rejected: untrusted issuer")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
signing_key = _jwk_client(issuer).get_signing_key_from_jwt(token)
|
||||||
|
claims = jwt.decode(
|
||||||
|
token,
|
||||||
|
signing_key.key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
audience=audience,
|
||||||
|
issuer=issuer,
|
||||||
|
options={
|
||||||
|
"require": [
|
||||||
|
"aud",
|
||||||
|
"email",
|
||||||
|
"exp",
|
||||||
|
"iat",
|
||||||
|
"iss",
|
||||||
|
"token_use",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except PyJWKClientConnectionError as exc:
|
||||||
|
logger.error("Cognito JWKS is unavailable: %s", type(exc).__name__)
|
||||||
|
raise CognitoVerificationUnavailable from exc
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("Cognito JWKS is unavailable: %s", type(exc).__name__)
|
||||||
|
raise CognitoVerificationUnavailable from exc
|
||||||
|
except (PyJWKClientError, PyJWTError, TypeError, ValueError) as exc:
|
||||||
|
logger.warning("Cognito token rejected: %s", type(exc).__name__)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _identity_from_claims(claims)
|
||||||
|
|
||||||
|
|
||||||
|
def _trusted_clients() -> dict[str, str]:
|
||||||
|
trusted: dict[str, str] = {}
|
||||||
|
raw_trust = os.environ.get("PORTAL_COGNITO_TRUST", "").strip()
|
||||||
|
if raw_trust:
|
||||||
|
items = json.loads(raw_trust)
|
||||||
|
if not isinstance(items, list):
|
||||||
|
raise CognitoVerificationUnavailable
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
issuer = str(item.get("issuer", "")).rstrip("/")
|
||||||
|
audience = str(item.get("audience", "")).strip()
|
||||||
|
if _COGNITO_ISSUER_RE.fullmatch(issuer) and audience:
|
||||||
|
trusted[issuer] = audience
|
||||||
|
issuer = os.environ.get("PORTAL_COGNITO_ISSUER", "").rstrip("/")
|
||||||
|
audience = os.environ.get("PORTAL_COGNITO_AUDIENCE", "").strip()
|
||||||
|
if issuer and audience:
|
||||||
|
if not _COGNITO_ISSUER_RE.fullmatch(issuer):
|
||||||
|
if not trusted:
|
||||||
|
logger.error("Cognito issuer is invalid")
|
||||||
|
raise CognitoVerificationUnavailable
|
||||||
|
else:
|
||||||
|
trusted.setdefault(issuer, audience)
|
||||||
|
return trusted
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_from_claims(claims: dict) -> dict | None:
|
||||||
|
if claims.get("token_use") != "id":
|
||||||
|
return None
|
||||||
|
email = claims.get("email")
|
||||||
|
if not isinstance(email, str) or not email.strip() or "@" not in email:
|
||||||
|
return None
|
||||||
|
email = email.strip()
|
||||||
|
if email.rsplit("@", 1)[1].lower() not in ALLOWED_EMAIL_DOMAINS:
|
||||||
|
return None
|
||||||
|
name = _display_name(claims, email)
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
return {"name": name, "email": email}
|
||||||
|
|
||||||
|
|
||||||
|
def _display_name(claims: dict, email: str) -> str | None:
|
||||||
|
for key in ("name", "given_name"):
|
||||||
|
value = claims.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
local = email.split("@", 1)[0].strip()
|
||||||
|
return local or None
|
||||||
14
src/shared/shared/effects.py
Normal file
14
src/shared/shared/effects.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""External side effects. Only production may call Slack or 3CX.
|
||||||
|
|
||||||
|
A missing STAGE is treated as prod so a task that lost its environment
|
||||||
|
variable does not silently drop production notifications. Dev and any
|
||||||
|
other named stage skip Slack and 3CX entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def prod_side_effects_enabled() -> bool:
|
||||||
|
return os.environ.get("STAGE", "prod") == "prod"
|
||||||
130
src/shared/shared/holiday_flow.py
Normal file
130
src/shared/shared/holiday_flow.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""Holiday activate/deactivate. Shared by the Lambda router and the Fargate worker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from shared.effects import prod_side_effects_enabled
|
||||||
|
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
||||||
|
from shared.secrets import get_secret
|
||||||
|
from shared.three_cx_client import ThreeCXClient, oauth_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def make_client() -> ThreeCXClient:
|
||||||
|
secret_prefix = os.environ["TCX_SECRET_PREFIX"]
|
||||||
|
return oauth_client(
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def holiday_extensions(holiday: dict) -> list[str]:
|
||||||
|
assignees = holiday.get("assignees", {}) or {}
|
||||||
|
extensions = list(assignees.keys())
|
||||||
|
return extensions or [FALLBACK_EXTENSION]
|
||||||
|
|
||||||
|
|
||||||
|
def activate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict:
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
logger.info("Skipping holiday activate because STAGE is not prod")
|
||||||
|
return {"action": "activate", "date": date, "skipped": "non_prod"}
|
||||||
|
holiday = schedule.get_holiday(date)
|
||||||
|
if holiday is None:
|
||||||
|
logger.info("No holiday record for %s — nothing to activate", date)
|
||||||
|
return {"action": "activate", "date": date, "skipped": "no_record"}
|
||||||
|
if holiday.get("activated"):
|
||||||
|
logger.info("Holiday %s already activated — no-op", date)
|
||||||
|
return {"action": "activate", "date": date, "skipped": "already_active"}
|
||||||
|
|
||||||
|
queue_number = schedule.get_holiday_queue()
|
||||||
|
ivr_number = schedule.get_ivr_number()
|
||||||
|
extensions = holiday_extensions(holiday)
|
||||||
|
client = (client_factory or make_client)()
|
||||||
|
|
||||||
|
ivr = client.get_ivr(ivr_number)
|
||||||
|
ivr_id = ivr["Id"]
|
||||||
|
current = client.extract_ivr_routes(ivr)
|
||||||
|
already_queue = str(current.get("key0")) == str(queue_number) and str(
|
||||||
|
current.get("timeout")
|
||||||
|
) == str(queue_number)
|
||||||
|
if already_queue:
|
||||||
|
logger.info(
|
||||||
|
"IVR %s already points at queue %s — not re-capturing routes",
|
||||||
|
ivr_number,
|
||||||
|
queue_number,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
schedule.set_captured_ivr_routes(current)
|
||||||
|
|
||||||
|
queue = client.get_queue(queue_number)
|
||||||
|
client.set_queue_agents(queue["Id"], extensions)
|
||||||
|
client.set_ivr_routes(ivr_id, key0_dn=queue_number, timeout_dn=queue_number)
|
||||||
|
schedule.set_holiday_activated(date, True)
|
||||||
|
logger.info(
|
||||||
|
"Activated holiday %s: queue %s agents=%s, IVR %s -> queue",
|
||||||
|
date,
|
||||||
|
queue_number,
|
||||||
|
extensions,
|
||||||
|
ivr_number,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"action": "activate",
|
||||||
|
"date": date,
|
||||||
|
"queue": str(queue_number),
|
||||||
|
"ivr": str(ivr_number),
|
||||||
|
"agents": extensions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def deactivate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict:
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
logger.info("Skipping holiday deactivate because STAGE is not prod")
|
||||||
|
return {"action": "deactivate", "date": date, "skipped": "non_prod"}
|
||||||
|
holiday = schedule.get_holiday(date)
|
||||||
|
if holiday is None:
|
||||||
|
logger.info("No holiday record for %s — nothing to deactivate", date)
|
||||||
|
return {"action": "deactivate", "date": date, "skipped": "no_record"}
|
||||||
|
if not holiday.get("activated"):
|
||||||
|
logger.info("Holiday %s not activated — no-op", date)
|
||||||
|
return {"action": "deactivate", "date": date, "skipped": "not_active"}
|
||||||
|
|
||||||
|
queue_number = schedule.get_holiday_queue()
|
||||||
|
ivr_number = schedule.get_ivr_number()
|
||||||
|
captured = schedule.get_captured_ivr_routes() or {}
|
||||||
|
client = (client_factory or make_client)()
|
||||||
|
|
||||||
|
ivr = client.get_ivr(ivr_number)
|
||||||
|
ivr_id = ivr["Id"]
|
||||||
|
live = client.extract_ivr_routes(ivr)
|
||||||
|
|
||||||
|
def _restore(which: str) -> str | None:
|
||||||
|
live_dn = live.get(which)
|
||||||
|
if str(live_dn) == str(queue_number):
|
||||||
|
return captured.get(which) or live_dn
|
||||||
|
return None
|
||||||
|
|
||||||
|
client.set_ivr_routes(
|
||||||
|
ivr_id,
|
||||||
|
key0_dn=_restore("key0"),
|
||||||
|
timeout_dn=_restore("timeout"),
|
||||||
|
)
|
||||||
|
queue = client.get_queue(queue_number)
|
||||||
|
client.set_queue_agents(queue["Id"], [])
|
||||||
|
schedule.set_captured_ivr_routes(None)
|
||||||
|
schedule.set_holiday_activated(date, False)
|
||||||
|
logger.info(
|
||||||
|
"Deactivated holiday %s: restored IVR %s, cleared queue %s",
|
||||||
|
date,
|
||||||
|
ivr_number,
|
||||||
|
queue_number,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"action": "deactivate",
|
||||||
|
"date": date,
|
||||||
|
"queue": str(queue_number),
|
||||||
|
"ivr": str(ivr_number),
|
||||||
|
}
|
||||||
223
src/shared/shared/portal_http.py
Normal file
223
src/shared/shared/portal_http.py
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
"""Cognito portal HTTP dispatch shared by Lambda and Flask."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from shared.cognito import CognitoVerificationUnavailable, verify_cognito_id_token
|
||||||
|
from shared.portal_ops import ActionError, snapshot
|
||||||
|
from shared.schedule import ShiftSchedule
|
||||||
|
from shared import portal_ops as ops
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CORS_ORIGINS = {
|
||||||
|
"https://internal.seahaven.com",
|
||||||
|
"https://internal.dev.seahaven.com",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DecimalEncoder(json.JSONEncoder):
|
||||||
|
def default(self, o):
|
||||||
|
if isinstance(o, Decimal):
|
||||||
|
return float(o)
|
||||||
|
return super().default(o)
|
||||||
|
|
||||||
|
|
||||||
|
def cors_headers(origin: str) -> dict[str, str]:
|
||||||
|
out = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Vary": "Origin",
|
||||||
|
}
|
||||||
|
if origin in CORS_ORIGINS:
|
||||||
|
out["Access-Control-Allow-Origin"] = origin
|
||||||
|
out["Access-Control-Allow-Headers"] = "Authorization,Content-Type"
|
||||||
|
out["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def identity_from_authorization(presented: str) -> dict:
|
||||||
|
parts = presented.split(None, 1)
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip():
|
||||||
|
raise ActionError(401, "UNAUTHORIZED", "Sign in to continue.")
|
||||||
|
try:
|
||||||
|
identity = verify_cognito_id_token(parts[1].strip())
|
||||||
|
except CognitoVerificationUnavailable as exc:
|
||||||
|
raise ActionError(
|
||||||
|
503, "AUTH_UNAVAILABLE", "Sign-in verification is unavailable."
|
||||||
|
) from exc
|
||||||
|
if not identity:
|
||||||
|
raise ActionError(401, "UNAUTHORIZED", "Sign in to continue.")
|
||||||
|
return identity
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_object(raw: bytes | str | None) -> dict:
|
||||||
|
if raw in (None, "", b""):
|
||||||
|
return {}
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
raw = raw.decode("utf-8")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ActionError(400, "INVALID_JSON", "Invalid JSON body.") from exc
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ActionError(400, "INVALID_JSON", "JSON body must be an object.")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def encode_body(body: dict[str, Any] | None) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
{} if body is None else body, cls=DecimalEncoder, separators=(",", ":")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
schedule: ShiftSchedule,
|
||||||
|
employee: dict,
|
||||||
|
body: dict,
|
||||||
|
) -> tuple[int, dict]:
|
||||||
|
parts = [p for p in path.split("/") if p]
|
||||||
|
if method == "POST" and path == "/api/shifts/pick":
|
||||||
|
return 200, ops.pick(
|
||||||
|
schedule, employee, body.get("date", ""), body.get("shiftType")
|
||||||
|
)
|
||||||
|
if method == "POST" and path == "/api/shifts/drop":
|
||||||
|
return 200, ops.drop(
|
||||||
|
schedule, employee, body.get("date", ""), body.get("shiftType")
|
||||||
|
)
|
||||||
|
if method == "POST" and path == "/api/shifts/swap":
|
||||||
|
return 200, ops.swap(
|
||||||
|
schedule,
|
||||||
|
employee,
|
||||||
|
body.get("date", ""),
|
||||||
|
body.get("targetExtension", ""),
|
||||||
|
body.get("shiftType"),
|
||||||
|
note=body.get("note"),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
method == "POST"
|
||||||
|
and len(parts) == 6
|
||||||
|
and parts[:3] == ["api", "shifts", "swaps"]
|
||||||
|
and parts[5]
|
||||||
|
in (
|
||||||
|
"accept",
|
||||||
|
"decline",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return 200, ops.respond_swap(
|
||||||
|
schedule,
|
||||||
|
employee,
|
||||||
|
unquote(parts[3]),
|
||||||
|
unquote(parts[4]),
|
||||||
|
accept=parts[5] == "accept",
|
||||||
|
)
|
||||||
|
if method == "POST" and path == "/api/shifts/admin/override":
|
||||||
|
return 200, ops.admin_override(
|
||||||
|
schedule,
|
||||||
|
employee,
|
||||||
|
body.get("date", ""),
|
||||||
|
body.get("extension", ""),
|
||||||
|
body.get("shiftType"),
|
||||||
|
)
|
||||||
|
if method == "POST" and path == "/api/shifts/admin/open":
|
||||||
|
return 200, ops.admin_open(
|
||||||
|
schedule, employee, body.get("date", ""), body.get("shiftType")
|
||||||
|
)
|
||||||
|
if method == "POST" and path == "/api/shifts/admin/clear":
|
||||||
|
return 200, ops.admin_clear(
|
||||||
|
schedule, employee, body.get("date", ""), body.get("shiftType")
|
||||||
|
)
|
||||||
|
if method == "POST" and path == "/api/shifts/admin/holidays":
|
||||||
|
return 200, ops.admin_holiday_add(
|
||||||
|
schedule,
|
||||||
|
employee,
|
||||||
|
body.get("date", ""),
|
||||||
|
body.get("slots"),
|
||||||
|
body.get("label", ""),
|
||||||
|
body.get("multiplier"),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
method == "DELETE"
|
||||||
|
and len(parts) == 5
|
||||||
|
and parts[:4] == ["api", "shifts", "admin", "holidays"]
|
||||||
|
):
|
||||||
|
return 200, ops.admin_holiday_remove(schedule, employee, unquote(parts[4]))
|
||||||
|
if (
|
||||||
|
method == "POST"
|
||||||
|
and len(parts) == 8
|
||||||
|
and parts[:4] == ["api", "shifts", "admin", "pickups"]
|
||||||
|
and parts[7] in ("approve", "deny")
|
||||||
|
):
|
||||||
|
return 200, ops.admin_pickup(
|
||||||
|
schedule,
|
||||||
|
employee,
|
||||||
|
unquote(parts[4]),
|
||||||
|
unquote(parts[5]),
|
||||||
|
unquote(parts[6]),
|
||||||
|
approve=parts[7] == "approve",
|
||||||
|
)
|
||||||
|
return 405, {"error": {"code": "METHOD", "message": "Method not allowed"}}
|
||||||
|
|
||||||
|
|
||||||
|
def handle(
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
origin: str,
|
||||||
|
authorization: str,
|
||||||
|
query: dict | None,
|
||||||
|
body: bytes | str | None,
|
||||||
|
) -> tuple[int, dict[str, str], dict | None]:
|
||||||
|
headers = cors_headers(origin)
|
||||||
|
path = path.rstrip("/") or "/"
|
||||||
|
method = method.upper()
|
||||||
|
try:
|
||||||
|
if method == "OPTIONS":
|
||||||
|
return 204, headers, {}
|
||||||
|
identity = identity_from_authorization(authorization)
|
||||||
|
schedule = ShiftSchedule()
|
||||||
|
if method == "GET" and path == "/api/shifts":
|
||||||
|
employee = schedule.get_employee_by_email(identity["email"])
|
||||||
|
if not employee:
|
||||||
|
return (
|
||||||
|
200,
|
||||||
|
headers,
|
||||||
|
{
|
||||||
|
"linked": False,
|
||||||
|
"email": identity["email"],
|
||||||
|
"isAdmin": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
week = (query or {}).get("week") or "this"
|
||||||
|
return 200, headers, snapshot(schedule, employee, week)
|
||||||
|
employee = schedule.get_employee_by_email(identity["email"])
|
||||||
|
if not employee:
|
||||||
|
raise ActionError(
|
||||||
|
404,
|
||||||
|
"UNLINKED",
|
||||||
|
"Your Google account is not on the after-hours roster yet.",
|
||||||
|
)
|
||||||
|
parsed = parse_json_object(body) if method in {"POST", "PUT", "PATCH"} else {}
|
||||||
|
status, payload = dispatch(method, path, schedule, employee, parsed)
|
||||||
|
return status, headers, payload
|
||||||
|
except ActionError as exc:
|
||||||
|
return (
|
||||||
|
exc.status,
|
||||||
|
headers,
|
||||||
|
{"error": {"code": exc.code, "message": exc.message}},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("portal http unexpected failure")
|
||||||
|
return (
|
||||||
|
500,
|
||||||
|
headers,
|
||||||
|
{"error": {"code": "INTERNAL", "message": "Internal error"}},
|
||||||
|
)
|
||||||
764
src/shared/shared/portal_ops.py
Normal file
764
src/shared/shared/portal_ops.py
Normal file
|
|
@ -0,0 +1,764 @@
|
||||||
|
"""Employee and admin shift mutations for the portal API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule, week_start
|
||||||
|
from shared.shift_clock import (
|
||||||
|
EASTERN,
|
||||||
|
holiday_window_active,
|
||||||
|
shift_end,
|
||||||
|
shift_ended,
|
||||||
|
shift_start,
|
||||||
|
shift_started,
|
||||||
|
within_drop_lock,
|
||||||
|
)
|
||||||
|
from shared import side_effects as effects
|
||||||
|
|
||||||
|
DATE_FMT = "%Y-%m-%d"
|
||||||
|
|
||||||
|
|
||||||
|
class ActionError(Exception):
|
||||||
|
def __init__(self, status: int, code: str, message: str):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(date_str: str) -> datetime:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str, DATE_FMT).replace(tzinfo=EASTERN)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ActionError(400, "INVALID_DATE", "Date must be YYYY-MM-DD.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_type(value: str | None) -> str:
|
||||||
|
shift_type = (value or "night").strip().lower()
|
||||||
|
if shift_type not in ("day", "night"):
|
||||||
|
raise ActionError(400, "INVALID_SHIFT", "Shift type must be day or night.")
|
||||||
|
return shift_type
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(schedule: ShiftSchedule, employee: dict) -> bool:
|
||||||
|
slack_id = employee.get("slack_user_id") or ""
|
||||||
|
return bool(slack_id) and slack_id in schedule.get_admin_users()
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(schedule: ShiftSchedule, employee: dict) -> None:
|
||||||
|
if not is_admin(schedule, employee):
|
||||||
|
raise ActionError(403, "FORBIDDEN", "Admin access required.")
|
||||||
|
|
||||||
|
|
||||||
|
def public_employee(item: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"extension": item.get("extension") or item.get("SK", ""),
|
||||||
|
"name": item.get("name", ""),
|
||||||
|
"email": item.get("email") or "",
|
||||||
|
"slackUserId": item.get("slack_user_id") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(value):
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_payload(
|
||||||
|
schedule: ShiftSchedule, date_str: str, day_name: str, shift_type: str, my_ext: str
|
||||||
|
) -> dict:
|
||||||
|
ctx = schedule.get_shift_context(date_str, day_name, shift_type)
|
||||||
|
assignees = [
|
||||||
|
{"extension": a["extension"], "name": a["name"]} for a in ctx["assignees"]
|
||||||
|
]
|
||||||
|
mine = any(a["extension"] == my_ext for a in assignees)
|
||||||
|
open_slots = int(ctx["open_slots"])
|
||||||
|
return {
|
||||||
|
"kind": ctx["kind"],
|
||||||
|
"shiftType": shift_type,
|
||||||
|
"label": ctx.get("label") or "",
|
||||||
|
"slots": int(ctx["slots"]),
|
||||||
|
"openSlots": open_slots,
|
||||||
|
"multiplier": _json_safe(ctx.get("multiplier") or 1),
|
||||||
|
"assignees": assignees,
|
||||||
|
"mine": mine,
|
||||||
|
"canPick": (not mine)
|
||||||
|
and open_slots > 0
|
||||||
|
and not shift_ended(date_str, shift_type),
|
||||||
|
"canDrop": mine
|
||||||
|
and not within_drop_lock(
|
||||||
|
date_str, "day" if ctx["kind"] == "holiday" else shift_type
|
||||||
|
),
|
||||||
|
"latePickup": (not mine)
|
||||||
|
and open_slots > 0
|
||||||
|
and shift_started(date_str, shift_type)
|
||||||
|
and not shift_ended(date_str, shift_type),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def week_range(which: str) -> tuple[datetime, str]:
|
||||||
|
"""Sunday–Saturday window. ``next`` is the following Sunday."""
|
||||||
|
now = datetime.now(EASTERN)
|
||||||
|
start = week_start(now)
|
||||||
|
if which == "next":
|
||||||
|
start = start + timedelta(days=7)
|
||||||
|
label = "this" if which != "next" else "next"
|
||||||
|
return start, label
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(schedule: ShiftSchedule, employee: dict, week: str = "this") -> dict:
|
||||||
|
start, label = week_range(week)
|
||||||
|
my_ext = employee["extension"]
|
||||||
|
days = []
|
||||||
|
for offset in range(7):
|
||||||
|
day = start + timedelta(days=offset)
|
||||||
|
date_str = day.strftime(DATE_FMT)
|
||||||
|
day_name = day.strftime("%A")
|
||||||
|
slots = []
|
||||||
|
if day_name in WEEKEND_DAYS or schedule.get_holiday(date_str) is not None:
|
||||||
|
slots.append(_slot_payload(schedule, date_str, day_name, "day", my_ext))
|
||||||
|
slots.append(_slot_payload(schedule, date_str, day_name, "night", my_ext))
|
||||||
|
days.append(
|
||||||
|
{
|
||||||
|
"date": date_str,
|
||||||
|
"dayName": day_name,
|
||||||
|
"slots": slots,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
pending_swaps = [
|
||||||
|
_swap_payload(item, my_ext)
|
||||||
|
for item in schedule.list_pending_swaps()
|
||||||
|
if item.get("target_ext") == my_ext or item.get("requester_ext") == my_ext
|
||||||
|
]
|
||||||
|
payload = {
|
||||||
|
"linked": True,
|
||||||
|
"week": label,
|
||||||
|
"weekStart": start.strftime(DATE_FMT),
|
||||||
|
"me": public_employee(employee),
|
||||||
|
"isAdmin": is_admin(schedule, employee),
|
||||||
|
"days": days,
|
||||||
|
"pendingSwaps": pending_swaps,
|
||||||
|
"roster": [public_employee(item) for item in schedule.get_roster()],
|
||||||
|
}
|
||||||
|
if payload["isAdmin"]:
|
||||||
|
payload["pendingPickups"] = [
|
||||||
|
_pickup_payload(item) for item in schedule.list_pending_pickup_requests()
|
||||||
|
]
|
||||||
|
payload["upcomingHolidays"] = [
|
||||||
|
{
|
||||||
|
"date": item["SK"],
|
||||||
|
"label": item.get("label", ""),
|
||||||
|
"slots": int(item.get("slots", 0)),
|
||||||
|
"multiplier": _json_safe(item.get("multiplier") or 1.5),
|
||||||
|
}
|
||||||
|
for item in schedule.list_holidays(datetime.now(EASTERN).strftime(DATE_FMT))
|
||||||
|
]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
NOTE_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_swap_note(note: str | None) -> str | None:
|
||||||
|
if note is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(note, str):
|
||||||
|
raise ActionError(400, "INVALID_NOTE", "Note must be text.")
|
||||||
|
cleaned = note.strip()
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
if len(cleaned) > NOTE_MAX:
|
||||||
|
raise ActionError(400, "NOTE_TOO_LONG", "Note must be 500 characters or fewer.")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _swap_payload(item: dict, my_ext: str) -> dict:
|
||||||
|
date_str, shift_type = _sk_to_date_shift(item.get("SK", ""))
|
||||||
|
payload = {
|
||||||
|
"date": date_str,
|
||||||
|
"shiftType": item.get("shift_type") or shift_type,
|
||||||
|
"requesterExt": item.get("requester_ext", ""),
|
||||||
|
"requesterName": item.get("requester_name", ""),
|
||||||
|
"targetExt": item.get("target_ext", ""),
|
||||||
|
"targetName": item.get("target_name", ""),
|
||||||
|
"incoming": item.get("target_ext") == my_ext,
|
||||||
|
}
|
||||||
|
note = item.get("note")
|
||||||
|
if isinstance(note, str) and note.strip():
|
||||||
|
payload["note"] = note.strip()
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _pickup_payload(item: dict) -> dict:
|
||||||
|
sk = item.get("SK", "")
|
||||||
|
date_str, shift_type, ext = _pickup_sk_parts(sk)
|
||||||
|
return {
|
||||||
|
"date": date_str,
|
||||||
|
"shiftType": item.get("shift_type") or shift_type,
|
||||||
|
"requesterExt": item.get("requester_ext") or ext,
|
||||||
|
"requesterName": item.get("requester_name", ""),
|
||||||
|
"isHoliday": bool(item.get("is_holiday")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sk_to_date_shift(sk: str) -> tuple[str, str]:
|
||||||
|
if sk.endswith("-DAY"):
|
||||||
|
return sk[:-4], "day"
|
||||||
|
return sk, "night"
|
||||||
|
|
||||||
|
|
||||||
|
def _pickup_sk_parts(sk: str) -> tuple[str, str, str]:
|
||||||
|
prefix, _, ext = sk.partition("#")
|
||||||
|
date_str, shift_type = _sk_to_date_shift(prefix)
|
||||||
|
return date_str, shift_type, ext
|
||||||
|
|
||||||
|
|
||||||
|
def pick(
|
||||||
|
schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None
|
||||||
|
) -> dict:
|
||||||
|
date = _parse_date(date_str)
|
||||||
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
||||||
|
raise ActionError(400, "PAST_SHIFT", "You can't pick up a shift in the past.")
|
||||||
|
day_name = date.strftime("%A")
|
||||||
|
shift_type = _resolve_pick_type(schedule, date_str, day_name, shift_type)
|
||||||
|
ctx = schedule.get_shift_context(date_str, day_name, shift_type)
|
||||||
|
token = effects.slack_token()
|
||||||
|
if ctx["kind"] == "holiday":
|
||||||
|
return _pick_holiday(schedule, employee, date, date_str, ctx, token)
|
||||||
|
return _pick_regular(
|
||||||
|
schedule, employee, date, date_str, day_name, shift_type, ctx, token
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_pick_type(schedule, date_str, day_name, explicit) -> str:
|
||||||
|
if explicit:
|
||||||
|
return _shift_type(explicit)
|
||||||
|
if schedule.get_shift_context(date_str, day_name, "day")["kind"] == "holiday":
|
||||||
|
return "day"
|
||||||
|
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":
|
||||||
|
return st
|
||||||
|
return "night"
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_regular(
|
||||||
|
schedule, employee, date, date_str, day_name, shift_type, ctx, token
|
||||||
|
) -> dict:
|
||||||
|
assignees = ctx["assignees"]
|
||||||
|
if assignees and assignees[0]["extension"] != employee["extension"]:
|
||||||
|
raise ActionError(
|
||||||
|
409,
|
||||||
|
"COVERED",
|
||||||
|
f"That shift is already covered by {assignees[0]['name']}.",
|
||||||
|
)
|
||||||
|
if shift_ended(date_str, shift_type):
|
||||||
|
raise ActionError(400, "ENDED", "That shift has already ended.")
|
||||||
|
if shift_started(date_str, shift_type):
|
||||||
|
return _request_late_pickup(
|
||||||
|
schedule, employee, date_str, shift_type, False, token
|
||||||
|
)
|
||||||
|
already_mine = (
|
||||||
|
bool(assignees) and assignees[0]["extension"] == employee["extension"]
|
||||||
|
)
|
||||||
|
if not already_mine:
|
||||||
|
claimed = schedule.claim_open_shift(
|
||||||
|
date_str, employee["extension"], employee["name"], shift_type
|
||||||
|
)
|
||||||
|
if not claimed:
|
||||||
|
raise ActionError(
|
||||||
|
409, "TAKEN", "That shift was just picked up by someone else."
|
||||||
|
)
|
||||||
|
effects.maybe_repoint_today(date_str, shift_type, employee["extension"])
|
||||||
|
effects.post_shift_change(
|
||||||
|
token,
|
||||||
|
employee.get("slack_user_id", ""),
|
||||||
|
date_str,
|
||||||
|
"picked_up",
|
||||||
|
employee["extension"],
|
||||||
|
employee["name"],
|
||||||
|
shift_type,
|
||||||
|
)
|
||||||
|
effects.refresh_schedule_post(schedule, token)
|
||||||
|
return {"ok": True, "latePickup": False, "message": "Shift picked up."}
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_holiday(schedule, employee, date, date_str, ctx, token) -> dict:
|
||||||
|
ext = employee["extension"]
|
||||||
|
if any(a["extension"] == ext for a in ctx["assignees"]):
|
||||||
|
raise ActionError(409, "ALREADY_ON", "You're already on that holiday shift.")
|
||||||
|
if shift_ended(date_str, "day"):
|
||||||
|
raise ActionError(400, "ENDED", "That holiday shift has already ended.")
|
||||||
|
if shift_started(date_str, "day"):
|
||||||
|
return _request_late_pickup(schedule, employee, date_str, "day", True, token)
|
||||||
|
claimed = schedule.claim_holiday_slot(date_str, ext, employee["name"])
|
||||||
|
if not claimed:
|
||||||
|
raise ActionError(409, "FULL", "Couldn't claim a holiday slot.")
|
||||||
|
if holiday_window_active(date_str):
|
||||||
|
effects.set_holiday_queue_agents(schedule, date_str)
|
||||||
|
effects.post_shift_change(
|
||||||
|
token,
|
||||||
|
employee.get("slack_user_id", ""),
|
||||||
|
date_str,
|
||||||
|
"picked_up",
|
||||||
|
ext,
|
||||||
|
employee["name"],
|
||||||
|
"day",
|
||||||
|
)
|
||||||
|
effects.refresh_schedule_post(schedule, token)
|
||||||
|
return {"ok": True, "latePickup": False, "message": "Holiday slot claimed."}
|
||||||
|
|
||||||
|
|
||||||
|
def _request_late_pickup(
|
||||||
|
schedule, employee, date_str, shift_type, is_holiday, token
|
||||||
|
) -> dict:
|
||||||
|
schedule.create_pickup_request(
|
||||||
|
date_str,
|
||||||
|
shift_type,
|
||||||
|
employee,
|
||||||
|
expires_at=int(shift_end(date_str, shift_type).timestamp()),
|
||||||
|
is_holiday=is_holiday,
|
||||||
|
)
|
||||||
|
delivered = effects.dm_late_pickup_admins(
|
||||||
|
schedule, token, employee, date_str, shift_type, is_holiday
|
||||||
|
)
|
||||||
|
if delivered == 0:
|
||||||
|
schedule.clear_pickup_request(date_str, shift_type, employee["extension"])
|
||||||
|
raise ActionError(
|
||||||
|
503,
|
||||||
|
"NO_ADMIN",
|
||||||
|
"That shift has started and needs admin approval, but no admin could be reached.",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"latePickup": True,
|
||||||
|
"message": "Pickup needs admin approval. Admins were notified in Slack and will see it here.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def drop(
|
||||||
|
schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None
|
||||||
|
) -> dict:
|
||||||
|
date = _parse_date(date_str)
|
||||||
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
||||||
|
raise ActionError(400, "PAST_SHIFT", "You can't drop a shift in the past.")
|
||||||
|
day_name = date.strftime("%A")
|
||||||
|
held = _droppable(schedule, date_str, day_name, employee["extension"])
|
||||||
|
if shift_type:
|
||||||
|
target = shift_type.strip().lower()
|
||||||
|
if target == "holiday":
|
||||||
|
target = "holiday"
|
||||||
|
elif target in ("day", "night"):
|
||||||
|
target = target
|
||||||
|
else:
|
||||||
|
raise ActionError(
|
||||||
|
400, "INVALID_SHIFT", "Shift type must be day, night, or holiday."
|
||||||
|
)
|
||||||
|
if target == "day" and "holiday" in held:
|
||||||
|
target = "holiday"
|
||||||
|
if target not in held:
|
||||||
|
raise ActionError(409, "NOT_YOURS", "You don't hold that shift.")
|
||||||
|
elif not held:
|
||||||
|
raise ActionError(409, "NOT_YOURS", "That's not your shift.")
|
||||||
|
elif len(held) > 1:
|
||||||
|
raise ActionError(
|
||||||
|
409,
|
||||||
|
"AMBIGUOUS",
|
||||||
|
"You hold more than one shift that day. Specify day, night, or holiday.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
target = held[0]
|
||||||
|
token = effects.slack_token()
|
||||||
|
if target == "holiday":
|
||||||
|
if within_drop_lock(date_str, "day"):
|
||||||
|
raise ActionError(
|
||||||
|
409,
|
||||||
|
"DROP_LOCK",
|
||||||
|
"This shift starts in under 24 hours. Swap it or ask an admin.",
|
||||||
|
)
|
||||||
|
released = schedule.release_holiday_slot(date_str, employee["extension"])
|
||||||
|
if not released:
|
||||||
|
raise ActionError(409, "NOT_YOURS", "You don't hold that holiday slot.")
|
||||||
|
effects.post_shift_change(
|
||||||
|
token,
|
||||||
|
employee.get("slack_user_id", ""),
|
||||||
|
date_str,
|
||||||
|
"dropped",
|
||||||
|
employee["extension"],
|
||||||
|
employee["name"],
|
||||||
|
"day",
|
||||||
|
)
|
||||||
|
effects.refresh_schedule_post(schedule, token)
|
||||||
|
return {"ok": True, "message": "Holiday slot dropped."}
|
||||||
|
if within_drop_lock(date_str, target):
|
||||||
|
raise ActionError(
|
||||||
|
409,
|
||||||
|
"DROP_LOCK",
|
||||||
|
"This shift starts in under 24 hours. Swap it or ask an admin.",
|
||||||
|
)
|
||||||
|
schedule.mark_open(date_str, target)
|
||||||
|
effects.post_shift_change(
|
||||||
|
token,
|
||||||
|
employee.get("slack_user_id", ""),
|
||||||
|
date_str,
|
||||||
|
"dropped",
|
||||||
|
employee["extension"],
|
||||||
|
employee["name"],
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
effects.refresh_schedule_post(schedule, token)
|
||||||
|
return {"ok": True, "message": "Shift dropped."}
|
||||||
|
|
||||||
|
|
||||||
|
def _droppable(schedule, date_str, day_name, employee_ext) -> list[str]:
|
||||||
|
held = []
|
||||||
|
holiday_ctx = schedule.get_shift_context(date_str, day_name, "day")
|
||||||
|
if holiday_ctx["kind"] == "holiday":
|
||||||
|
if any(a["extension"] == employee_ext for a in holiday_ctx["assignees"]):
|
||||||
|
held.append("holiday")
|
||||||
|
elif day_name in WEEKEND_DAYS:
|
||||||
|
ext, _name, _source = schedule.resolve_shift(date_str, day_name, "day")
|
||||||
|
if ext == employee_ext:
|
||||||
|
held.append("day")
|
||||||
|
ext, _name, _source = schedule.resolve_shift(date_str, day_name, "night")
|
||||||
|
if ext == employee_ext:
|
||||||
|
held.append("night")
|
||||||
|
return held
|
||||||
|
|
||||||
|
|
||||||
|
def swap(
|
||||||
|
schedule: ShiftSchedule,
|
||||||
|
employee: dict,
|
||||||
|
date_str: str,
|
||||||
|
target_extension: str,
|
||||||
|
shift_type: str | None,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
date = _parse_date(date_str)
|
||||||
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
||||||
|
raise ActionError(400, "PAST_SHIFT", "You can't swap a shift in the past.")
|
||||||
|
day_name = date.strftime("%A")
|
||||||
|
holiday_ctx = schedule.get_shift_context(date_str, day_name, "day")
|
||||||
|
holiday_swap = holiday_ctx["kind"] == "holiday" and any(
|
||||||
|
a["extension"] == employee["extension"] for a in holiday_ctx["assignees"]
|
||||||
|
)
|
||||||
|
if holiday_swap:
|
||||||
|
resolved = "day"
|
||||||
|
else:
|
||||||
|
found = None
|
||||||
|
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["extension"]:
|
||||||
|
found = st
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
ext, _name, _source = schedule.resolve_shift(date_str, day_name, "night")
|
||||||
|
if ext == employee["extension"]:
|
||||||
|
found = "night"
|
||||||
|
if not found:
|
||||||
|
raise ActionError(409, "NOT_YOURS", "You can only swap your own shifts.")
|
||||||
|
resolved = found
|
||||||
|
if shift_type and _shift_type(shift_type) != resolved and not holiday_swap:
|
||||||
|
if _shift_type(shift_type) != resolved:
|
||||||
|
raise ActionError(409, "NOT_YOURS", "You don't hold that shift type.")
|
||||||
|
target = schedule.get_employee_by_extension((target_extension or "").strip())
|
||||||
|
if not target:
|
||||||
|
raise ActionError(400, "UNKNOWN_TARGET", "That extension is not on the roster.")
|
||||||
|
if target["extension"] == employee["extension"]:
|
||||||
|
raise ActionError(400, "SELF_SWAP", "That shift is already yours.")
|
||||||
|
cleaned_note = _clean_swap_note(note)
|
||||||
|
expires_at = int(shift_start(date_str, resolved).timestamp())
|
||||||
|
schedule.create_pending_swap(
|
||||||
|
date_str, resolved, employee, target, expires_at, note=cleaned_note
|
||||||
|
)
|
||||||
|
token = effects.slack_token()
|
||||||
|
if target.get("slack_user_id"):
|
||||||
|
effects.dm_swap_request(
|
||||||
|
token,
|
||||||
|
employee.get("slack_user_id", ""),
|
||||||
|
target["slack_user_id"],
|
||||||
|
date_str,
|
||||||
|
resolved,
|
||||||
|
employee["name"],
|
||||||
|
note=cleaned_note,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": f"Swap request sent to {target['name']}. They can accept here or in Slack.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def respond_swap(
|
||||||
|
schedule: ShiftSchedule,
|
||||||
|
employee: dict,
|
||||||
|
date_str: str,
|
||||||
|
shift_type: str,
|
||||||
|
accept: bool,
|
||||||
|
) -> dict:
|
||||||
|
date = _parse_date(date_str)
|
||||||
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
shift_type = _shift_type(shift_type)
|
||||||
|
swap_row = schedule.get_swap(date_str, shift_type)
|
||||||
|
if (
|
||||||
|
not swap_row
|
||||||
|
or swap_row.get("status") != "pending"
|
||||||
|
or swap_row.get("target_ext") != employee["extension"]
|
||||||
|
):
|
||||||
|
raise ActionError(404, "NOT_FOUND", "This swap request is no longer valid.")
|
||||||
|
token = effects.slack_token()
|
||||||
|
if not accept:
|
||||||
|
schedule.clear_swap(date_str, shift_type)
|
||||||
|
if swap_row.get("requester_slack"):
|
||||||
|
effects.dm_text(
|
||||||
|
token,
|
||||||
|
swap_row["requester_slack"],
|
||||||
|
f"{employee['name']} declined your swap for {date_str}.",
|
||||||
|
)
|
||||||
|
return {"ok": True, "message": "Swap declined."}
|
||||||
|
if shift_started(date_str, shift_type):
|
||||||
|
schedule.clear_swap(date_str, shift_type)
|
||||||
|
raise ActionError(
|
||||||
|
409, "EXPIRED", "This swap expired because the shift has started."
|
||||||
|
)
|
||||||
|
is_holiday = shift_type == "day" and schedule.get_holiday(date_str) is not None
|
||||||
|
if is_holiday:
|
||||||
|
moved = schedule.swap_holiday_assignee(
|
||||||
|
date_str,
|
||||||
|
swap_row["requester_ext"],
|
||||||
|
swap_row["target_ext"],
|
||||||
|
employee["name"],
|
||||||
|
)
|
||||||
|
if not moved:
|
||||||
|
schedule.clear_swap(date_str, shift_type)
|
||||||
|
raise ActionError(409, "CONFLICT", "Couldn't move that holiday slot.")
|
||||||
|
if holiday_window_active(date_str):
|
||||||
|
effects.set_holiday_queue_agents(schedule, date_str)
|
||||||
|
else:
|
||||||
|
current_ext, _name, _source = schedule.resolve_shift(
|
||||||
|
date_str, date.strftime("%A"), shift_type
|
||||||
|
)
|
||||||
|
if current_ext != swap_row["requester_ext"]:
|
||||||
|
schedule.clear_swap(date_str, shift_type)
|
||||||
|
raise ActionError(
|
||||||
|
409,
|
||||||
|
"CONFLICT",
|
||||||
|
"The shift is no longer assigned to the person who requested the swap.",
|
||||||
|
)
|
||||||
|
moved = schedule.reassign_if_held_by(
|
||||||
|
date_str,
|
||||||
|
swap_row["requester_ext"],
|
||||||
|
employee["extension"],
|
||||||
|
employee["name"],
|
||||||
|
shift_type,
|
||||||
|
)
|
||||||
|
if not moved:
|
||||||
|
schedule.clear_swap(date_str, shift_type)
|
||||||
|
raise ActionError(409, "CONFLICT", "Couldn't move that shift.")
|
||||||
|
effects.maybe_repoint_today(date_str, shift_type, employee["extension"])
|
||||||
|
schedule.mark_swap_verified(date_str, shift_type)
|
||||||
|
if swap_row.get("requester_slack"):
|
||||||
|
effects.dm_text(
|
||||||
|
token,
|
||||||
|
swap_row["requester_slack"],
|
||||||
|
f"{employee['name']} accepted your swap for {date_str}.",
|
||||||
|
)
|
||||||
|
effects.post_shift_change(
|
||||||
|
token,
|
||||||
|
employee.get("slack_user_id", ""),
|
||||||
|
date_str,
|
||||||
|
"swapped",
|
||||||
|
employee["extension"],
|
||||||
|
employee["name"],
|
||||||
|
shift_type,
|
||||||
|
)
|
||||||
|
effects.refresh_schedule_post(schedule, token)
|
||||||
|
return {"ok": True, "message": "Swap accepted."}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_override(schedule, employee, date_str, extension, shift_type) -> dict:
|
||||||
|
require_admin(schedule, employee)
|
||||||
|
date = _parse_date(date_str)
|
||||||
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
shift_type = _shift_type(shift_type)
|
||||||
|
target = schedule.get_employee_by_extension((extension or "").strip())
|
||||||
|
if not target:
|
||||||
|
raise ActionError(400, "UNKNOWN_TARGET", "That extension is not on the roster.")
|
||||||
|
schedule.set_override(date_str, target["extension"], target["name"], shift_type)
|
||||||
|
repointed = effects.maybe_repoint_today(date_str, shift_type, target["extension"])
|
||||||
|
effects.refresh_schedule_post(schedule, effects.slack_token())
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"repointed": repointed,
|
||||||
|
"message": f"Override set for {target['name']}.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_open(schedule, employee, date_str, shift_type) -> dict:
|
||||||
|
require_admin(schedule, employee)
|
||||||
|
date = _parse_date(date_str)
|
||||||
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
shift_type = _shift_type(shift_type)
|
||||||
|
schedule.mark_open(date_str, shift_type)
|
||||||
|
repointed = effects.maybe_repoint_today(date_str, shift_type, FALLBACK_EXTENSION)
|
||||||
|
effects.refresh_schedule_post(schedule, effects.slack_token())
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"repointed": repointed,
|
||||||
|
"message": "Shift marked open.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_clear(schedule, employee, date_str, shift_type) -> dict:
|
||||||
|
require_admin(schedule, employee)
|
||||||
|
date = _parse_date(date_str)
|
||||||
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
shift_type = _shift_type(shift_type)
|
||||||
|
schedule.remove_override(date_str, shift_type)
|
||||||
|
resolved_ext, _name, _source = schedule.resolve_shift(
|
||||||
|
date_str, date.strftime("%A"), shift_type
|
||||||
|
)
|
||||||
|
repointed = effects.maybe_repoint_today(date_str, shift_type, resolved_ext)
|
||||||
|
effects.refresh_schedule_post(schedule, effects.slack_token())
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"repointed": repointed,
|
||||||
|
"message": "Override cleared.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) -> dict:
|
||||||
|
require_admin(schedule, employee)
|
||||||
|
date = _parse_date(date_str)
|
||||||
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
||||||
|
raise ActionError(
|
||||||
|
400, "PAST_SHIFT", "You can't schedule a holiday in the past."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
slot_count = int(slots)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ActionError(
|
||||||
|
400, "INVALID_SLOTS", "Slots must be a whole number."
|
||||||
|
) from exc
|
||||||
|
if slot_count < 1:
|
||||||
|
raise ActionError(400, "INVALID_SLOTS", "Slots must be at least 1.")
|
||||||
|
name = (label or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ActionError(400, "INVALID_LABEL", "A holiday label is required.")
|
||||||
|
multiplier_value = None
|
||||||
|
if multiplier is not None and str(multiplier).strip():
|
||||||
|
match = re.fullmatch(r"x?([0-9]+(?:\.[0-9]+)?)", str(multiplier).strip(), re.I)
|
||||||
|
if not match:
|
||||||
|
raise ActionError(
|
||||||
|
400,
|
||||||
|
"INVALID_MULTIPLIER",
|
||||||
|
"Multiplier must be a number like 2 or 1.5.",
|
||||||
|
)
|
||||||
|
multiplier_value = Decimal(match.group(1))
|
||||||
|
token = effects.slack_token()
|
||||||
|
names = effects.create_holiday_schedules(date_str)
|
||||||
|
created = schedule.create_holiday(
|
||||||
|
date_str,
|
||||||
|
slots=slot_count,
|
||||||
|
label=name,
|
||||||
|
created_by=employee.get("slack_user_id") or employee.get("email") or "",
|
||||||
|
multiplier=multiplier_value,
|
||||||
|
schedule_names=names,
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
effects.delete_holiday_schedules(names)
|
||||||
|
raise ActionError(409, "EXISTS", "A holiday already exists on that date.")
|
||||||
|
if holiday_window_active(date_str):
|
||||||
|
effects.activate_holiday_inline(schedule, date_str)
|
||||||
|
holiday = schedule.get_holiday(date_str)
|
||||||
|
effects.post_holiday_added(
|
||||||
|
token,
|
||||||
|
date_str,
|
||||||
|
name,
|
||||||
|
slot_count,
|
||||||
|
holiday["multiplier"] if holiday else multiplier,
|
||||||
|
)
|
||||||
|
effects.refresh_schedule_post(schedule, token)
|
||||||
|
return {"ok": True, "message": f"Scheduled {name}."}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_holiday_remove(schedule, employee, date_str) -> dict:
|
||||||
|
require_admin(schedule, employee)
|
||||||
|
date = _parse_date(date_str)
|
||||||
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
holiday = schedule.get_holiday(date_str)
|
||||||
|
if not holiday:
|
||||||
|
raise ActionError(404, "NOT_FOUND", "No holiday on that date.")
|
||||||
|
effects.delete_holiday_schedules(list(holiday.get("schedule_names") or []))
|
||||||
|
schedule.remove_holiday(date_str)
|
||||||
|
effects.refresh_schedule_post(schedule, effects.slack_token())
|
||||||
|
return {"ok": True, "message": "Holiday removed."}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_pickup(
|
||||||
|
schedule, employee, date_str, shift_type, extension, approve: bool
|
||||||
|
) -> dict:
|
||||||
|
require_admin(schedule, employee)
|
||||||
|
date = _parse_date(date_str)
|
||||||
|
date_str = date.strftime(DATE_FMT)
|
||||||
|
shift_type = _shift_type(shift_type)
|
||||||
|
ext = (extension or "").strip()
|
||||||
|
req = schedule.get_pickup_request(date_str, shift_type, ext)
|
||||||
|
if not req or req.get("status") != "pending":
|
||||||
|
raise ActionError(404, "NOT_FOUND", "This pickup request is no longer pending.")
|
||||||
|
token = effects.slack_token()
|
||||||
|
if not approve:
|
||||||
|
schedule.clear_pickup_request(date_str, shift_type, ext)
|
||||||
|
if req.get("requester_slack"):
|
||||||
|
effects.dm_text(
|
||||||
|
token,
|
||||||
|
req["requester_slack"],
|
||||||
|
f"Your late pickup for {date_str} was denied.",
|
||||||
|
)
|
||||||
|
return {"ok": True, "message": "Pickup denied."}
|
||||||
|
if shift_ended(date_str, shift_type):
|
||||||
|
schedule.clear_pickup_request(date_str, shift_type, ext)
|
||||||
|
raise ActionError(409, "EXPIRED", "This pickup request expired.")
|
||||||
|
if not schedule.approve_pickup_request(date_str, shift_type, ext):
|
||||||
|
raise ActionError(409, "NOT_FOUND", "This pickup request is no longer pending.")
|
||||||
|
requester_name = req.get("requester_name", ext)
|
||||||
|
if req.get("is_holiday"):
|
||||||
|
claimed = schedule.claim_holiday_slot(date_str, ext, requester_name)
|
||||||
|
if not claimed:
|
||||||
|
schedule.clear_pickup_request(date_str, shift_type, ext)
|
||||||
|
raise ActionError(409, "FULL", "Couldn't assign the holiday slot.")
|
||||||
|
if holiday_window_active(date_str):
|
||||||
|
effects.set_holiday_queue_agents(schedule, date_str)
|
||||||
|
else:
|
||||||
|
claimed = schedule.claim_open_shift(date_str, ext, requester_name, shift_type)
|
||||||
|
if not claimed:
|
||||||
|
schedule.clear_pickup_request(date_str, shift_type, ext)
|
||||||
|
raise ActionError(409, "COVERED", "Couldn't assign the shift.")
|
||||||
|
effects.maybe_repoint_today(date_str, shift_type, ext)
|
||||||
|
schedule.clear_pickup_request(date_str, shift_type, ext)
|
||||||
|
if req.get("requester_slack"):
|
||||||
|
effects.dm_text(
|
||||||
|
token,
|
||||||
|
req["requester_slack"],
|
||||||
|
f"Your late pickup for {date_str} was approved.",
|
||||||
|
)
|
||||||
|
effects.post_shift_change(
|
||||||
|
token,
|
||||||
|
req.get("requester_slack", ""),
|
||||||
|
date_str,
|
||||||
|
"picked_up",
|
||||||
|
ext,
|
||||||
|
requester_name,
|
||||||
|
shift_type,
|
||||||
|
)
|
||||||
|
effects.refresh_schedule_post(schedule, token)
|
||||||
|
return {"ok": True, "message": "Pickup approved."}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from shared.three_cx_client import ThreeCXClient
|
from shared.three_cx_client import oauth_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -15,12 +15,7 @@ def update_queue_routing(
|
||||||
client_secret: str,
|
client_secret: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update 3CX queue forwarding to route calls to the given extension."""
|
"""Update 3CX queue forwarding to route calls to the given extension."""
|
||||||
client = ThreeCXClient(
|
client = oauth_client(domain, client_id, client_secret)
|
||||||
domain=domain,
|
|
||||||
auth_mode="oauth",
|
|
||||||
client_id=client_id,
|
|
||||||
client_secret=client_secret,
|
|
||||||
)
|
|
||||||
queue = client.get_queue(queue_number)
|
queue = client.get_queue(queue_number)
|
||||||
client.update_queue_forwarding(
|
client.update_queue_forwarding(
|
||||||
queue_id=queue["Id"],
|
queue_id=queue["Id"],
|
||||||
|
|
|
||||||
165
src/shared/shared/roster_http.py
Normal file
165
src/shared/shared/roster_http.py
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
"""Roster PUT/DELETE helpers shared by Lambda and Flask."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from shared.schedule import ShiftSchedule
|
||||||
|
from shared.secrets import get_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PUT_FIELDS = ("name", "extension", "slack_user_id")
|
||||||
|
OPTIONAL_PUT_FIELDS = ("email",)
|
||||||
|
MAX_BODY_BYTES = 4096
|
||||||
|
MAX_NAME_LEN = 128
|
||||||
|
MAX_EXTENSION_LEN = 16
|
||||||
|
MAX_SLACK_ID_LEN = 64
|
||||||
|
MAX_EMAIL_LEN = 254
|
||||||
|
ALLOWED_EMAIL_DOMAINS = {"seahaven.com", "seahavenind.com"}
|
||||||
|
|
||||||
|
_cached_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(Exception):
|
||||||
|
"""Missing or wrong Bearer token."""
|
||||||
|
|
||||||
|
|
||||||
|
class SecretUnavailable(Exception):
|
||||||
|
"""Token secret could not be read."""
|
||||||
|
|
||||||
|
|
||||||
|
def has_disallowed_chars(value: str, *, allow_space: bool) -> bool:
|
||||||
|
for char in value:
|
||||||
|
if char == " " and allow_space:
|
||||||
|
continue
|
||||||
|
if char.isspace() or unicodedata.category(char).startswith("C"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def expected_token() -> str:
|
||||||
|
global _cached_token
|
||||||
|
if _cached_token:
|
||||||
|
return _cached_token
|
||||||
|
secret_id = os.environ["ROSTER_API_TOKEN_SECRET"]
|
||||||
|
try:
|
||||||
|
token = get_secret(secret_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("roster api token secret read failed")
|
||||||
|
raise SecretUnavailable from None
|
||||||
|
if not isinstance(token, str):
|
||||||
|
raise SecretUnavailable
|
||||||
|
token = token.strip()
|
||||||
|
if not token:
|
||||||
|
raise SecretUnavailable
|
||||||
|
_cached_token = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def authorize_bearer(presented: str) -> None:
|
||||||
|
if not presented:
|
||||||
|
raise AuthError
|
||||||
|
parts = presented.split(None, 1)
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip():
|
||||||
|
raise AuthError
|
||||||
|
token = parts[1].strip()
|
||||||
|
expected = expected_token()
|
||||||
|
try:
|
||||||
|
matched = hmac.compare_digest(token, expected)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise AuthError from None
|
||||||
|
if not matched:
|
||||||
|
raise AuthError
|
||||||
|
|
||||||
|
|
||||||
|
def validate_email(value: str) -> str:
|
||||||
|
if (
|
||||||
|
len(value) > MAX_EMAIL_LEN
|
||||||
|
or "@" not in value
|
||||||
|
or has_disallowed_chars(value, allow_space=False)
|
||||||
|
):
|
||||||
|
raise ValueError("fields")
|
||||||
|
local, _, domain = value.partition("@")
|
||||||
|
if not local or domain.lower() not in ALLOWED_EMAIL_DOMAINS:
|
||||||
|
raise ValueError("fields")
|
||||||
|
return value.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_put(raw: bytes) -> tuple[str, str, str, str | None]:
|
||||||
|
if len(raw) > MAX_BODY_BYTES:
|
||||||
|
raise ValueError("oversized")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
raise ValueError("invalid json") from None
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise TypeError("invalid json")
|
||||||
|
allowed = set(PUT_FIELDS) | set(OPTIONAL_PUT_FIELDS)
|
||||||
|
if not set(PUT_FIELDS).issubset(parsed) or not set(parsed).issubset(allowed):
|
||||||
|
raise ValueError("fields")
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for field in PUT_FIELDS:
|
||||||
|
value = parsed[field]
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise TypeError("fields")
|
||||||
|
trimmed = value.strip()
|
||||||
|
if not trimmed:
|
||||||
|
raise ValueError("fields")
|
||||||
|
values[field] = trimmed
|
||||||
|
|
||||||
|
name = values["name"]
|
||||||
|
extension = values["extension"]
|
||||||
|
slack_user_id = values["slack_user_id"]
|
||||||
|
email = None
|
||||||
|
if "email" in parsed:
|
||||||
|
raw_email = parsed["email"]
|
||||||
|
if not isinstance(raw_email, str):
|
||||||
|
raise TypeError("fields")
|
||||||
|
trimmed_email = raw_email.strip()
|
||||||
|
if not trimmed_email:
|
||||||
|
raise ValueError("fields")
|
||||||
|
email = validate_email(trimmed_email)
|
||||||
|
|
||||||
|
if len(name) > MAX_NAME_LEN or has_disallowed_chars(name, allow_space=True):
|
||||||
|
raise ValueError("fields")
|
||||||
|
if (
|
||||||
|
len(extension) > MAX_EXTENSION_LEN
|
||||||
|
or not extension.isdigit()
|
||||||
|
or has_disallowed_chars(extension, allow_space=False)
|
||||||
|
):
|
||||||
|
raise ValueError("fields")
|
||||||
|
if (
|
||||||
|
len(slack_user_id) > MAX_SLACK_ID_LEN
|
||||||
|
or not slack_user_id.isalnum()
|
||||||
|
or has_disallowed_chars(slack_user_id, allow_space=False)
|
||||||
|
):
|
||||||
|
raise ValueError("fields")
|
||||||
|
return name, extension, slack_user_id, email
|
||||||
|
|
||||||
|
|
||||||
|
def validate_extension(raw: str) -> str:
|
||||||
|
extension = raw.strip()
|
||||||
|
if (
|
||||||
|
not extension
|
||||||
|
or len(extension) > MAX_EXTENSION_LEN
|
||||||
|
or not extension.isdigit()
|
||||||
|
or has_disallowed_chars(extension, allow_space=False)
|
||||||
|
):
|
||||||
|
raise ValueError("extension")
|
||||||
|
return extension
|
||||||
|
|
||||||
|
|
||||||
|
def upsert(raw: bytes) -> None:
|
||||||
|
name, extension, slack_user_id, email = validate_put(raw)
|
||||||
|
ShiftSchedule().upsert_roster_entry(extension, name, slack_user_id, email=email)
|
||||||
|
logger.info("roster upserted extension=%s", extension)
|
||||||
|
|
||||||
|
|
||||||
|
def remove(extension: str) -> None:
|
||||||
|
ShiftSchedule().remove_roster_entry(extension)
|
||||||
|
logger.info("roster deleted extension=%s", extension)
|
||||||
|
|
@ -10,7 +10,7 @@ Single-table design:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
@ -36,6 +36,21 @@ DEFAULT_HOLIDAY_QUEUE = "802"
|
||||||
DEFAULT_IVR_NUMBER = "800"
|
DEFAULT_IVR_NUMBER = "800"
|
||||||
|
|
||||||
|
|
||||||
|
def week_start(when: datetime) -> datetime:
|
||||||
|
"""Sunday 00:00 Eastern of the Sun–Sat work week that contains ``when``.
|
||||||
|
|
||||||
|
Shifts belong to the week of their start date. A Saturday night shift
|
||||||
|
(5pm Saturday through 8am Sunday) stays in the week that ends Saturday.
|
||||||
|
Sunday day and Sunday night open the next week.
|
||||||
|
"""
|
||||||
|
if when.tzinfo is None:
|
||||||
|
when = when.replace(tzinfo=EASTERN)
|
||||||
|
else:
|
||||||
|
when = when.astimezone(EASTERN)
|
||||||
|
when = when.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
return when - timedelta(days=(when.weekday() + 1) % 7)
|
||||||
|
|
||||||
|
|
||||||
def determine_shift_type(now: datetime | None = None) -> str:
|
def determine_shift_type(now: datetime | None = None) -> str:
|
||||||
"""Return the currently active shift type: 'day' or 'night'.
|
"""Return the currently active shift type: 'day' or 'night'.
|
||||||
|
|
||||||
|
|
@ -70,6 +85,17 @@ class ShiftSchedule:
|
||||||
return item
|
return item
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_employee_by_email(self, email: str) -> dict | None:
|
||||||
|
"""Match a roster row by email, case-insensitive."""
|
||||||
|
wanted = email.strip().lower()
|
||||||
|
if not wanted:
|
||||||
|
return None
|
||||||
|
for item in self.get_roster():
|
||||||
|
stored = item.get("email")
|
||||||
|
if isinstance(stored, str) and stored.strip().lower() == wanted:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
def register_user(self, slack_user_id: str, extension: str) -> dict | None:
|
def register_user(self, slack_user_id: str, extension: str) -> dict | None:
|
||||||
"""Link a Slack user to a roster extension.
|
"""Link a Slack user to a roster extension.
|
||||||
|
|
||||||
|
|
@ -235,29 +261,32 @@ class ShiftSchedule:
|
||||||
requester: dict,
|
requester: dict,
|
||||||
target: dict,
|
target: dict,
|
||||||
expires_at: int,
|
expires_at: int,
|
||||||
|
note: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create (or supersede) a pending swap request for a shift.
|
"""Create (or supersede) a pending swap request for a shift.
|
||||||
|
|
||||||
One swap per shift (unique SK), so a new request overwrites any prior
|
One swap per shift (unique SK), so a new request overwrites any prior
|
||||||
pending one. ``expires_at`` is an epoch timestamp used for DynamoDB TTL.
|
pending one. ``expires_at`` is an epoch timestamp used for DynamoDB TTL.
|
||||||
|
``note`` is omitted when empty so Slack ``/oncall swap`` stays unchanged.
|
||||||
"""
|
"""
|
||||||
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
|
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
|
||||||
self.table.put_item(
|
item = {
|
||||||
Item={
|
"PK": "SWAP",
|
||||||
"PK": "SWAP",
|
"SK": sk,
|
||||||
"SK": sk,
|
"shift_type": shift_type,
|
||||||
"shift_type": shift_type,
|
"status": "pending",
|
||||||
"status": "pending",
|
"requester_ext": requester["extension"],
|
||||||
"requester_ext": requester["extension"],
|
"requester_name": requester["name"],
|
||||||
"requester_name": requester["name"],
|
"requester_slack": requester.get("slack_user_id", ""),
|
||||||
"requester_slack": requester.get("slack_user_id", ""),
|
"target_ext": target["extension"],
|
||||||
"target_ext": target["extension"],
|
"target_name": target["name"],
|
||||||
"target_name": target["name"],
|
"target_slack": target.get("slack_user_id", ""),
|
||||||
"target_slack": target.get("slack_user_id", ""),
|
"created_at": datetime.now(EASTERN).isoformat(),
|
||||||
"created_at": datetime.now(EASTERN).isoformat(),
|
"expires_at": expires_at,
|
||||||
"expires_at": expires_at,
|
}
|
||||||
}
|
if note:
|
||||||
)
|
item["note"] = note
|
||||||
|
self.table.put_item(Item=item)
|
||||||
|
|
||||||
def get_swap(self, date_str: str, shift_type: str = "night") -> dict | None:
|
def get_swap(self, date_str: str, shift_type: str = "night") -> dict | None:
|
||||||
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
|
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
|
||||||
|
|
@ -280,6 +309,12 @@ class ShiftSchedule:
|
||||||
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
|
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
|
||||||
self.table.delete_item(Key={"PK": "SWAP", "SK": sk})
|
self.table.delete_item(Key={"PK": "SWAP", "SK": sk})
|
||||||
|
|
||||||
|
def list_pending_swaps(self) -> list[dict]:
|
||||||
|
resp = self.table.query(KeyConditionExpression=Key("PK").eq("SWAP"))
|
||||||
|
return [
|
||||||
|
item for item in resp.get("Items", []) if item.get("status") == "pending"
|
||||||
|
]
|
||||||
|
|
||||||
# ── Late-pickup requests ─────────────────────────────────────────────
|
# ── Late-pickup requests ─────────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -364,6 +399,12 @@ class ShiftSchedule:
|
||||||
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
|
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def list_pending_pickup_requests(self) -> list[dict]:
|
||||||
|
resp = self.table.query(KeyConditionExpression=Key("PK").eq("PICKUP_REQUEST"))
|
||||||
|
return [
|
||||||
|
item for item in resp.get("Items", []) if item.get("status") == "pending"
|
||||||
|
]
|
||||||
|
|
||||||
# ── Holidays ────────────────────────────────────────────────────────
|
# ── Holidays ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_holiday(self, date_str: str) -> dict | None:
|
def get_holiday(self, date_str: str) -> dict | None:
|
||||||
|
|
@ -620,12 +661,27 @@ class ShiftSchedule:
|
||||||
# ── Pay records ─────────────────────────────────────────────────────
|
# ── Pay records ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_pay_record(self, week_key: str) -> dict | None:
|
def get_pay_record(self, week_key: str) -> dict | None:
|
||||||
"""Get a pay record by week key (e.g. '2026-04-06')."""
|
"""Get a pay record by week key (the Sunday that opens the week)."""
|
||||||
resp = self.table.get_item(Key={"PK": "PAY", "SK": week_key})
|
resp = self.table.get_item(Key={"PK": "PAY", "SK": week_key})
|
||||||
return resp.get("Item")
|
return resp.get("Item")
|
||||||
|
|
||||||
|
def list_pay_records(self) -> list[dict]:
|
||||||
|
"""Return every PAY row, including legacy Monday-keyed weeks."""
|
||||||
|
items: list[dict] = []
|
||||||
|
kwargs: dict = {"KeyConditionExpression": Key("PK").eq("PAY")}
|
||||||
|
while True:
|
||||||
|
resp = self.table.query(**kwargs)
|
||||||
|
items.extend(resp.get("Items", []))
|
||||||
|
last = resp.get("LastEvaluatedKey")
|
||||||
|
if not last:
|
||||||
|
return items
|
||||||
|
kwargs["ExclusiveStartKey"] = last
|
||||||
|
|
||||||
def save_pay_record(self, week_key: str, record: dict) -> None:
|
def save_pay_record(self, week_key: str, record: dict) -> None:
|
||||||
"""Save a weekly pay summary. week_key is the Monday date string."""
|
"""Save a weekly pay summary. week_key is the Sunday date string.
|
||||||
|
|
||||||
|
Older rows may still be keyed by Monday. New writes use Sunday.
|
||||||
|
"""
|
||||||
self.table.put_item(Item={"PK": "PAY", "SK": week_key, **record})
|
self.table.put_item(Item={"PK": "PAY", "SK": week_key, **record})
|
||||||
|
|
||||||
# ── Config ──────────────────────────────────────────────────────────
|
# ── Config ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -700,6 +756,37 @@ class ShiftSchedule:
|
||||||
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
|
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def upsert_roster_entry(
|
||||||
|
self,
|
||||||
|
extension: str,
|
||||||
|
name: str,
|
||||||
|
slack_user_id: str,
|
||||||
|
email: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Create or update a roster row without clobbering unrelated attributes.
|
||||||
|
|
||||||
|
Always writes ``name``, ``extension``, and ``slack_user_id``. ``email`` is
|
||||||
|
written when provided and left untouched when omitted. An existing
|
||||||
|
``shift_rate`` (and any other attributes) survive. Unlike
|
||||||
|
:meth:`add_roster_entry`, this is an upsert and writes the Slack id.
|
||||||
|
"""
|
||||||
|
names = {"#n": "name"}
|
||||||
|
values = {
|
||||||
|
":name": name,
|
||||||
|
":ext": extension,
|
||||||
|
":sid": slack_user_id,
|
||||||
|
}
|
||||||
|
expression = "SET #n = :name, extension = :ext, slack_user_id = :sid"
|
||||||
|
if email is not None:
|
||||||
|
expression += ", email = :email"
|
||||||
|
values[":email"] = email
|
||||||
|
self.table.update_item(
|
||||||
|
Key={"PK": "ROSTER", "SK": extension},
|
||||||
|
UpdateExpression=expression,
|
||||||
|
ExpressionAttributeNames=names,
|
||||||
|
ExpressionAttributeValues=values,
|
||||||
|
)
|
||||||
|
|
||||||
def remove_roster_entry(self, extension: str) -> None:
|
def remove_roster_entry(self, extension: str) -> None:
|
||||||
self.table.delete_item(Key={"PK": "ROSTER", "SK": extension})
|
self.table.delete_item(Key={"PK": "ROSTER", "SK": extension})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,19 +120,42 @@ def _before_send(event, _hint):
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def _git_sha():
|
||||||
|
try:
|
||||||
|
from shared.build_info import GIT_SHA
|
||||||
|
except ImportError:
|
||||||
|
return os.environ.get("GIT_SHA", "").strip()
|
||||||
|
return str(GIT_SHA or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _integrations():
|
||||||
|
if os.environ.get("AWS_LAMBDA_FUNCTION_NAME"):
|
||||||
|
return [AwsLambdaIntegration(timeout_warning=True)]
|
||||||
|
try:
|
||||||
|
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||||
|
|
||||||
|
return [FlaskIntegration()]
|
||||||
|
except Exception:
|
||||||
|
return [AwsLambdaIntegration(timeout_warning=True)]
|
||||||
|
|
||||||
|
|
||||||
def init_sentry():
|
def init_sentry():
|
||||||
dsn = os.environ.get("SENTRY_DSN")
|
dsn = os.environ.get("SENTRY_DSN")
|
||||||
if not dsn:
|
if not dsn:
|
||||||
return
|
return
|
||||||
sentry_sdk.init(
|
kwargs = {
|
||||||
dsn=dsn,
|
"dsn": dsn,
|
||||||
integrations=[AwsLambdaIntegration(timeout_warning=True)],
|
"integrations": _integrations(),
|
||||||
send_default_pii=False,
|
"send_default_pii": False,
|
||||||
include_local_variables=False,
|
"include_local_variables": False,
|
||||||
enable_logs=False,
|
"enable_logs": False,
|
||||||
traces_sample_rate=0.0,
|
"traces_sample_rate": 0.0,
|
||||||
before_send=_before_send,
|
"before_send": _before_send,
|
||||||
)
|
}
|
||||||
|
sha = _git_sha()
|
||||||
|
if sha:
|
||||||
|
kwargs["release"] = sha
|
||||||
|
sentry_sdk.init(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
init_sentry()
|
init_sentry()
|
||||||
|
|
|
||||||
47
src/shared/shared/shift_clock.py
Normal file
47
src/shared/shared/shift_clock.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Eastern-time shift window helpers shared by Slack and the portal API."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from shared.schedule import determine_shift_type
|
||||||
|
|
||||||
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
|
||||||
|
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 shift_start(date_str: str, shift_type: str) -> datetime:
|
||||||
|
d = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=EASTERN)
|
||||||
|
return d.replace(hour=8 if shift_type == "day" else 17)
|
||||||
|
|
||||||
|
|
||||||
|
def shift_started(date_str: str, shift_type: str) -> bool:
|
||||||
|
return datetime.now(EASTERN) >= shift_start(date_str, shift_type)
|
||||||
|
|
||||||
|
|
||||||
|
def shift_end(date_str: str, shift_type: str) -> datetime:
|
||||||
|
d = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=EASTERN)
|
||||||
|
if shift_type == "day":
|
||||||
|
return d.replace(hour=17)
|
||||||
|
return d.replace(hour=8) + timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def shift_ended(date_str: str, shift_type: str) -> bool:
|
||||||
|
return datetime.now(EASTERN) >= shift_end(date_str, shift_type)
|
||||||
|
|
||||||
|
|
||||||
|
def holiday_window_active(date_str: str) -> bool:
|
||||||
|
now = datetime.now(EASTERN)
|
||||||
|
return shift_start(date_str, "day") <= now < shift_end(date_str, "day")
|
||||||
|
|
||||||
|
|
||||||
|
def within_drop_lock(date_str: str, shift_type: str) -> bool:
|
||||||
|
return datetime.now(EASTERN) >= shift_start(date_str, shift_type) - timedelta(
|
||||||
|
hours=24
|
||||||
|
)
|
||||||
349
src/shared/shared/side_effects.py
Normal file
349
src/shared/shared/side_effects.py
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
"""3CX, holiday scheduler, and Slack channel side effects for portal mutations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from shared.blocks import (
|
||||||
|
build_holiday_added_blocks,
|
||||||
|
build_pickup_request_blocks,
|
||||||
|
build_shift_change_message,
|
||||||
|
_mrkdwn_text,
|
||||||
|
build_swap_request_blocks,
|
||||||
|
build_week_schedule,
|
||||||
|
)
|
||||||
|
from shared.effects import prod_side_effects_enabled
|
||||||
|
from shared.ring_scheduler import update_queue_routing
|
||||||
|
from shared.schedule import FALLBACK_EXTENSION, week_start
|
||||||
|
from shared.secrets import get_secret
|
||||||
|
from shared.shift_clock import is_active_shift_type, is_today
|
||||||
|
from shared.three_cx_client import ThreeCXClient, oauth_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
SLACK_API = "https://slack.com/api"
|
||||||
|
|
||||||
|
|
||||||
|
def slack_token() -> str | None:
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
return None
|
||||||
|
secret_id = os.environ.get("SLACK_BOT_TOKEN_SECRET")
|
||||||
|
if not secret_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return get_secret(secret_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to read Slack bot token")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def slack_call(method: str, token: str, **payload) -> bool:
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
logger.info("Skipping Slack %s because STAGE is not prod", method)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{SLACK_API}/{method}",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
timeout=8,
|
||||||
|
)
|
||||||
|
body = response.json()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Slack %s failed", method)
|
||||||
|
return False
|
||||||
|
if not body.get("ok"):
|
||||||
|
logger.warning("Slack %s error: %s", method, body.get("error"))
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def update_3cx_routing(extension: str) -> None:
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
logger.info("Skipping 3CX routing because STAGE is not prod")
|
||||||
|
return
|
||||||
|
queue_number = os.environ.get("QUEUE_NUMBER")
|
||||||
|
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
||||||
|
if not queue_number or not secret_prefix:
|
||||||
|
logger.warning("3CX env vars not set — skipping queue update")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
update_queue_routing(
|
||||||
|
extension=extension,
|
||||||
|
queue_number=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")
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_repoint_today(date_str: str, shift_type: str, extension: str) -> bool:
|
||||||
|
if is_today(date_str) and is_active_shift_type(shift_type):
|
||||||
|
update_3cx_routing(extension)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def make_3cx_client() -> ThreeCXClient | None:
|
||||||
|
"""Return the process OAuth client, refreshing it when the token or secret changed."""
|
||||||
|
if not prod_side_effects_enabled():
|
||||||
|
return None
|
||||||
|
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
|
||||||
|
if not secret_prefix:
|
||||||
|
logger.warning("3CX env vars not set — skipping 3CX call")
|
||||||
|
return None
|
||||||
|
return oauth_client(
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_holiday_queue_agents(schedule, date_str: str) -> None:
|
||||||
|
holiday = schedule.get_holiday(date_str)
|
||||||
|
if holiday is None:
|
||||||
|
return
|
||||||
|
assignees = holiday.get("assignees", {}) or {}
|
||||||
|
extensions = list(assignees.keys()) or [FALLBACK_EXTENSION]
|
||||||
|
try:
|
||||||
|
client = make_3cx_client()
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
queue_number = schedule.get_holiday_queue()
|
||||||
|
queue = client.get_queue(queue_number)
|
||||||
|
client.set_queue_agents(queue["Id"], extensions)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to set holiday queue agents for %s", date_str)
|
||||||
|
|
||||||
|
|
||||||
|
def activate_holiday_inline(schedule, date_str: str) -> None:
|
||||||
|
if os.environ.get("JOBS_QUEUE_URL", "").strip():
|
||||||
|
from shared.holiday_flow import activate
|
||||||
|
|
||||||
|
try:
|
||||||
|
activate(schedule, date_str)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed in-process holiday activate for %s", date_str)
|
||||||
|
return
|
||||||
|
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
||||||
|
if not router_arn:
|
||||||
|
logger.warning("HOLIDAY_ROUTER_ARN not set — skipping inline activation")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
boto3.client("lambda").invoke(
|
||||||
|
FunctionName=router_arn,
|
||||||
|
InvocationType="Event",
|
||||||
|
Payload=json.dumps({"action": "activate", "date": date_str}).encode(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to invoke holiday router for %s", date_str)
|
||||||
|
|
||||||
|
|
||||||
|
def holiday_schedule_names(date_str: str) -> tuple[str, str]:
|
||||||
|
compact = date_str.replace("-", "")
|
||||||
|
return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}"
|
||||||
|
|
||||||
|
|
||||||
|
def _holiday_schedule_target(action: str, date_str: str) -> dict | None:
|
||||||
|
role_arn = os.environ.get("HOLIDAY_SCHEDULER_ROLE_ARN")
|
||||||
|
queue_arn = os.environ.get("JOBS_QUEUE_ARN", "").strip()
|
||||||
|
if queue_arn and role_arn:
|
||||||
|
return {
|
||||||
|
"Arn": queue_arn,
|
||||||
|
"RoleArn": role_arn,
|
||||||
|
"Input": json.dumps(
|
||||||
|
{"event": "holiday", "action": action, "date": date_str}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
||||||
|
if router_arn and role_arn:
|
||||||
|
return {
|
||||||
|
"Arn": router_arn,
|
||||||
|
"RoleArn": role_arn,
|
||||||
|
"Input": json.dumps({"action": action, "date": date_str}),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_holiday_schedules(date_str: str) -> list[str]:
|
||||||
|
group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default")
|
||||||
|
target = _holiday_schedule_target("activate", date_str)
|
||||||
|
if target is None:
|
||||||
|
logger.warning(
|
||||||
|
"HOLIDAY_SCHEDULER_ROLE_ARN plus JOBS_QUEUE_ARN or HOLIDAY_ROUTER_ARN "
|
||||||
|
"not set — skipping schedules"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
activate_name, deactivate_name = holiday_schedule_names(date_str)
|
||||||
|
client = boto3.client("scheduler")
|
||||||
|
created: list[str] = []
|
||||||
|
for name, action, at_time in (
|
||||||
|
(activate_name, "activate", "08:00:00"),
|
||||||
|
(deactivate_name, "deactivate", "17:00:00"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
client.create_schedule(
|
||||||
|
Name=name,
|
||||||
|
GroupName=group,
|
||||||
|
ScheduleExpression=f"at({date_str}T{at_time})",
|
||||||
|
ScheduleExpressionTimezone="America/New_York",
|
||||||
|
FlexibleTimeWindow={"Mode": "OFF"},
|
||||||
|
ActionAfterCompletion="DELETE",
|
||||||
|
Target=_holiday_schedule_target(action, date_str),
|
||||||
|
)
|
||||||
|
created.append(name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to create %s schedule for %s", action, date_str)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def delete_holiday_schedules(schedule_names: list[str]) -> None:
|
||||||
|
if not schedule_names:
|
||||||
|
return
|
||||||
|
group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default")
|
||||||
|
try:
|
||||||
|
client = boto3.client("scheduler")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Could not create scheduler client to delete schedules")
|
||||||
|
return
|
||||||
|
for name in schedule_names:
|
||||||
|
try:
|
||||||
|
client.delete_schedule(Name=name, GroupName=group)
|
||||||
|
except client.exceptions.ResourceNotFoundException:
|
||||||
|
logger.info("Holiday schedule %s already gone", name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to delete holiday schedule %s", name)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_fallback_text() -> str:
|
||||||
|
now = datetime.now(EASTERN)
|
||||||
|
start = week_start(now)
|
||||||
|
end_date = start + timedelta(days=13)
|
||||||
|
return (
|
||||||
|
f"After-Hours Schedule — {start.strftime('%b %-d')} "
|
||||||
|
f"to {end_date.strftime('%b %-d')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_schedule_post(schedule, token: str | None) -> None:
|
||||||
|
channel = os.environ.get("SHIFT_CHANNEL")
|
||||||
|
if not channel or not token:
|
||||||
|
return
|
||||||
|
post = schedule.get_schedule_post(channel)
|
||||||
|
if not post or not post.get("message_ts"):
|
||||||
|
return
|
||||||
|
slack_call(
|
||||||
|
"chat.update",
|
||||||
|
token,
|
||||||
|
channel=channel,
|
||||||
|
ts=post["message_ts"],
|
||||||
|
blocks=build_week_schedule(schedule),
|
||||||
|
text=schedule_fallback_text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def post_shift_change(
|
||||||
|
token: str | None,
|
||||||
|
user_id: str,
|
||||||
|
date_str: str,
|
||||||
|
action: str,
|
||||||
|
ext: str,
|
||||||
|
name: str,
|
||||||
|
shift_type: str,
|
||||||
|
) -> None:
|
||||||
|
channel = os.environ.get("SHIFT_CHANNEL")
|
||||||
|
if not channel or not token:
|
||||||
|
return
|
||||||
|
slack_call(
|
||||||
|
"chat.postMessage",
|
||||||
|
token,
|
||||||
|
channel=channel,
|
||||||
|
blocks=build_shift_change_message(
|
||||||
|
user_id, date_str, action, ext, name, shift_type=shift_type
|
||||||
|
),
|
||||||
|
text=f"Shift {action.replace('_', ' ')} for {date_str}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dm_swap_request(
|
||||||
|
token: str | None,
|
||||||
|
requester_slack: str,
|
||||||
|
target_slack: str,
|
||||||
|
date_str: str,
|
||||||
|
shift_type: str,
|
||||||
|
requester_name: str,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
if not token or not target_slack:
|
||||||
|
return False
|
||||||
|
text = f"{requester_name} wants to swap you the {date_str} shift"
|
||||||
|
if note:
|
||||||
|
text += f"\nNote: {_mrkdwn_text(note)}"
|
||||||
|
return slack_call(
|
||||||
|
"chat.postMessage",
|
||||||
|
token,
|
||||||
|
channel=target_slack,
|
||||||
|
blocks=build_swap_request_blocks(requester_slack, date_str, shift_type, note),
|
||||||
|
text=text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dm_text(token: str | None, user_id: str, text: str) -> None:
|
||||||
|
if not token or not user_id:
|
||||||
|
return
|
||||||
|
slack_call("chat.postMessage", token, channel=user_id, text=text)
|
||||||
|
|
||||||
|
|
||||||
|
def dm_late_pickup_admins(
|
||||||
|
schedule,
|
||||||
|
token: str | None,
|
||||||
|
employee: dict,
|
||||||
|
date_str: str,
|
||||||
|
shift_type: str,
|
||||||
|
is_holiday: bool,
|
||||||
|
) -> int:
|
||||||
|
if not token:
|
||||||
|
return 0
|
||||||
|
blocks = build_pickup_request_blocks(
|
||||||
|
requester_slack=employee.get("slack_user_id", ""),
|
||||||
|
requester_name=employee["name"],
|
||||||
|
date_str=date_str,
|
||||||
|
shift_type=shift_type,
|
||||||
|
requester_ext=employee["extension"],
|
||||||
|
is_holiday=is_holiday,
|
||||||
|
)
|
||||||
|
text = f"{employee['name']} wants to pick up the already-started {date_str} shift"
|
||||||
|
delivered = 0
|
||||||
|
for admin_id in schedule.get_admin_users():
|
||||||
|
if slack_call(
|
||||||
|
"chat.postMessage", token, channel=admin_id, blocks=blocks, text=text
|
||||||
|
):
|
||||||
|
delivered += 1
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
|
||||||
|
def post_holiday_added(
|
||||||
|
token: str | None, date_str: str, label: str, slots: int, multiplier
|
||||||
|
) -> None:
|
||||||
|
channel = os.environ.get("SHIFT_CHANNEL")
|
||||||
|
if not channel or not token:
|
||||||
|
return
|
||||||
|
slack_call(
|
||||||
|
"chat.postMessage",
|
||||||
|
token,
|
||||||
|
channel=channel,
|
||||||
|
blocks=build_holiday_added_blocks(date_str, label, slots, multiplier),
|
||||||
|
text=f"Holiday added: {label} on {date_str}",
|
||||||
|
)
|
||||||
|
|
@ -1,8 +1,37 @@
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_oauth_clients: dict[tuple[str, str], "ThreeCXClient"] = {}
|
||||||
|
# Refresh this long before 3CX's expires_in, so a job does not start on a token
|
||||||
|
# that dies mid-call. 3CX client-credentials tokens last 3600 seconds.
|
||||||
|
_TOKEN_SKEW_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_client(domain: str, client_id: str, client_secret: str) -> "ThreeCXClient":
|
||||||
|
"""Reuse one OAuth client per (domain, client_id) in this process.
|
||||||
|
|
||||||
|
Re-authenticates when the access token is near expiry, and immediately when
|
||||||
|
``client_secret`` differs from the one the cached client logged in with.
|
||||||
|
"""
|
||||||
|
key = (domain, client_id)
|
||||||
|
client = _oauth_clients.get(key)
|
||||||
|
if client is None:
|
||||||
|
client = ThreeCXClient(
|
||||||
|
domain=domain,
|
||||||
|
auth_mode="oauth",
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
)
|
||||||
|
_oauth_clients[key] = client
|
||||||
|
return client
|
||||||
|
client.use_client_secret(client_secret)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
class ThreeCXClient:
|
class ThreeCXClient:
|
||||||
"""Client for 3CX V20 cloud-hosted management API (XAPI)."""
|
"""Client for 3CX V20 cloud-hosted management API (XAPI)."""
|
||||||
|
|
@ -15,7 +44,14 @@ class ThreeCXClient:
|
||||||
auth_kwargs: credentials — see _authenticate_user / _authenticate_oauth
|
auth_kwargs: credentials — see _authenticate_user / _authenticate_oauth
|
||||||
"""
|
"""
|
||||||
self.base_url = f"https://{domain}"
|
self.base_url = f"https://{domain}"
|
||||||
|
self._auth_mode = auth_mode
|
||||||
|
self._client_id = auth_kwargs.get("client_id")
|
||||||
|
self._client_secret = auth_kwargs.get("client_secret")
|
||||||
|
self._token_expires_at = 0.0
|
||||||
|
self._auth_lock = threading.RLock()
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
|
self._raw_request = self.session.request
|
||||||
|
self.session.request = self._request
|
||||||
self.session.headers.update(
|
self.session.headers.update(
|
||||||
{
|
{
|
||||||
"OData-Version": "4.0",
|
"OData-Version": "4.0",
|
||||||
|
|
@ -24,12 +60,66 @@ class ThreeCXClient:
|
||||||
)
|
)
|
||||||
|
|
||||||
if auth_mode == "oauth":
|
if auth_mode == "oauth":
|
||||||
self._authenticate_oauth(
|
self._authenticate_oauth(self._client_id, self._client_secret)
|
||||||
auth_kwargs["client_id"], auth_kwargs["client_secret"]
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self._authenticate_user(auth_kwargs["username"], auth_kwargs["password"])
|
self._authenticate_user(auth_kwargs["username"], auth_kwargs["password"])
|
||||||
|
|
||||||
|
def use_client_secret(self, client_secret: str) -> None:
|
||||||
|
"""Point this client at ``client_secret`` and log in again if needed.
|
||||||
|
|
||||||
|
A different secret is logged in with before it replaces the current one.
|
||||||
|
A candidate that 3CX rejects leaves the working secret in place, so a
|
||||||
|
slower caller still holding a revoked secret cannot clobber a good one,
|
||||||
|
and a later revert to a secret 3CX accepts still takes effect.
|
||||||
|
"""
|
||||||
|
with self._auth_lock:
|
||||||
|
if client_secret == self._client_secret:
|
||||||
|
self._refresh_expired_token()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._authenticate_oauth(self._client_id, client_secret)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"3CX login with a new client secret failed; keeping the current secret"
|
||||||
|
)
|
||||||
|
self._refresh_expired_token()
|
||||||
|
return
|
||||||
|
self._client_secret = client_secret
|
||||||
|
|
||||||
|
def ensure_fresh_token(self) -> None:
|
||||||
|
"""Fetch a new access token when the current one is missing or near expiry."""
|
||||||
|
if self._auth_mode != "oauth":
|
||||||
|
return
|
||||||
|
if time.monotonic() < self._token_expires_at:
|
||||||
|
return
|
||||||
|
with self._auth_lock:
|
||||||
|
self._refresh_expired_token()
|
||||||
|
|
||||||
|
def _refresh_expired_token(self) -> None:
|
||||||
|
"""Log in again when the token is due. Caller holds ``_auth_lock``."""
|
||||||
|
if self._auth_mode != "oauth":
|
||||||
|
return
|
||||||
|
if time.monotonic() < self._token_expires_at:
|
||||||
|
return
|
||||||
|
self._authenticate_oauth(self._client_id, self._client_secret)
|
||||||
|
|
||||||
|
def _request(self, method, url, **kwargs):
|
||||||
|
if self._auth_mode == "oauth":
|
||||||
|
self.ensure_fresh_token()
|
||||||
|
response = self._raw_request(method, url, **kwargs)
|
||||||
|
if self._auth_mode == "oauth" and response.status_code == 401:
|
||||||
|
try:
|
||||||
|
with self._auth_lock:
|
||||||
|
self._token_expires_at = 0.0
|
||||||
|
self._authenticate_oauth(self._client_id, self._client_secret)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"3CX re-login after 401 failed; returning the original response"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
response = self._raw_request(method, url, **kwargs)
|
||||||
|
return response
|
||||||
|
|
||||||
def _authenticate_user(self, username: str, password: str):
|
def _authenticate_user(self, username: str, password: str):
|
||||||
"""Authenticate via extension/user credentials (any license tier)."""
|
"""Authenticate via extension/user credentials (any license tier)."""
|
||||||
resp = self.session.post(
|
resp = self.session.post(
|
||||||
|
|
@ -47,17 +137,29 @@ class ThreeCXClient:
|
||||||
def _authenticate_oauth(self, client_id: str, client_secret: str):
|
def _authenticate_oauth(self, client_id: str, client_secret: str):
|
||||||
"""Authenticate via OAuth2 client credentials (Enterprise license required).
|
"""Authenticate via OAuth2 client credentials (Enterprise license required).
|
||||||
API client must be created in 3CX Admin > Integrations > API."""
|
API client must be created in 3CX Admin > Integrations > API."""
|
||||||
resp = self.session.post(
|
# Drop the session bearer. A refresh otherwise sends the expired
|
||||||
|
# access token to /connect/token, and 3CX answers 400.
|
||||||
|
resp = self._raw_request(
|
||||||
|
"POST",
|
||||||
f"{self.base_url}/connect/token",
|
f"{self.base_url}/connect/token",
|
||||||
data={
|
data={
|
||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
"client_secret": client_secret,
|
"client_secret": client_secret,
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
},
|
},
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
headers={
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Authorization": None,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
token = resp.json()["access_token"]
|
body = resp.json()
|
||||||
|
token = body.get("access_token")
|
||||||
|
if not token:
|
||||||
|
raise ValueError("Failed to get access token from 3CX OAuth response")
|
||||||
|
expires_in = int(body.get("expires_in") or 3600)
|
||||||
|
skew = min(_TOKEN_SKEW_SECONDS, expires_in // 10)
|
||||||
|
self._token_expires_at = time.monotonic() + max(expires_in - skew, 0)
|
||||||
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
||||||
logger.info("Authenticated to 3CX via OAuth2 client credentials")
|
logger.info("Authenticated to 3CX via OAuth2 client credentials")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,27 @@ fine and still supported.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v1.17.0 — September 25, 2026
|
||||||
|
|
||||||
|
**The work week now runs Sunday through Saturday, matching payroll.** The Monday
|
||||||
|
7am schedule post and pay summary use that week. A Saturday night shift (5pm
|
||||||
|
Saturday through 8am Sunday) stays in the Saturday week. Sunday day and Sunday
|
||||||
|
night open the next week. The first pay close after this change skips any date
|
||||||
|
already sent to Flex, so that Sunday is not paid twice.
|
||||||
|
|
||||||
|
## v1.16.0 — September 21, 2026
|
||||||
|
|
||||||
|
**After Hours is available in the employee portal, and Slack still works.** Employees
|
||||||
|
can pick up, drop, and swap shifts from `internal.seahaven.com`, and admins can
|
||||||
|
override coverage, open or clear a shift, manage holidays, and approve late
|
||||||
|
pickups there. Swap and late-pickup still send Slack DMs. Slack App Home admin
|
||||||
|
modals are unchanged.
|
||||||
|
|
||||||
|
Portal identity is the roster email on Paychex `PUT /roster` (optional so existing
|
||||||
|
syncs keep working). Admin access is still the Slack IDs in `admin_users` after
|
||||||
|
that lookup. A new `afterhours-portal-api` Lambda serves `GET/POST/DELETE /api/shifts`
|
||||||
|
on the existing HTTP API with Cognito ID-token auth.
|
||||||
|
|
||||||
## v1.15.0 — September 2, 2026
|
## v1.15.0 — September 2, 2026
|
||||||
|
|
||||||
**Monday pay totals now queue to Flex payroll posting.** The weekly post still
|
**Monday pay totals now queue to Flex payroll posting.** The weekly post still
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ is a thin wiring layer that registers the Bolt routes and delegates to them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -45,6 +44,7 @@ from shared.schedule import (
|
||||||
ExtensionAlreadyRegistered,
|
ExtensionAlreadyRegistered,
|
||||||
ShiftSchedule,
|
ShiftSchedule,
|
||||||
determine_shift_type,
|
determine_shift_type,
|
||||||
|
week_start,
|
||||||
)
|
)
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
from shared.three_cx_client import ThreeCXClient
|
from shared.three_cx_client import ThreeCXClient
|
||||||
|
|
@ -191,85 +191,24 @@ def _set_holiday_queue_agents(schedule, date_str: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _activate_holiday_inline(schedule, date_str: str) -> None:
|
def _activate_holiday_inline(schedule, date_str: str) -> None:
|
||||||
"""Invoke the holiday router's activate path now, for a holiday added late.
|
"""Activate a late-added holiday in-process or via the holiday-router Lambda."""
|
||||||
|
from shared.side_effects import activate_holiday_inline
|
||||||
|
|
||||||
When an admin schedules a holiday whose window is already open (08:00 ≤ now <
|
activate_holiday_inline(schedule, date_str)
|
||||||
17:00 ET), the 08:00 activation schedule has already passed, so the call flow
|
|
||||||
must be repointed immediately. We invoke the holiday-router Lambda
|
|
||||||
asynchronously so the (idempotent) activate logic — IVR capture/repoint,
|
|
||||||
queue membership, ``activated`` flag — runs exactly as it would at 08:00.
|
|
||||||
Best-effort: a missing ARN or invoke failure is logged, not raised.
|
|
||||||
"""
|
|
||||||
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
|
||||||
if not router_arn:
|
|
||||||
logger.warning("HOLIDAY_ROUTER_ARN not set — skipping inline activation")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
boto3.client("lambda").invoke(
|
|
||||||
FunctionName=router_arn,
|
|
||||||
InvocationType="Event",
|
|
||||||
Payload=json.dumps({"action": "activate", "date": date_str}).encode(),
|
|
||||||
)
|
|
||||||
logger.info("Invoked holiday router inline activate for %s", date_str)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to invoke holiday router for %s", date_str)
|
|
||||||
|
|
||||||
|
|
||||||
def _holiday_schedule_names(date_str: str) -> tuple[str, str]:
|
def _holiday_schedule_names(date_str: str) -> tuple[str, str]:
|
||||||
"""The (activate, deactivate) one-off schedule names for a holiday date."""
|
"""The (activate, deactivate) one-off schedule names for a holiday date."""
|
||||||
compact = date_str.replace("-", "")
|
from shared.side_effects import holiday_schedule_names
|
||||||
return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}"
|
|
||||||
|
return holiday_schedule_names(date_str)
|
||||||
|
|
||||||
|
|
||||||
def _create_holiday_schedules(date_str: str) -> list[str]:
|
def _create_holiday_schedules(date_str: str) -> list[str]:
|
||||||
"""Create the two one-off EventBridge schedules for a holiday and return names.
|
"""Create the two one-off EventBridge schedules for a holiday and return names."""
|
||||||
|
from shared.side_effects import create_holiday_schedules
|
||||||
|
|
||||||
One schedule fires the holiday router's ``activate`` at 08:00 ET on the
|
return create_holiday_schedules(date_str)
|
||||||
date, the other its ``deactivate`` at 17:00 ET. Both use a flexible
|
|
||||||
one-time ``at(...)`` expression in ``America/New_York``,
|
|
||||||
``ActionAfterCompletion=DELETE`` (self-cleanup once fired), and target the
|
|
||||||
holiday-router Lambda via the passed scheduler execution role.
|
|
||||||
|
|
||||||
Returns the created schedule names (stored on the HOLIDAY record so a later
|
|
||||||
``remove`` can delete any that have not yet fired). Best-effort: returns the
|
|
||||||
names it managed to create; missing config short-circuits to ``[]``.
|
|
||||||
"""
|
|
||||||
router_arn = os.environ.get("HOLIDAY_ROUTER_ARN")
|
|
||||||
role_arn = os.environ.get("HOLIDAY_SCHEDULER_ROLE_ARN")
|
|
||||||
group = os.environ.get("HOLIDAY_SCHEDULE_GROUP", "default")
|
|
||||||
if not router_arn or not role_arn:
|
|
||||||
logger.warning(
|
|
||||||
"HOLIDAY_ROUTER_ARN/HOLIDAY_SCHEDULER_ROLE_ARN not set — "
|
|
||||||
"skipping schedule creation"
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
activate_name, deactivate_name = _holiday_schedule_names(date_str)
|
|
||||||
client = boto3.client("scheduler")
|
|
||||||
created: list[str] = []
|
|
||||||
specs = [
|
|
||||||
(activate_name, "activate", "08:00:00"),
|
|
||||||
(deactivate_name, "deactivate", "17:00:00"),
|
|
||||||
]
|
|
||||||
for name, action, at_time in specs:
|
|
||||||
try:
|
|
||||||
client.create_schedule(
|
|
||||||
Name=name,
|
|
||||||
GroupName=group,
|
|
||||||
ScheduleExpression=f"at({date_str}T{at_time})",
|
|
||||||
ScheduleExpressionTimezone="America/New_York",
|
|
||||||
FlexibleTimeWindow={"Mode": "OFF"},
|
|
||||||
ActionAfterCompletion="DELETE",
|
|
||||||
Target={
|
|
||||||
"Arn": router_arn,
|
|
||||||
"RoleArn": role_arn,
|
|
||||||
"Input": json.dumps({"action": action, "date": date_str}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
created.append(name)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to create %s schedule for %s", action, date_str)
|
|
||||||
return created
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_holiday_schedules(schedule_names: list[str]) -> None:
|
def _delete_holiday_schedules(schedule_names: list[str]) -> None:
|
||||||
|
|
@ -407,10 +346,10 @@ def _droppable_shifts(schedule, date_str, day_name, employee_ext) -> list[str]:
|
||||||
def _schedule_fallback_text() -> str:
|
def _schedule_fallback_text() -> str:
|
||||||
"""Notification fallback text for the two-week schedule post."""
|
"""Notification fallback text for the two-week schedule post."""
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
this_monday = now - timedelta(days=now.weekday())
|
start = week_start(now)
|
||||||
end_date = this_monday + timedelta(days=13)
|
end_date = start + timedelta(days=13)
|
||||||
return (
|
return (
|
||||||
f"After-Hours Schedule — {this_monday.strftime('%b %-d')} "
|
f"After-Hours Schedule — {start.strftime('%b %-d')} "
|
||||||
f"to {end_date.strftime('%b %-d')}"
|
f"to {end_date.strftime('%b %-d')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -703,25 +642,50 @@ def _show_schedule(respond, schedule):
|
||||||
|
|
||||||
def _show_next_week(respond, schedule):
|
def _show_next_week(respond, schedule):
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
# Jump 2 weeks ahead from this week's Monday
|
# The default view is already two weeks, so "next" starts two Sundays ahead.
|
||||||
this_monday = now - timedelta(days=now.weekday())
|
next_start = week_start(now) + timedelta(days=14)
|
||||||
next_start = this_monday + timedelta(days=14)
|
|
||||||
blocks = build_week_schedule(schedule, start_date=next_start)
|
blocks = build_week_schedule(schedule, start_date=next_start)
|
||||||
respond(blocks=blocks)
|
respond(blocks=blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def _pay_week_bounds(pay_record: dict) -> tuple[datetime, datetime]:
|
||||||
|
"""Label bounds for a pay record.
|
||||||
|
|
||||||
|
New rows are Sunday–Saturday. A cutover row may store a shorter
|
||||||
|
``window_start``/``window_end``. Legacy rows are Monday–Sunday via
|
||||||
|
``week_start`` plus six days.
|
||||||
|
"""
|
||||||
|
start = datetime.strptime(
|
||||||
|
pay_record.get("window_start") or pay_record["week_start"], "%Y-%m-%d"
|
||||||
|
)
|
||||||
|
if pay_record.get("window_end"):
|
||||||
|
end = datetime.strptime(pay_record["window_end"], "%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
end = start + timedelta(days=6)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
def _show_pay(respond, schedule):
|
def _show_pay(respond, schedule):
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
# Show last completed week's pay (previous Monday–Sunday)
|
# The Monday 7am close writes the Sun–Sat week that ended Saturday. On
|
||||||
this_monday = now - timedelta(days=now.weekday())
|
# Sunday, and on Monday before that close, that row is not written yet, so
|
||||||
prev_monday = this_monday - timedelta(days=7)
|
# also try the prior week's Sunday key and the legacy Monday keys.
|
||||||
week_key = prev_monday.strftime("%Y-%m-%d")
|
prev_start = week_start(now) - timedelta(days=7)
|
||||||
|
pay_record = None
|
||||||
pay_record = schedule.get_pay_record(week_key)
|
for start in (
|
||||||
|
prev_start,
|
||||||
|
prev_start + timedelta(days=1),
|
||||||
|
prev_start - timedelta(days=7),
|
||||||
|
prev_start - timedelta(days=6),
|
||||||
|
):
|
||||||
|
record = schedule.get_pay_record(start.strftime("%Y-%m-%d"))
|
||||||
|
if record and record.get("breakdown"):
|
||||||
|
pay_record = record
|
||||||
|
break
|
||||||
if pay_record and pay_record.get("breakdown"):
|
if pay_record and pay_record.get("breakdown"):
|
||||||
prev_sunday = prev_monday + timedelta(days=6)
|
label_start, label_end = _pay_week_bounds(pay_record)
|
||||||
week_label = (
|
week_label = (
|
||||||
f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
|
f"{label_start.strftime('%b %-d')} to {label_end.strftime('%b %-d')}"
|
||||||
)
|
)
|
||||||
blocks = build_pay_summary_blocks(
|
blocks = build_pay_summary_blocks(
|
||||||
week_label, pay_record["breakdown"], pay_record["totals"]
|
week_label, pay_record["breakdown"], pay_record["totals"]
|
||||||
|
|
@ -729,7 +693,7 @@ def _show_pay(respond, schedule):
|
||||||
respond(blocks=blocks)
|
respond(blocks=blocks)
|
||||||
else:
|
else:
|
||||||
respond(
|
respond(
|
||||||
text=f"No pay record found for the week of {prev_monday.strftime('%b %-d')}."
|
text=f"No pay record found for the week of {prev_start.strftime('%b %-d')}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2230,19 +2194,31 @@ def _admin_holiday_list(respond, schedule):
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=1)
|
@functools.lru_cache(maxsize=1)
|
||||||
def _changelog_text() -> str:
|
def _changelog_text() -> str:
|
||||||
"""Read the CHANGELOG shipped in this function's package.
|
"""Read the CHANGELOG shipped next to this module.
|
||||||
|
|
||||||
Lazy (never at import) and tolerant of a missing file, so the App Home tab
|
Lazy (never at import) and tolerant of a missing file, so the App Home tab
|
||||||
degrades to "no What's New section" rather than erroring. The copy lives at
|
degrades to "no What's New section" rather than erroring. Lambda zips and
|
||||||
``$LAMBDA_TASK_ROOT/CHANGELOG.md`` (synced from the repo root).
|
the Fargate image both keep ``CHANGELOG.md`` beside ``app.py``.
|
||||||
|
``LAMBDA_TASK_ROOT`` remains a fallback for the zip layout.
|
||||||
"""
|
"""
|
||||||
path = os.path.join(os.environ.get("LAMBDA_TASK_ROOT", "."), "CHANGELOG.md")
|
tried = []
|
||||||
try:
|
for path in _changelog_paths():
|
||||||
with open(path, encoding="utf-8") as fh:
|
tried.append(path)
|
||||||
return fh.read()
|
try:
|
||||||
except OSError:
|
with open(path, encoding="utf-8") as fh:
|
||||||
logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", path)
|
return fh.read()
|
||||||
return ""
|
except OSError:
|
||||||
|
continue
|
||||||
|
logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", tried)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _changelog_paths() -> list[str]:
|
||||||
|
paths = [os.path.join(os.path.dirname(os.path.abspath(__file__)), "CHANGELOG.md")]
|
||||||
|
task_root = os.environ.get("LAMBDA_TASK_ROOT", "").strip()
|
||||||
|
if task_root:
|
||||||
|
paths.append(os.path.join(task_root, "CHANGELOG.md"))
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
_HOME_OVERVIEW_DAYS = 60
|
_HOME_OVERVIEW_DAYS = 60
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Lambda handler — Slack Bolt app entry point."""
|
"""Lambda handler — Slack Bolt app entry point."""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
@ -33,4 +34,8 @@ def _get_handler() -> SlackRequestHandler:
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
def handler(event, context):
|
||||||
return _get_handler().handle(event, context)
|
try:
|
||||||
|
return _get_handler().handle(event, context)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("rejecting malformed slack request body")
|
||||||
|
return {"statusCode": 400, "body": "invalid request"}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
slack_bolt>=1.30.0,<2.0
|
slack_bolt>=1.30.0,<2.0
|
||||||
boto3>=1.43.82
|
boto3>=1.43.99
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Lambda handler — posts the weekly on-call schedule and previous week's pay summary
|
"""Lambda handler — posts the two-week on-call schedule and the previous
|
||||||
to Slack every Monday at 7am ET, and emails the pay summary to payroll."""
|
Sunday–Saturday pay summary to Slack every Monday at 7am ET, and enqueues
|
||||||
|
after-hours dollars for Flex."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -13,7 +14,7 @@ from slack_sdk import WebClient
|
||||||
|
|
||||||
import shared.sentry_init # noqa: F401
|
import shared.sentry_init # noqa: F401
|
||||||
from shared.blocks import build_pay_summary_blocks, build_week_schedule
|
from shared.blocks import build_pay_summary_blocks, build_week_schedule
|
||||||
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
|
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule, week_start
|
||||||
from shared.secrets import get_secret
|
from shared.secrets import get_secret
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
@ -22,16 +23,6 @@ logger.setLevel(logging.INFO)
|
||||||
EASTERN = ZoneInfo("America/New_York")
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
KIND_AFTER_HOURS = "after_hours"
|
KIND_AFTER_HOURS = "after_hours"
|
||||||
|
|
||||||
DAY_ORDER = [
|
|
||||||
"Monday",
|
|
||||||
"Tuesday",
|
|
||||||
"Wednesday",
|
|
||||||
"Thursday",
|
|
||||||
"Friday",
|
|
||||||
"Saturday",
|
|
||||||
"Sunday",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _record_pay_line(
|
def _record_pay_line(
|
||||||
breakdown, totals, *, date, day_label, name, ext, effective, base_rate, is_holiday
|
breakdown, totals, *, date, day_label, name, ext, effective, base_rate, is_holiday
|
||||||
|
|
@ -136,15 +127,20 @@ def _add_shift_to_pay(schedule, breakdown, totals, date, day_name, shift_type="n
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict:
|
def _calculate_weekly_pay(schedule: ShiftSchedule, week_start_dt: datetime) -> dict:
|
||||||
"""Calculate pay for a Mon–Sun week. Returns pay record dict."""
|
"""Calculate pay for a Sun–Sat week. Returns pay record dict.
|
||||||
|
|
||||||
|
``week_start_dt`` is the Sunday that opens the week. Each shift is attributed
|
||||||
|
by its start date, so Saturday night stays in this week and the following
|
||||||
|
Sunday does not.
|
||||||
|
"""
|
||||||
default_rate = schedule.get_shift_rate()
|
default_rate = schedule.get_shift_rate()
|
||||||
breakdown = []
|
breakdown = []
|
||||||
totals = {}
|
totals = {}
|
||||||
|
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
date = week_start + timedelta(days=i)
|
date = week_start_dt + timedelta(days=i)
|
||||||
day_name = DAY_ORDER[i]
|
day_name = date.strftime("%A")
|
||||||
date_str = date.strftime("%Y-%m-%d")
|
date_str = date.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
# Day shifts (8am–5pm) exist on weekends and on holidays. Holidays are
|
# Day shifts (8am–5pm) exist on weekends and on holidays. Holidays are
|
||||||
|
|
@ -157,103 +153,104 @@ def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict
|
||||||
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "night")
|
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "night")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"week_start": week_start.strftime("%Y-%m-%d"),
|
"week_start": week_start_dt.strftime("%Y-%m-%d"),
|
||||||
"default_rate": str(default_rate),
|
"default_rate": str(default_rate),
|
||||||
"breakdown": breakdown,
|
"breakdown": breakdown,
|
||||||
"totals": totals,
|
"totals": totals,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_pay_email_html(week_label: str, pay_record: dict) -> str:
|
def _already_sent_dates(schedule: ShiftSchedule, week_key: str) -> set[str]:
|
||||||
"""Build an HTML email body for the weekly pay summary.
|
"""Dates already included in some other pay row that Flex has received.
|
||||||
|
|
||||||
Holiday shifts are rendered distinctly: a per-shift breakdown section
|
The current week's own row is ignored so a retry can rebuild it.
|
||||||
highlights holiday rows (shaded, showing the multiplier and label) and the
|
|
||||||
totals table flags any employee who worked a holiday during the week.
|
|
||||||
"""
|
"""
|
||||||
breakdown = pay_record.get("breakdown", [])
|
sent: set[str] = set()
|
||||||
totals = pay_record.get("totals", {})
|
for record in schedule.list_pay_records():
|
||||||
|
if record.get("SK") == week_key:
|
||||||
|
continue
|
||||||
|
if not record.get("checkcomponents_sent"):
|
||||||
|
continue
|
||||||
|
for line in record.get("breakdown") or []:
|
||||||
|
date = line.get("date")
|
||||||
|
if date:
|
||||||
|
sent.add(str(date))
|
||||||
|
return sent
|
||||||
|
|
||||||
holiday_names = {line["name"] for line in breakdown if line.get("is_holiday")}
|
|
||||||
|
|
||||||
totals_rows = ""
|
def _rebuild_totals(breakdown: list[dict]) -> dict:
|
||||||
for name, info in sorted(totals.items()):
|
totals: dict = {}
|
||||||
holiday_tag = (
|
|
||||||
' <span style="color:#b8860b;">★ holiday</span>'
|
|
||||||
if name in holiday_names
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
totals_rows += (
|
|
||||||
f"<tr><td>{name}{holiday_tag}</td>"
|
|
||||||
f"<td>${info.get('rate', 0):.2f}</td><td><strong>${info['total']:.2f}</strong></td></tr>\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
breakdown_rows = ""
|
|
||||||
for line in breakdown:
|
for line in breakdown:
|
||||||
|
name = line["name"]
|
||||||
|
if name not in totals:
|
||||||
|
totals[name] = {
|
||||||
|
"shifts": 0,
|
||||||
|
"total": Decimal("0"),
|
||||||
|
"extension": line["extension"],
|
||||||
|
"rate": line.get("base_rate", line["rate"]),
|
||||||
|
"holiday_shifts": 0,
|
||||||
|
}
|
||||||
|
totals[name]["shifts"] += 1
|
||||||
|
totals[name]["total"] += Decimal(str(line["amount"]))
|
||||||
if line.get("is_holiday"):
|
if line.get("is_holiday"):
|
||||||
mult = line.get("multiplier", Decimal("1"))
|
totals[name]["holiday_shifts"] += 1
|
||||||
label = line.get("holiday_label", "") or "Holiday"
|
return totals
|
||||||
base = line.get("base_rate", line["rate"])
|
|
||||||
row_style = ' style="background:#fff8e1;"'
|
|
||||||
detail = f"{label} — ${base:.2f} × {mult:.2f}x"
|
def _exclude_sent_dates(
|
||||||
|
schedule: ShiftSchedule, pay_record: dict, week_key: str
|
||||||
|
) -> dict:
|
||||||
|
"""Drop dates Flex already received, and shrink the reported window.
|
||||||
|
|
||||||
|
The PAY key stays the Sunday that opened the computed week. When the
|
||||||
|
leading Sunday (or any other day) was in a sent Monday–Sunday row, the
|
||||||
|
Flex window becomes the remaining span, often Monday–Saturday once.
|
||||||
|
"""
|
||||||
|
sent = _already_sent_dates(schedule, week_key)
|
||||||
|
if not sent:
|
||||||
|
return pay_record
|
||||||
|
start = datetime.strptime(pay_record["week_start"], "%Y-%m-%d").date()
|
||||||
|
kept_days = []
|
||||||
|
omitted = False
|
||||||
|
for offset in range(7):
|
||||||
|
day = start + timedelta(days=offset)
|
||||||
|
if day.isoformat() in sent:
|
||||||
|
omitted = True
|
||||||
else:
|
else:
|
||||||
row_style = ""
|
kept_days.append(day)
|
||||||
detail = f"${line['rate']:.2f}"
|
if not omitted:
|
||||||
breakdown_rows += (
|
return pay_record
|
||||||
f"<tr{row_style}><td>{line['date_label']}</td>"
|
pay_record["breakdown"] = [
|
||||||
f"<td>{line['day']}</td>"
|
line for line in pay_record["breakdown"] if str(line.get("date")) not in sent
|
||||||
f"<td>{line['name']}</td>"
|
]
|
||||||
f"<td>{detail}</td>"
|
pay_record["totals"] = _rebuild_totals(pay_record["breakdown"])
|
||||||
f"<td><strong>${line['amount']:.2f}</strong></td></tr>\n"
|
if kept_days and len(kept_days) < 7:
|
||||||
)
|
pay_record["window_start"] = kept_days[0].isoformat()
|
||||||
|
pay_record["window_end"] = kept_days[-1].isoformat()
|
||||||
return f"""<html>
|
return pay_record
|
||||||
<body style="font-family: Arial, sans-serif; color: #333;">
|
|
||||||
<h2>Bonus Pay Summary — {week_label}</h2>
|
|
||||||
|
|
||||||
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
|
|
||||||
<tr style="background: #f0f0f0;"><th>Name</th><th>Rate</th><th>Total</th></tr>
|
|
||||||
{totals_rows}</table>
|
|
||||||
|
|
||||||
<h3>Shift Breakdown</h3>
|
|
||||||
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
|
|
||||||
<tr style="background: #f0f0f0;"><th>Date</th><th>Day</th><th>Name</th><th>Detail</th><th>Amount</th></tr>
|
|
||||||
{breakdown_rows}</table>
|
|
||||||
<p style="color: #888; font-size: 12px;">Holiday shifts are shaded and paid at the listed multiplier.</p>
|
|
||||||
|
|
||||||
<p style="color: #888; font-size: 12px;">This is an automated report from Sea Haven Industries.</p>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
|
|
||||||
def _send_pay_email(week_label: str, pay_record: dict) -> None:
|
def _pay_week_label(pay_record: dict) -> str:
|
||||||
"""Send the weekly pay summary email via SES."""
|
start = datetime.strptime(
|
||||||
sender = os.environ.get("SES_SENDER", "noreply@seahaven.com")
|
pay_record.get("window_start") or pay_record["week_start"], "%Y-%m-%d"
|
||||||
recipients = os.environ.get("PAYROLL_RECIPIENTS", "").split(",")
|
|
||||||
recipients = [r.strip() for r in recipients if r.strip()]
|
|
||||||
|
|
||||||
if not recipients:
|
|
||||||
logger.warning("No PAYROLL_RECIPIENTS configured — skipping email")
|
|
||||||
return
|
|
||||||
|
|
||||||
ses = boto3.client("ses")
|
|
||||||
html_body = _build_pay_email_html(week_label, pay_record)
|
|
||||||
|
|
||||||
ses.send_email(
|
|
||||||
Source=sender,
|
|
||||||
Destination={"ToAddresses": recipients},
|
|
||||||
Message={
|
|
||||||
"Subject": {"Data": f"Bonus Pay Summary — {week_label}"},
|
|
||||||
"Body": {"Html": {"Data": html_body}},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
logger.info("Sent pay email to %s", recipients)
|
if pay_record.get("window_end"):
|
||||||
|
end = datetime.strptime(pay_record["window_end"], "%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
end = start + timedelta(days=6)
|
||||||
|
return f"{start.strftime('%b %-d')} to {end.strftime('%b %-d')}"
|
||||||
|
|
||||||
|
|
||||||
def build_checkcomponents_payload(pay_record: dict) -> dict:
|
def build_checkcomponents_payload(pay_record: dict) -> dict:
|
||||||
"""Sibling dollar lines only. No Flex OAuth, no payPeriodId invention."""
|
"""Sibling dollar lines only. No Flex OAuth, no payPeriodId invention."""
|
||||||
week_start = datetime.strptime(pay_record["week_start"], "%Y-%m-%d").date()
|
week_start_date = datetime.strptime(pay_record["week_start"], "%Y-%m-%d").date()
|
||||||
window_end = week_start + timedelta(days=6)
|
window_end = week_start_date + timedelta(days=6)
|
||||||
|
if pay_record.get("window_start"):
|
||||||
|
week_start_date = datetime.strptime(
|
||||||
|
pay_record["window_start"], "%Y-%m-%d"
|
||||||
|
).date()
|
||||||
|
if pay_record.get("window_end"):
|
||||||
|
window_end = datetime.strptime(pay_record["window_end"], "%Y-%m-%d").date()
|
||||||
lines = []
|
lines = []
|
||||||
for info in pay_record.get("totals", {}).values():
|
for info in pay_record.get("totals", {}).values():
|
||||||
ext = str(info.get("extension") or "").strip()
|
ext = str(info.get("extension") or "").strip()
|
||||||
|
|
@ -271,7 +268,7 @@ def build_checkcomponents_payload(pay_record: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
"type": "checkcomponents",
|
"type": "checkcomponents",
|
||||||
"kind": KIND_AFTER_HOURS,
|
"kind": KIND_AFTER_HOURS,
|
||||||
"windowStart": week_start.isoformat(),
|
"windowStart": week_start_date.isoformat(),
|
||||||
"windowEnd": window_end.isoformat(),
|
"windowEnd": window_end.isoformat(),
|
||||||
"lines": lines,
|
"lines": lines,
|
||||||
}
|
}
|
||||||
|
|
@ -292,6 +289,10 @@ def _send_checkcomponents(pay_record: dict) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def handler(event, context):
|
def handler(event, context):
|
||||||
|
if os.environ.get("STAGE", "prod") != "prod":
|
||||||
|
logger.info("Skipping weekly post because STAGE is not prod")
|
||||||
|
return {"skipped": "non_prod"}
|
||||||
|
|
||||||
now = datetime.now(EASTERN)
|
now = datetime.now(EASTERN)
|
||||||
|
|
||||||
# DST guard — same pattern as the 3CX scheduler
|
# DST guard — same pattern as the 3CX scheduler
|
||||||
|
|
@ -308,24 +309,22 @@ def handler(event, context):
|
||||||
schedule = ShiftSchedule()
|
schedule = ShiftSchedule()
|
||||||
slack = WebClient(token=bot_token)
|
slack = WebClient(token=bot_token)
|
||||||
|
|
||||||
# --- Previous week's pay summary ---
|
# --- Previous completed Sun–Sat pay summary ---
|
||||||
prev_monday = now - timedelta(days=7)
|
# Monday 7am is after Saturday night ends (Sunday 8am), so this week is closed.
|
||||||
prev_monday = prev_monday.replace(hour=0, minute=0, second=0, microsecond=0)
|
prev_sunday = week_start(now) - timedelta(days=7)
|
||||||
pay_record = _calculate_weekly_pay(schedule, prev_monday)
|
pay_record = _calculate_weekly_pay(schedule, prev_sunday)
|
||||||
|
|
||||||
pay_dm_user = os.environ.get("PAY_REPORT_USER")
|
pay_dm_user = os.environ.get("PAY_REPORT_USER")
|
||||||
|
|
||||||
week_key = prev_monday.strftime("%Y-%m-%d")
|
week_key = prev_sunday.strftime("%Y-%m-%d")
|
||||||
|
pay_record = _exclude_sent_dates(schedule, pay_record, week_key)
|
||||||
if pay_record["breakdown"]:
|
if pay_record["breakdown"]:
|
||||||
existing = schedule.get_pay_record(week_key)
|
existing = schedule.get_pay_record(week_key)
|
||||||
if existing and existing.get("checkcomponents_sent"):
|
if existing and existing.get("checkcomponents_sent"):
|
||||||
pay_record["checkcomponents_sent"] = True
|
pay_record["checkcomponents_sent"] = True
|
||||||
schedule.save_pay_record(week_key, pay_record)
|
schedule.save_pay_record(week_key, pay_record)
|
||||||
|
|
||||||
prev_sunday = prev_monday + timedelta(days=6)
|
week_label = _pay_week_label(pay_record)
|
||||||
week_label = (
|
|
||||||
f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
|
|
||||||
)
|
|
||||||
pay_blocks = build_pay_summary_blocks(
|
pay_blocks = build_pay_summary_blocks(
|
||||||
week_label, pay_record["breakdown"], pay_record["totals"]
|
week_label, pay_record["breakdown"], pay_record["totals"]
|
||||||
)
|
)
|
||||||
|
|
@ -343,25 +342,14 @@ def handler(event, context):
|
||||||
else:
|
else:
|
||||||
logger.warning("PAY_REPORT_USER not set — skipping Slack pay summary")
|
logger.warning("PAY_REPORT_USER not set — skipping Slack pay summary")
|
||||||
|
|
||||||
# Email pay summary to payroll. Isolated so a delivery failure (e.g.
|
# --- Two-week schedule (starts on Sunday of this Sun–Sat week) ---
|
||||||
# an SES permission/identity issue) can never abort the rest of the
|
this_sunday = week_start(now)
|
||||||
# handler — the Slack schedule post below must still go out.
|
schedule_week = this_sunday.strftime("%Y-%m-%d")
|
||||||
try:
|
blocks = build_week_schedule(schedule, start_date=this_sunday)
|
||||||
_send_pay_email(week_label, pay_record)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to send pay summary email to payroll for week of %s",
|
|
||||||
week_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Two-week schedule (always starts on Monday of this week) ---
|
end_date = this_sunday + timedelta(days=13)
|
||||||
this_monday = now - timedelta(days=now.weekday())
|
|
||||||
week_start = this_monday.strftime("%Y-%m-%d")
|
|
||||||
blocks = build_week_schedule(schedule, start_date=this_monday)
|
|
||||||
|
|
||||||
end_date = this_monday + timedelta(days=13)
|
|
||||||
fallback_text = (
|
fallback_text = (
|
||||||
f"After-Hours Schedule — {this_monday.strftime('%b %-d')} "
|
f"After-Hours Schedule — {this_sunday.strftime('%b %-d')} "
|
||||||
f"to {end_date.strftime('%b %-d')}"
|
f"to {end_date.strftime('%b %-d')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -397,7 +385,7 @@ def handler(event, context):
|
||||||
# deleted) ts, no-op its delete, and post a *second* schedule, orphaning
|
# deleted) ts, no-op its delete, and post a *second* schedule, orphaning
|
||||||
# this one at the bottom of the channel.
|
# this one at the bottom of the channel.
|
||||||
try:
|
try:
|
||||||
schedule.save_schedule_post(channel_id, message_ts, week_start)
|
schedule.save_schedule_post(channel_id, message_ts, schedule_week)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to persist schedule post %s; rolling it back to avoid an "
|
"Failed to persist schedule post %s; rolling it back to avoid an "
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
boto3>=1.43.82
|
boto3>=1.43.99
|
||||||
slack_sdk>=3.44.0,<4.0
|
slack_sdk>=3.44.1,<4.0
|
||||||
|
|
|
||||||
1032
template.yaml
1032
template.yaml
File diff suppressed because it is too large
Load diff
61
terraform/.terraform.lock.hcl
generated
Normal file
61
terraform/.terraform.lock.hcl
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# This file is maintained automatically by "terraform init".
|
||||||
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
|
provider "registry.terraform.io/hashicorp/archive" {
|
||||||
|
version = "2.8.1"
|
||||||
|
constraints = "~> 2.8"
|
||||||
|
hashes = [
|
||||||
|
"h1:aLNmq6dc3cDcqZc8s/8eKtn0I+UQXyJMGrmo4rRFtNw=",
|
||||||
|
"zh:03de290604114a89fcd45c2e5bc7787d5a1ebfc5f964fb5989306bea7a4c79ec",
|
||||||
|
"zh:0a7d69dc9fbbc48960bc2f04588c8fb1bd92c78a8f306566b7fb17fc4a4f2058",
|
||||||
|
"zh:4df1f3981379c35f1757da957470f7f7724496b57219da3485177cf1647bdf59",
|
||||||
|
"zh:50f0e72ba53bfe6e11b03b7fc899e1f72536354381d302a743a274ae2a3f45f4",
|
||||||
|
"zh:5c4e15a04c98e2a8cafb1cd9632b48ad318051e8462d105b1153006277985c35",
|
||||||
|
"zh:66069e604bcf5c4af0278e15997d9e6bd755c54fb3801d78885838b889729c5f",
|
||||||
|
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||||
|
"zh:979765db3f42601870ab377104ba70befb029547b278337ec4ada980e3582de6",
|
||||||
|
"zh:b165254da774f49945a73fbccc3ef1b63d70ea00a98fa9e14716665ba80ecaad",
|
||||||
|
"zh:c0bb2697b525da9fec4511f569ed2bd2b42f25d5c1f9fa00f8f3645b50cfc2e9",
|
||||||
|
"zh:c48f6695d12df0d0fa5231f7c1b8d518a4feae91a733fdd35a5804af3a303831",
|
||||||
|
"zh:d589954c93f075180c9f4e2ce91780d5edcb56dfd0d3cda8ba08e13c10b52249",
|
||||||
|
"zh:f5792ed06da65d0daf7ca3711f5399ff78c7cb4400afe53fbce9a926fbde4477",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "registry.terraform.io/hashicorp/aws" {
|
||||||
|
version = "6.66.0"
|
||||||
|
constraints = "~> 6.66"
|
||||||
|
hashes = [
|
||||||
|
"h1:/yJhdVJVyi5k1KA4z1lDoYWQlTOPjR3NIAh/v4cLgLo=",
|
||||||
|
"h1:5COqw8J20qkGI2ao1u8RTTguYEgiE3LJSbToD5fYUg4=",
|
||||||
|
"h1:5t1vkYwqDYRN27RliDkyWRmQfQCnNHFqWxC2UDVCK78=",
|
||||||
|
"h1:7Qtd6MjgS/ymociMyFCG9EKK6Jy5kGOy7EfXngFwsl4=",
|
||||||
|
"h1:LgU7nnuiiD9m9YuYBNMArIgHex/RZqe4VFevYb/1kJU=",
|
||||||
|
"h1:OnLj4nhqJnEcUzyyRKUjp1FgWG00Y8maikJEYSf9Zjw=",
|
||||||
|
"h1:RhHqC2ugIjXrVhSu/Q6WYd/WOjjQ6rH27bh7LZuIW1w=",
|
||||||
|
"h1:Rx4Ktpqk2eSvoPEIWB240IC67BkQxEXGmY/eRu6PIGk=",
|
||||||
|
"h1:S8gYRM7I6/ufvF4dhBaAAGHm3f3+FKBUnEKR93Wcprs=",
|
||||||
|
"h1:ZbkpwuEfpWTZDKdJnEZSDKN5tGEQTUYRkKuK6Cz2wcc=",
|
||||||
|
"h1:c9A3yNQ0xB0wlJfG1XK3pfEHOEYx3HyJaQ56WnNv190=",
|
||||||
|
"h1:cXBw4chYKvv6XlSvyVGAfSGKCAXwC0/fOAuq7xv7hME=",
|
||||||
|
"h1:hBEaeBm9nm7A/u1nnD0nfolTPP55/BoKRFWk8zG8/fk=",
|
||||||
|
"h1:mIolsCn33slp3F7Zd4KCTScXAWuUQsjtIzA/a6TFG6Q=",
|
||||||
|
"h1:xehZnyesOrJ1/R9tmnRSu7FRkwoDDKelEHI3WdnJ72g=",
|
||||||
|
"zh:156fe7164a3d26ef6b35734c43e99fb198df90575ed897d1182b8e930b8cd523",
|
||||||
|
"zh:1af52b22b35be00f8d16e3ebebff9fa699ec4db2ef69e6032ba5c536f80c03d9",
|
||||||
|
"zh:2545a8478bd551fdc9694f6cc1a1ad24617f6736f8bde0ad6cae90987c65380f",
|
||||||
|
"zh:4070db1ee369ccb41cb610bfd887386bc0a9b9ecad60aeb4dbce58443d2519dd",
|
||||||
|
"zh:53da7d3c1840ef875c7d34e967732502a64fe677af0e78824773d4c15a8fe740",
|
||||||
|
"zh:576a93a28bf611a4de2a2e6ced697a41d5126b8fd31d30782b16797e410a9706",
|
||||||
|
"zh:58fed5fa9a033355b9d4f3092c817b70d934100e0d8678d6e4c93f3c9493d4e4",
|
||||||
|
"zh:6a9ca2f24e2ee9156dd785d159a850b35d190e9cf7eca21cb9582970c2db80cd",
|
||||||
|
"zh:729edd30f99cc16009deba5c013265b0c81eda261a3d0821cbd011d3287fd230",
|
||||||
|
"zh:7ae460049b75bd4aefee465ef7c53a01ac2df46d4d3e3ac00824afa8b5cb83fb",
|
||||||
|
"zh:9051fa85c8034ade8a57a5c6f232fd33da28f3800bb5aa40bc8625dbc5e27632",
|
||||||
|
"zh:906547e4319805e7acf7fbdf2bac28a4b1a7370790a2a430c7adb1b29bb934eb",
|
||||||
|
"zh:998f27410a66158a35ee5ed142c27e5b21fe8601941da55da2157f8042d6dcca",
|
||||||
|
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||||
|
"zh:9c1804eff1dda0446dc2d215231015bb65a2fc6c3b7ba24584fe45f1ddd3fa9f",
|
||||||
|
"zh:b03ff5efdee310502aaaeb460144dc059bce72a0d8217e6b989099ef8aef9283",
|
||||||
|
]
|
||||||
|
}
|
||||||
14
terraform/acm.tf
Normal file
14
terraform/acm.tf
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# ACM certificate for ALB HTTPS. Lookup only; do not mint. The issued wildcard
|
||||||
|
# already exists in the account (portal pattern).
|
||||||
|
#
|
||||||
|
# Bootstrap order if the listener is ever rebuilt from nothing:
|
||||||
|
# 1. Confirm an ISSUED certificate for local.acm_wildcard_domain exists.
|
||||||
|
# 2. Set attach_custom_domain=true and apply. Until the lookup finds ISSUED,
|
||||||
|
# the plan fails closed.
|
||||||
|
|
||||||
|
data "aws_acm_certificate" "wildcard" {
|
||||||
|
count = var.attach_custom_domain ? 1 : 0
|
||||||
|
domain = local.acm_wildcard_domain
|
||||||
|
statuses = ["ISSUED"]
|
||||||
|
most_recent = true
|
||||||
|
}
|
||||||
79
terraform/alarms.tf
Normal file
79
terraform/alarms.tf
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "ddb_read_throttle" {
|
||||||
|
alarm_name = "DDB-ReadThrottle-${local.table_name}"
|
||||||
|
alarm_description = "afterhours-shifts table had one or more read throttle events"
|
||||||
|
namespace = "AWS/DynamoDB"
|
||||||
|
metric_name = "ReadThrottleEvents"
|
||||||
|
dimensions = { TableName = aws_dynamodb_table.shifts.name }
|
||||||
|
statistic = "Sum"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = 1
|
||||||
|
threshold = 0
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [local.site_alerts_arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "ddb_write_throttle" {
|
||||||
|
alarm_name = "DDB-WriteThrottle-${local.table_name}"
|
||||||
|
alarm_description = "afterhours-shifts table had one or more write throttle events"
|
||||||
|
namespace = "AWS/DynamoDB"
|
||||||
|
metric_name = "WriteThrottleEvents"
|
||||||
|
dimensions = { TableName = aws_dynamodb_table.shifts.name }
|
||||||
|
statistic = "Sum"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = 1
|
||||||
|
threshold = 0
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [local.site_alerts_arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
|
||||||
|
alarm_name = "ALB-5xx-${local.project}"
|
||||||
|
alarm_description = "ALB 5xx from afterhours-shift-manager"
|
||||||
|
namespace = "AWS/ApplicationELB"
|
||||||
|
metric_name = "HTTPCode_Target_5XX_Count"
|
||||||
|
dimensions = { LoadBalancer = aws_lb.api.arn_suffix }
|
||||||
|
statistic = "Sum"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = 1
|
||||||
|
threshold = 0
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [local.site_alerts_arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "alb_latency" {
|
||||||
|
alarm_name = "ALB-Latency-${local.project}"
|
||||||
|
alarm_description = "p99 target response time on the afterhours ALB exceeded 3s"
|
||||||
|
namespace = "AWS/ApplicationELB"
|
||||||
|
metric_name = "TargetResponseTime"
|
||||||
|
dimensions = { LoadBalancer = aws_lb.api.arn_suffix }
|
||||||
|
extended_statistic = "p99"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = 3
|
||||||
|
datapoints_to_alarm = 2
|
||||||
|
threshold = 3
|
||||||
|
comparison_operator = "GreaterThanOrEqualToThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [local.site_alerts_arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "alb_unhealthy_hosts" {
|
||||||
|
alarm_name = "ALB-UnhealthyHost-${local.project}"
|
||||||
|
alarm_description = "Unhealthy Fargate targets on the afterhours ALB"
|
||||||
|
namespace = "AWS/ApplicationELB"
|
||||||
|
metric_name = "UnHealthyHostCount"
|
||||||
|
dimensions = {
|
||||||
|
LoadBalancer = aws_lb.api.arn_suffix
|
||||||
|
TargetGroup = aws_lb_target_group.api.arn_suffix
|
||||||
|
}
|
||||||
|
statistic = "Maximum"
|
||||||
|
period = 60
|
||||||
|
evaluation_periods = 3
|
||||||
|
datapoints_to_alarm = 3
|
||||||
|
threshold = 0
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [local.site_alerts_arn]
|
||||||
|
}
|
||||||
118
terraform/artifacts.tf
Normal file
118
terraform/artifacts.tf
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
# Lambda artifacts bucket. Terraform ships only the bootstrap stub.
|
||||||
|
# .github/workflows/deploy.yaml uploads functions/<name>/<sha>.zip and calls
|
||||||
|
# update-function-code. Functions ignore code attributes afterwards.
|
||||||
|
|
||||||
|
resource "aws_s3_bucket" "artifacts" {
|
||||||
|
bucket = local.artifacts_bucket_name
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Purpose = "Lambda deployment packages for afterhours-shift-manager"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_public_access_block" "artifacts" {
|
||||||
|
bucket = aws_s3_bucket.artifacts.id
|
||||||
|
|
||||||
|
block_public_acls = true
|
||||||
|
block_public_policy = true
|
||||||
|
ignore_public_acls = true
|
||||||
|
restrict_public_buckets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_ownership_controls" "artifacts" {
|
||||||
|
bucket = aws_s3_bucket.artifacts.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
object_ownership = "BucketOwnerEnforced"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
|
||||||
|
bucket = aws_s3_bucket.artifacts.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
apply_server_side_encryption_by_default {
|
||||||
|
sse_algorithm = "AES256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_versioning" "artifacts" {
|
||||||
|
bucket = aws_s3_bucket.artifacts.id
|
||||||
|
|
||||||
|
versioning_configuration {
|
||||||
|
status = "Enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_lifecycle_configuration" "artifacts" {
|
||||||
|
bucket = aws_s3_bucket.artifacts.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
id = "expire-noncurrent-packages"
|
||||||
|
status = "Enabled"
|
||||||
|
|
||||||
|
filter {}
|
||||||
|
|
||||||
|
noncurrent_version_expiration {
|
||||||
|
noncurrent_days = 180
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rule {
|
||||||
|
id = "abort-incomplete-multipart"
|
||||||
|
status = "Enabled"
|
||||||
|
|
||||||
|
filter {}
|
||||||
|
|
||||||
|
abort_incomplete_multipart_upload {
|
||||||
|
days_after_initiation = 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [aws_s3_bucket_versioning.artifacts]
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "artifacts" {
|
||||||
|
statement {
|
||||||
|
sid = "DenyInsecureTransport"
|
||||||
|
effect = "Deny"
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "*"
|
||||||
|
identifiers = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
actions = ["s3:*"]
|
||||||
|
resources = [
|
||||||
|
aws_s3_bucket.artifacts.arn,
|
||||||
|
"${aws_s3_bucket.artifacts.arn}/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "Bool"
|
||||||
|
variable = "aws:SecureTransport"
|
||||||
|
values = ["false"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_policy" "artifacts" {
|
||||||
|
bucket = aws_s3_bucket.artifacts.id
|
||||||
|
policy = data.aws_iam_policy_document.artifacts.json
|
||||||
|
|
||||||
|
depends_on = [aws_s3_bucket_public_access_block.artifacts]
|
||||||
|
}
|
||||||
|
|
||||||
|
data "archive_file" "bootstrap_stub" {
|
||||||
|
type = "zip"
|
||||||
|
source_dir = "${path.module}/bootstrap/stub"
|
||||||
|
output_path = "${path.module}/build/packages/bootstrap-stub.zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_object" "bootstrap_stub" {
|
||||||
|
bucket = aws_s3_bucket.artifacts.id
|
||||||
|
key = "functions/bootstrap-stub.zip"
|
||||||
|
content_base64 = filebase64(data.archive_file.bootstrap_stub.output_path)
|
||||||
|
source_hash = data.archive_file.bootstrap_stub.output_base64sha256
|
||||||
|
}
|
||||||
9
terraform/bootstrap/stub/handler.py
Normal file
9
terraform/bootstrap/stub/handler.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""Bootstrap stub. GitHub Actions replaces this zip via update-function-code."""
|
||||||
|
|
||||||
|
|
||||||
|
def handler(event, context):
|
||||||
|
return {
|
||||||
|
"statusCode": 503,
|
||||||
|
"headers": {"content-type": "application/json"},
|
||||||
|
"body": '{"error":{"code":"NOT_DEPLOYED","message":"Function code has not been deployed yet."}}',
|
||||||
|
}
|
||||||
35
terraform/data.tf
Normal file
35
terraform/data.tf
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
data "aws_caller_identity" "current" {}
|
||||||
|
|
||||||
|
# Resource names in locals.tf embed the account ID. If the workspace is ever
|
||||||
|
# pointed at another account, fail the plan here rather than creating a parallel
|
||||||
|
# set of oddly-named resources somewhere else.
|
||||||
|
check "correct_account" {
|
||||||
|
assert {
|
||||||
|
condition = data.aws_caller_identity.current.account_id == local.account_id
|
||||||
|
error_message = "This configuration targets account ${local.account_id} (${var.environment}), but the credentials resolve to ${data.aws_caller_identity.current.account_id}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check "dev_has_no_external_side_effects" {
|
||||||
|
assert {
|
||||||
|
condition = local.is_prod || alltrue([
|
||||||
|
for name in ["SHIFT_CHANNEL", "QUEUE_NUMBER", "PAY_REPORT_USER", "TCX_SECRET_PREFIX"] :
|
||||||
|
one([for env in local.api_environment : env.value if env.name == name]) == ""
|
||||||
|
])
|
||||||
|
error_message = "Non-prod must leave SHIFT_CHANNEL, QUEUE_NUMBER, PAY_REPORT_USER, and TCX_SECRET_PREFIX empty so the task cannot post to Slack or move the production phone queue."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check "dev_has_no_paychex" {
|
||||||
|
assert {
|
||||||
|
condition = local.is_prod || var.checkcomponents_queue_url == ""
|
||||||
|
error_message = "checkcomponents_queue_url must be empty in non-prod so weekly_post cannot send to the prod Paychex queue."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check "checkcomponents_pair" {
|
||||||
|
assert {
|
||||||
|
condition = (var.checkcomponents_queue_url == "") == (var.checkcomponents_queue_arn == "")
|
||||||
|
error_message = "checkcomponents_queue_url and checkcomponents_queue_arn must both be set or both be empty."
|
||||||
|
}
|
||||||
|
}
|
||||||
21
terraform/dynamodb.tf
Normal file
21
terraform/dynamodb.tf
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
resource "aws_dynamodb_table" "shifts" {
|
||||||
|
name = local.table_name
|
||||||
|
billing_mode = "PAY_PER_REQUEST"
|
||||||
|
hash_key = "PK"
|
||||||
|
range_key = "SK"
|
||||||
|
|
||||||
|
attribute {
|
||||||
|
name = "PK"
|
||||||
|
type = "S"
|
||||||
|
}
|
||||||
|
|
||||||
|
attribute {
|
||||||
|
name = "SK"
|
||||||
|
type = "S"
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl {
|
||||||
|
attribute_name = "expires_at"
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
293
terraform/ecs.tf
Normal file
293
terraform/ecs.tf
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
# Always-on afterhours API: Fargate behind an ALB. GitHub Actions owns the
|
||||||
|
# image; Terraform ignores container_definitions after the bootstrap task
|
||||||
|
# definition. Dual-run with API Gateway until the Fargate cutover.
|
||||||
|
|
||||||
|
resource "aws_ecr_repository" "api" {
|
||||||
|
name = local.project
|
||||||
|
image_tag_mutability = "MUTABLE"
|
||||||
|
force_delete = !local.is_prod
|
||||||
|
|
||||||
|
image_scanning_configuration {
|
||||||
|
scan_on_push = true
|
||||||
|
}
|
||||||
|
|
||||||
|
encryption_configuration {
|
||||||
|
encryption_type = "AES256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecr_lifecycle_policy" "api" {
|
||||||
|
repository = aws_ecr_repository.api.name
|
||||||
|
|
||||||
|
policy = jsonencode({
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
rulePriority = 1
|
||||||
|
description = "Keep the last 20 images"
|
||||||
|
selection = {
|
||||||
|
tagStatus = "any"
|
||||||
|
countType = "imageCountMoreThan"
|
||||||
|
countNumber = 20
|
||||||
|
}
|
||||||
|
action = {
|
||||||
|
type = "expire"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_security_group" "alb" {
|
||||||
|
name = "${local.project}-alb"
|
||||||
|
description = "Public ALB for afterhours-shift-manager"
|
||||||
|
vpc_id = aws_vpc.this.id
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
description = "HTTP from the internet (health and pre-DNS)"
|
||||||
|
from_port = 80
|
||||||
|
to_port = 80
|
||||||
|
protocol = "tcp"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "ingress" {
|
||||||
|
for_each = var.attach_custom_domain ? [1] : []
|
||||||
|
content {
|
||||||
|
description = "HTTPS from the internet"
|
||||||
|
from_port = 443
|
||||||
|
to_port = 443
|
||||||
|
protocol = "tcp"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
egress {
|
||||||
|
from_port = 0
|
||||||
|
to_port = 0
|
||||||
|
protocol = "-1"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_security_group" "api" {
|
||||||
|
name = "${local.project}-api"
|
||||||
|
description = "Fargate tasks for afterhours-shift-manager"
|
||||||
|
vpc_id = aws_vpc.this.id
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
description = "From ALB"
|
||||||
|
from_port = 8080
|
||||||
|
to_port = 8080
|
||||||
|
protocol = "tcp"
|
||||||
|
security_groups = [aws_security_group.alb.id]
|
||||||
|
}
|
||||||
|
|
||||||
|
egress {
|
||||||
|
from_port = 0
|
||||||
|
to_port = 0
|
||||||
|
protocol = "-1"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb" "api" {
|
||||||
|
name = local.project
|
||||||
|
load_balancer_type = "application"
|
||||||
|
idle_timeout = 120
|
||||||
|
security_groups = [aws_security_group.alb.id]
|
||||||
|
subnets = aws_subnet.public[*].id
|
||||||
|
|
||||||
|
drop_invalid_header_fields = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb_target_group" "api" {
|
||||||
|
name = "${local.project}-api"
|
||||||
|
port = 8080
|
||||||
|
protocol = "HTTP"
|
||||||
|
vpc_id = aws_vpc.this.id
|
||||||
|
target_type = "ip"
|
||||||
|
|
||||||
|
health_check {
|
||||||
|
enabled = true
|
||||||
|
path = "/api/health"
|
||||||
|
matcher = "200"
|
||||||
|
interval = 30
|
||||||
|
timeout = 5
|
||||||
|
healthy_threshold = 2
|
||||||
|
unhealthy_threshold = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb_listener" "http" {
|
||||||
|
load_balancer_arn = aws_lb.api.arn
|
||||||
|
port = 80
|
||||||
|
protocol = "HTTP"
|
||||||
|
|
||||||
|
dynamic "default_action" {
|
||||||
|
for_each = var.attach_custom_domain ? [1] : []
|
||||||
|
content {
|
||||||
|
type = "redirect"
|
||||||
|
redirect {
|
||||||
|
port = "443"
|
||||||
|
protocol = "HTTPS"
|
||||||
|
status_code = "HTTP_301"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "default_action" {
|
||||||
|
for_each = var.attach_custom_domain ? [] : [1]
|
||||||
|
content {
|
||||||
|
type = "forward"
|
||||||
|
target_group_arn = aws_lb_target_group.api.arn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb_listener" "https" {
|
||||||
|
count = var.attach_custom_domain ? 1 : 0
|
||||||
|
|
||||||
|
load_balancer_arn = aws_lb.api.arn
|
||||||
|
port = 443
|
||||||
|
protocol = "HTTPS"
|
||||||
|
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
|
||||||
|
certificate_arn = data.aws_acm_certificate.wildcard[0].arn
|
||||||
|
|
||||||
|
default_action {
|
||||||
|
type = "forward"
|
||||||
|
target_group_arn = aws_lb_target_group.api.arn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_cluster" "api" {
|
||||||
|
name = local.project
|
||||||
|
|
||||||
|
setting {
|
||||||
|
name = "containerInsights"
|
||||||
|
value = local.is_prod ? "enabled" : "disabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
api_container_name = "api"
|
||||||
|
bootstrap_command = [
|
||||||
|
"python",
|
||||||
|
"-c",
|
||||||
|
"from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler\nclass H(BaseHTTPRequestHandler):\n def do_GET(self):\n body = b'{\"stage\":\"bootstrap\",\"sha\":\"bootstrap\"}'\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.send_header('Content-Length', str(len(body)))\n self.end_headers()\n self.wfile.write(body)\nThreadingHTTPServer(('0.0.0.0', 8080), H).serve_forever()",
|
||||||
|
]
|
||||||
|
|
||||||
|
api_environment = [
|
||||||
|
{ name = "STAGE", value = var.environment },
|
||||||
|
{ name = "GIT_SHA", value = "bootstrap" },
|
||||||
|
{ name = "SHIFT_TABLE", value = aws_dynamodb_table.shifts.name },
|
||||||
|
{ name = "SLACK_BOT_TOKEN_SECRET", value = "afterhours-shift-manager/slack-bot-token" },
|
||||||
|
{ name = "SLACK_SIGNING_SECRET", value = "afterhours-shift-manager/slack-signing-secret" },
|
||||||
|
{ name = "SHIFT_CHANNEL", value = local.is_prod ? var.shift_channel : "" },
|
||||||
|
{ name = "TCX_SECRET_PREFIX", value = local.is_prod ? "afterhours-shift-manager/3cx-" : "" },
|
||||||
|
{ name = "QUEUE_NUMBER", value = local.is_prod ? var.queue_number : "" },
|
||||||
|
{ name = "TZ", value = var.timezone },
|
||||||
|
{ name = "HOLIDAY_SCHEDULER_ROLE_ARN", value = local.holiday_scheduler_role_arn },
|
||||||
|
{ name = "HOLIDAY_SCHEDULE_GROUP", value = "default" },
|
||||||
|
{ name = "SENTRY_DSN", value = var.sentry_dsn },
|
||||||
|
{ name = "PORTAL_COGNITO_ISSUER", value = var.portal_cognito_issuer },
|
||||||
|
{ name = "PORTAL_COGNITO_AUDIENCE", value = var.portal_cognito_audience },
|
||||||
|
{ name = "PORTAL_COGNITO_TRUST", value = jsonencode(concat(
|
||||||
|
var.portal_cognito_issuer != "" && var.portal_cognito_audience != "" ? [{ issuer = var.portal_cognito_issuer, audience = var.portal_cognito_audience }] : [],
|
||||||
|
var.portal_cognito_extra_trust,
|
||||||
|
)) },
|
||||||
|
{ name = "PAY_REPORT_USER", value = local.is_prod ? var.pay_report_user : "" },
|
||||||
|
{ name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url },
|
||||||
|
{ name = "ROSTER_API_TOKEN_SECRET", value = "afterhours-shift-manager/roster-api-token" },
|
||||||
|
{ name = "SYNC_GROUP", value = "DEFAULT" },
|
||||||
|
{ name = "JOBS_QUEUE_URL", value = aws_sqs_queue.jobs.id },
|
||||||
|
{ name = "JOBS_QUEUE_ARN", value = aws_sqs_queue.jobs.arn },
|
||||||
|
{ name = "AWS_DEFAULT_REGION", value = var.aws_region },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_task_definition" "api" {
|
||||||
|
family = local.project
|
||||||
|
requires_compatibilities = ["FARGATE"]
|
||||||
|
network_mode = "awsvpc"
|
||||||
|
cpu = "512"
|
||||||
|
memory = "1024"
|
||||||
|
execution_role_arn = aws_iam_role.ecs_execution.arn
|
||||||
|
task_role_arn = aws_iam_role.ecs_task.arn
|
||||||
|
|
||||||
|
runtime_platform {
|
||||||
|
operating_system_family = "LINUX"
|
||||||
|
cpu_architecture = "ARM64"
|
||||||
|
}
|
||||||
|
|
||||||
|
container_definitions = jsonencode([
|
||||||
|
{
|
||||||
|
name = local.api_container_name
|
||||||
|
image = "public.ecr.aws/docker/library/python:3.12-slim"
|
||||||
|
essential = true
|
||||||
|
command = local.bootstrap_command
|
||||||
|
portMappings = [
|
||||||
|
{
|
||||||
|
containerPort = 8080
|
||||||
|
protocol = "tcp"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
environment = local.api_environment
|
||||||
|
logConfiguration = {
|
||||||
|
logDriver = "awslogs"
|
||||||
|
options = {
|
||||||
|
"awslogs-group" = aws_cloudwatch_log_group.api.name
|
||||||
|
"awslogs-region" = var.aws_region
|
||||||
|
"awslogs-stream-prefix" = "ecs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [container_definitions]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_service" "api" {
|
||||||
|
name = local.project
|
||||||
|
cluster = aws_ecs_cluster.api.id
|
||||||
|
task_definition = aws_ecs_task_definition.api.arn
|
||||||
|
desired_count = local.is_prod ? 2 : 1
|
||||||
|
launch_type = "FARGATE"
|
||||||
|
health_check_grace_period_seconds = 60
|
||||||
|
deployment_minimum_healthy_percent = local.is_prod ? 50 : 0
|
||||||
|
deployment_maximum_percent = 200
|
||||||
|
|
||||||
|
network_configuration {
|
||||||
|
subnets = aws_subnet.public[*].id
|
||||||
|
security_groups = [aws_security_group.api.id]
|
||||||
|
assign_public_ip = true
|
||||||
|
}
|
||||||
|
|
||||||
|
load_balancer {
|
||||||
|
target_group_arn = aws_lb_target_group.api.arn
|
||||||
|
container_name = local.api_container_name
|
||||||
|
container_port = 8080
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [task_definition, desired_count]
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [aws_lb_listener.http]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_sqs_queue" "jobs_dlq" {
|
||||||
|
name = "${local.project}-jobs-dlq"
|
||||||
|
message_retention_seconds = 1209600
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_sqs_queue" "jobs" {
|
||||||
|
name = "${local.project}-jobs"
|
||||||
|
visibility_timeout_seconds = 180
|
||||||
|
receive_wait_time_seconds = 20
|
||||||
|
redrive_policy = jsonencode({
|
||||||
|
deadLetterTargetArn = aws_sqs_queue.jobs_dlq.arn
|
||||||
|
maxReceiveCount = 3
|
||||||
|
})
|
||||||
|
}
|
||||||
1134
terraform/hcp_iam.tf
Normal file
1134
terraform/hcp_iam.tf
Normal file
File diff suppressed because it is too large
Load diff
194
terraform/iam.tf
Normal file
194
terraform/iam.tf
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
# Execution, task, and EventBridge Scheduler roles for the Fargate API.
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "ecs_assume" {
|
||||||
|
statement {
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sts:AssumeRole"]
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Service"
|
||||||
|
identifiers = ["ecs-tasks.amazonaws.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "jobs_scheduler_assume" {
|
||||||
|
statement {
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sts:AssumeRole"]
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Service"
|
||||||
|
identifiers = ["scheduler.amazonaws.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "ecs_task_boundary" {
|
||||||
|
statement {
|
||||||
|
sid = "DdbCrud"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"dynamodb:BatchGetItem",
|
||||||
|
"dynamodb:BatchWriteItem",
|
||||||
|
"dynamodb:ConditionCheckItem",
|
||||||
|
"dynamodb:DeleteItem",
|
||||||
|
"dynamodb:DescribeTable",
|
||||||
|
"dynamodb:GetItem",
|
||||||
|
"dynamodb:PutItem",
|
||||||
|
"dynamodb:Query",
|
||||||
|
"dynamodb:Scan",
|
||||||
|
"dynamodb:UpdateItem",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}",
|
||||||
|
"arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}/index/*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "Secrets"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["secretsmanager:GetSecretValue"]
|
||||||
|
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "HolidaySchedules"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"scheduler:CreateSchedule",
|
||||||
|
"scheduler:DeleteSchedule",
|
||||||
|
"scheduler:GetSchedule",
|
||||||
|
]
|
||||||
|
resources = ["arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "PassHolidayScheduler"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["iam:PassRole"]
|
||||||
|
resources = [local.holiday_scheduler_role_arn]
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "iam:PassedToService"
|
||||||
|
values = ["scheduler.amazonaws.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "InvokeHolidayRouter"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["lambda:InvokeFunction"]
|
||||||
|
resources = [local.holiday_router_arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "JobsQueue"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"sqs:SendMessage",
|
||||||
|
"sqs:ReceiveMessage",
|
||||||
|
"sqs:DeleteMessage",
|
||||||
|
"sqs:GetQueueAttributes",
|
||||||
|
]
|
||||||
|
resources = ["arn:aws:sqs:${var.aws_region}:${local.account_id}:${local.project}-jobs"]
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "statement" {
|
||||||
|
for_each = var.checkcomponents_queue_arn == "" ? [] : [1]
|
||||||
|
content {
|
||||||
|
sid = "CheckcomponentsSend"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sqs:SendMessage"]
|
||||||
|
resources = [var.checkcomponents_queue_arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcrAuth"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["ecr:GetAuthorizationToken"]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcrPull"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecr:BatchCheckLayerAvailability",
|
||||||
|
"ecr:BatchGetImage",
|
||||||
|
"ecr:GetDownloadUrlForLayer",
|
||||||
|
]
|
||||||
|
resources = ["arn:aws:ecr:${var.aws_region}:${local.account_id}:repository/${local.project}"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "TaskLogs"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"logs:CreateLogStream",
|
||||||
|
"logs:PutLogEvents",
|
||||||
|
"logs:CreateLogGroup",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/ecs/${local.project}",
|
||||||
|
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/ecs/${local.project}:*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_policy" "ecs_task_boundary" {
|
||||||
|
name = "${local.project}-ecs-task-boundary"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
description = "Permissions boundary for the afterhours-shift-manager ECS task role"
|
||||||
|
policy = data.aws_iam_policy_document.ecs_task_boundary.json
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "ecs_execution" {
|
||||||
|
name = "${local.project}-ecs-exec"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
|
||||||
|
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "ecs_execution" {
|
||||||
|
role = aws_iam_role.ecs_execution.name
|
||||||
|
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "ecs_task" {
|
||||||
|
name = "${local.project}-api"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
|
||||||
|
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "ecs_task" {
|
||||||
|
name = "api-runtime"
|
||||||
|
role = aws_iam_role.ecs_task.id
|
||||||
|
policy = data.aws_iam_policy_document.ecs_task_boundary.json
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "jobs_scheduler" {
|
||||||
|
name = "${local.project}-scheduler"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.jobs_scheduler_assume.json
|
||||||
|
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "jobs_scheduler" {
|
||||||
|
statement {
|
||||||
|
sid = "SendJobs"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sqs:SendMessage"]
|
||||||
|
resources = [aws_sqs_queue.jobs.arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "jobs_scheduler" {
|
||||||
|
name = "enqueue-jobs"
|
||||||
|
role = aws_iam_role.jobs_scheduler.id
|
||||||
|
policy = data.aws_iam_policy_document.jobs_scheduler.json
|
||||||
|
}
|
||||||
116
terraform/iam_github_deploy.tf
Normal file
116
terraform/iam_github_deploy.tf
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# GitHub Actions OIDC role for the thin deploy-api.yaml caller of
|
||||||
|
# org reusable cd-hcp-fargate.yaml.
|
||||||
|
#
|
||||||
|
# One role: GitHub Environments have a single DEPLOY_ROLE_ARN. Trust is pinned
|
||||||
|
# to Environments dev and prod. job_workflow_ref matches the reusable at any ref.
|
||||||
|
# AWS STS GitHub condition keys include job_workflow_ref, not workflow_ref.
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "github_deploy_assume" {
|
||||||
|
statement {
|
||||||
|
sid = "GithubDeployOidc"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sts:AssumeRoleWithWebIdentity"]
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Federated"
|
||||||
|
identifiers = [local.github_oidc_provider_arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "token.actions.githubusercontent.com:aud"
|
||||||
|
values = ["sts.amazonaws.com"]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "token.actions.githubusercontent.com:sub"
|
||||||
|
values = local.github_oidc_subs
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringLike"
|
||||||
|
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
||||||
|
values = [
|
||||||
|
"Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "github_deploy" {
|
||||||
|
name = local.deploy_role
|
||||||
|
path = "/tf-managed/"
|
||||||
|
description = "GitHub Actions image deploy role for ${var.github_repo}"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
|
||||||
|
max_session_duration = 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "github_deploy" {
|
||||||
|
statement {
|
||||||
|
sid = "EcrAuth"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecr:GetAuthorizationToken",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcrPush"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecr:BatchCheckLayerAvailability",
|
||||||
|
"ecr:BatchGetImage",
|
||||||
|
"ecr:CompleteLayerUpload",
|
||||||
|
"ecr:GetDownloadUrlForLayer",
|
||||||
|
"ecr:InitiateLayerUpload",
|
||||||
|
"ecr:PutImage",
|
||||||
|
"ecr:UploadLayerPart",
|
||||||
|
"ecr:DescribeRepositories",
|
||||||
|
"ecr:DescribeImages",
|
||||||
|
]
|
||||||
|
resources = [aws_ecr_repository.api.arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcsDeploy"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecs:DescribeServices",
|
||||||
|
"ecs:DescribeTaskDefinition",
|
||||||
|
"ecs:DescribeTasks",
|
||||||
|
"ecs:ListTasks",
|
||||||
|
"ecs:RegisterTaskDefinition",
|
||||||
|
"ecs:UpdateService",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "PassTaskRoles"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["iam:PassRole"]
|
||||||
|
resources = [
|
||||||
|
aws_iam_role.ecs_task.arn,
|
||||||
|
aws_iam_role.ecs_execution.arn,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "DeployParams"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ssm:GetParameter",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "github_deploy" {
|
||||||
|
name = "afterhours-shift-manager-deploy"
|
||||||
|
role = aws_iam_role.github_deploy.id
|
||||||
|
policy = data.aws_iam_policy_document.github_deploy.json
|
||||||
|
}
|
||||||
3
terraform/lambda.tf
Normal file
3
terraform/lambda.tf
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Leftover Lambda execution roles were removed after Paychex dropped
|
||||||
|
# AfterhoursWeeklyPostSend (PLAT-218). Zip handlers in src/*/app.py remain for
|
||||||
|
# packaging via scripts/package_lambdas.py.
|
||||||
144
terraform/lambda_boundary.tf
Normal file
144
terraform/lambda_boundary.tf
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
# Per-workload Lambda permissions boundary. Created on the first (bootstrap)
|
||||||
|
# apply. The scoped apply role denies iam:CreatePolicy / CreatePolicyVersion,
|
||||||
|
# so later edits to this document need the hcptf-bootstrap window.
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "lambda_boundary" {
|
||||||
|
statement {
|
||||||
|
sid = "CloudWatchLogsWrite"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"logs:CreateLogGroup",
|
||||||
|
"logs:CreateLogStream",
|
||||||
|
"logs:PutLogEvents",
|
||||||
|
"logs:DescribeLogStreams",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:/aws/lambda*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "CloudWatchLogsDescribe"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["logs:DescribeLogGroups"]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "XRay"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"xray:PutTraceSegments",
|
||||||
|
"xray:PutTelemetryRecords",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "Ec2Eni"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ec2:CreateNetworkInterface",
|
||||||
|
"ec2:DescribeNetworkInterfaces",
|
||||||
|
"ec2:DeleteNetworkInterface",
|
||||||
|
"ec2:DescribeSubnets",
|
||||||
|
"ec2:DescribeSecurityGroups",
|
||||||
|
"ec2:DescribeVpcs",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "AfterhoursSecrets"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"secretsmanager:GetSecretValue",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "AfterhoursDynamoDB"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"dynamodb:GetItem",
|
||||||
|
"dynamodb:PutItem",
|
||||||
|
"dynamodb:UpdateItem",
|
||||||
|
"dynamodb:DeleteItem",
|
||||||
|
"dynamodb:Query",
|
||||||
|
"dynamodb:Scan",
|
||||||
|
"dynamodb:BatchGetItem",
|
||||||
|
"dynamodb:BatchWriteItem",
|
||||||
|
"dynamodb:DescribeTable",
|
||||||
|
"dynamodb:ConditionCheckItem",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}",
|
||||||
|
"arn:aws:dynamodb:${var.aws_region}:${local.account_id}:table/${local.table_name}/*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "AfterhoursScheduler"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"scheduler:CreateSchedule",
|
||||||
|
"scheduler:DeleteSchedule",
|
||||||
|
"scheduler:GetSchedule",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "AfterhoursPassRoleScheduler"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"iam:PassRole",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:iam::${local.account_id}:role/tf-managed/afterhours-shift-manager-holiday-scheduler",
|
||||||
|
]
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "iam:PassedToService"
|
||||||
|
values = ["scheduler.amazonaws.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "AfterhoursInvokeHolidayRouter"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"lambda:InvokeFunction",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:lambda:${var.aws_region}:${local.account_id}:function:afterhours-holiday-router",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic "statement" {
|
||||||
|
for_each = var.checkcomponents_queue_arn == "" ? [] : [1]
|
||||||
|
content {
|
||||||
|
sid = "AfterhoursCheckcomponentsSend"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"sqs:SendMessage",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
var.checkcomponents_queue_arn,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_policy" "lambda_boundary" {
|
||||||
|
name = "afterhours-shift-manager-lambda-boundary"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
description = "Per-workload Lambda permissions boundary for afterhours-shift-manager (PLAT-74)."
|
||||||
|
policy = data.aws_iam_policy_document.lambda_boundary.json
|
||||||
|
}
|
||||||
102
terraform/locals.tf
Normal file
102
terraform/locals.tf
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
locals {
|
||||||
|
project = "afterhours-shift-manager"
|
||||||
|
is_prod = var.environment == "prod"
|
||||||
|
account_id = local.is_prod ? "011934824531" : "710827005802"
|
||||||
|
environment = var.environment
|
||||||
|
|
||||||
|
hcp_project = "seahaven-${var.environment}"
|
||||||
|
hcp_workspace = "${local.project}-${var.environment}"
|
||||||
|
apply_role = "hcptf-afterhours-shift-manager"
|
||||||
|
plan_role = "hcptf-afterhours-shift-manager-plan"
|
||||||
|
deploy_role = "githubdeploy-afterhours-shift-manager"
|
||||||
|
stack_name = local.project
|
||||||
|
stack_prefix = "afterhours-shift-manager-"
|
||||||
|
|
||||||
|
artifacts_bucket_name = "afterhours-shift-manager-artifacts-${local.account_id}"
|
||||||
|
ssm_prefix = "/afterhours-shift-manager"
|
||||||
|
|
||||||
|
# Prod has no default VPC. This stack owns 10.70. Meals attaches with
|
||||||
|
# existing_vpc_id. Portal Fargate (PLAT-217) should too. Do not mint 10.62.
|
||||||
|
vpc_cidr = "10.70.0.0/16"
|
||||||
|
public_subnet_cidrs = ["10.70.0.0/24", "10.70.1.0/24"]
|
||||||
|
table_name = "afterhours-shifts"
|
||||||
|
site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts"
|
||||||
|
|
||||||
|
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
|
||||||
|
# Org has Actions OIDC use_immutable_subject=true.
|
||||||
|
github_oidc_subs = [
|
||||||
|
"repo:${var.github_repo}:environment:dev",
|
||||||
|
"repo:${var.github_repo}:environment:prod",
|
||||||
|
"repo:Sea-Haven-Industries@183236204/afterhours-shift-manager@1200846448:environment:dev",
|
||||||
|
"repo:Sea-Haven-Industries@183236204/afterhours-shift-manager@1200846448:environment:prod",
|
||||||
|
]
|
||||||
|
|
||||||
|
domain_name = var.domain_name != "" ? var.domain_name : (local.is_prod ? "afterhours.seahaven.com" : "afterhours.dev.seahaven.com")
|
||||||
|
acm_wildcard_domain = local.is_prod ? "*.seahaven.com" : "*.dev.seahaven.com"
|
||||||
|
api_url = var.attach_custom_domain ? "https://${local.domain_name}" : "http://${aws_lb.api.dns_name}"
|
||||||
|
|
||||||
|
secret_names = [
|
||||||
|
"afterhours-shift-manager/slack-bot-token",
|
||||||
|
"afterhours-shift-manager/slack-signing-secret",
|
||||||
|
"afterhours-shift-manager/3cx-domain",
|
||||||
|
"afterhours-shift-manager/3cx-client-id",
|
||||||
|
"afterhours-shift-manager/3cx-client-secret",
|
||||||
|
"afterhours-shift-manager/roster-api-token",
|
||||||
|
]
|
||||||
|
|
||||||
|
holiday_router_arn = "arn:aws:lambda:${var.aws_region}:${local.account_id}:function:afterhours-holiday-router"
|
||||||
|
holiday_scheduler_role_name = "afterhours-shift-manager-holiday-scheduler"
|
||||||
|
holiday_scheduler_role_arn = "arn:aws:iam::${local.account_id}:role/tf-managed/${local.holiday_scheduler_role_name}"
|
||||||
|
|
||||||
|
functions = {
|
||||||
|
slack_bot = {
|
||||||
|
function_name = "afterhours-shift-manager"
|
||||||
|
role_name = "afterhours-shift-manager-slack-bot"
|
||||||
|
handler = "handler.handler"
|
||||||
|
timeout = 30
|
||||||
|
duration_ms = 24000
|
||||||
|
}
|
||||||
|
weekly_post = {
|
||||||
|
function_name = "afterhours-weekly-post"
|
||||||
|
role_name = "afterhours-shift-manager-weekly-post"
|
||||||
|
handler = "app.handler"
|
||||||
|
timeout = 30
|
||||||
|
duration_ms = 24000
|
||||||
|
}
|
||||||
|
roster_sync = {
|
||||||
|
function_name = "afterhours-roster-sync"
|
||||||
|
role_name = "afterhours-shift-manager-roster-sync"
|
||||||
|
handler = "app.handler"
|
||||||
|
timeout = 60
|
||||||
|
duration_ms = 48000
|
||||||
|
}
|
||||||
|
roster_api = {
|
||||||
|
function_name = "afterhours-roster-api"
|
||||||
|
role_name = "afterhours-shift-manager-roster-api"
|
||||||
|
handler = "app.handler"
|
||||||
|
timeout = 30
|
||||||
|
duration_ms = 24000
|
||||||
|
}
|
||||||
|
ring_scheduler = {
|
||||||
|
function_name = "afterhours-ring-scheduler"
|
||||||
|
role_name = "afterhours-shift-manager-ring-scheduler"
|
||||||
|
handler = "app.handler"
|
||||||
|
timeout = 60
|
||||||
|
duration_ms = 48000
|
||||||
|
}
|
||||||
|
holiday_router = {
|
||||||
|
function_name = "afterhours-holiday-router"
|
||||||
|
role_name = "afterhours-shift-manager-holiday-router"
|
||||||
|
handler = "app.handler"
|
||||||
|
timeout = 60
|
||||||
|
duration_ms = 48000
|
||||||
|
}
|
||||||
|
portal_api = {
|
||||||
|
function_name = "afterhours-portal-api"
|
||||||
|
role_name = "afterhours-shift-manager-portal-api"
|
||||||
|
handler = "app.handler"
|
||||||
|
timeout = 30
|
||||||
|
duration_ms = 24000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
terraform/logs.tf
Normal file
4
terraform/logs.tf
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
resource "aws_cloudwatch_log_group" "api" {
|
||||||
|
name = "/ecs/${local.project}"
|
||||||
|
retention_in_days = local.is_prod ? 60 : 14
|
||||||
|
}
|
||||||
69
terraform/outputs.tf
Normal file
69
terraform/outputs.tf
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
output "slack_request_url" {
|
||||||
|
description = "Slack app Request URL (slash command and interactivity)."
|
||||||
|
value = "${local.api_url}/slack/events"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "api_origin" {
|
||||||
|
description = "HTTP origin for Paychex AFTERHOURS_BASE_URL and portal VITE_SHIFTS_API_BASE. No /roster suffix."
|
||||||
|
value = local.api_url
|
||||||
|
}
|
||||||
|
|
||||||
|
output "shift_table_name" {
|
||||||
|
description = "DynamoDB table name."
|
||||||
|
value = aws_dynamodb_table.shifts.name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "holiday_scheduler_role_arn" {
|
||||||
|
description = "Role EventBridge Scheduler assumes to enqueue holiday jobs."
|
||||||
|
value = aws_iam_role.holiday_scheduler.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "github_deploy_role_arn" {
|
||||||
|
description = "OIDC role ARN for deploy-api.yaml (GitHub Environment variable DEPLOY_ROLE_ARN)."
|
||||||
|
value = aws_iam_role.github_deploy.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "artifacts_bucket_name" {
|
||||||
|
description = "Leftover Lambda artifacts bucket from dual-run zip CD."
|
||||||
|
value = aws_s3_bucket.artifacts.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "alb_dns_name" {
|
||||||
|
description = "ALB DNS name. Public DNS for afterhours.seahaven.com is out of band."
|
||||||
|
value = aws_lb.api.dns_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "fargate_origin" {
|
||||||
|
description = "Fargate origin for Slack/Paychex/portal cutover. HTTPS after attach_custom_domain."
|
||||||
|
value = local.api_url
|
||||||
|
}
|
||||||
|
|
||||||
|
output "jobs_queue_arn" {
|
||||||
|
description = "SQS ARN EventBridge Scheduler holiday one-offs target after cutover."
|
||||||
|
value = aws_sqs_queue.jobs.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "ecs_task_role_arn" {
|
||||||
|
description = "ECS task role. Paychex already allows this ARN."
|
||||||
|
value = aws_iam_role.ecs_task.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "hcptf_apply_role_arn" {
|
||||||
|
description = "HCP apply role ARN. Set TFC_AWS_APPLY_ROLE_ARN after the bootstrap window."
|
||||||
|
value = aws_iam_role.hcptf_apply.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "hcptf_plan_role_arn" {
|
||||||
|
description = "HCP plan role ARN. Set TFC_AWS_PLAN_ROLE_ARN after the bootstrap window."
|
||||||
|
value = aws_iam_role.hcptf_plan.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "vpc_id" {
|
||||||
|
description = "VPC that meals already attaches to. Portal Fargate prod sets existing_vpc_id to this value."
|
||||||
|
value = aws_vpc.this.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "public_subnet_ids" {
|
||||||
|
description = "Public subnet IDs for ALB and Fargate. Portal HCP existing_public_subnet_ids."
|
||||||
|
value = aws_subnet.public[*].id
|
||||||
|
}
|
||||||
12
terraform/providers.tf
Normal file
12
terraform/providers.tf
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
provider "aws" {
|
||||||
|
region = var.aws_region
|
||||||
|
|
||||||
|
default_tags {
|
||||||
|
tags = {
|
||||||
|
Project = local.project
|
||||||
|
Environment = var.environment
|
||||||
|
ManagedBy = "terraform"
|
||||||
|
Workspace = local.hcp_workspace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
terraform/scheduler.tf
Normal file
100
terraform/scheduler.tf
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# EventBridge Scheduler execution role. slack-bot creates one-off holiday-*
|
||||||
|
# schedules at runtime; Terraform does not create those schedules.
|
||||||
|
# Recurring jobs use America/New_York Scheduler -> SQS (not dual EST/EDT rules).
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "holiday_scheduler_assume" {
|
||||||
|
statement {
|
||||||
|
sid = "SchedulerAssume"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sts:AssumeRole"]
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Service"
|
||||||
|
identifiers = ["scheduler.amazonaws.com"]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "aws:SourceAccount"
|
||||||
|
values = [local.account_id]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "ArnLike"
|
||||||
|
variable = "aws:SourceArn"
|
||||||
|
values = ["arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "holiday_scheduler" {
|
||||||
|
name = local.holiday_scheduler_role_name
|
||||||
|
path = "/tf-managed/"
|
||||||
|
description = "EventBridge Scheduler assumes this role to enqueue holiday jobs or invoke afterhours-holiday-router"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.holiday_scheduler_assume.json
|
||||||
|
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "holiday_scheduler" {
|
||||||
|
statement {
|
||||||
|
sid = "SendHolidayJobs"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sqs:SendMessage"]
|
||||||
|
resources = [aws_sqs_queue.jobs.arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "holiday_scheduler" {
|
||||||
|
name = "invoke-holiday-router"
|
||||||
|
role = aws_iam_role.holiday_scheduler.id
|
||||||
|
policy = data.aws_iam_policy_document.holiday_scheduler.json
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
job_schedules = {
|
||||||
|
weekly-post = {
|
||||||
|
description = "Post weekly schedule Monday 7am Eastern; closes the Sun-Sat pay week"
|
||||||
|
schedule = "cron(0 7 ? * MON *)"
|
||||||
|
event = "weekly_post"
|
||||||
|
}
|
||||||
|
roster-sync = {
|
||||||
|
description = "Sync roster from 3CX at 6am Eastern"
|
||||||
|
schedule = "cron(0 6 ? * * *)"
|
||||||
|
event = "roster_sync"
|
||||||
|
}
|
||||||
|
ring-scheduler-daily = {
|
||||||
|
description = "Update 3CX queue at 8am Eastern"
|
||||||
|
schedule = "cron(0 8 ? * * *)"
|
||||||
|
event = "ring_scheduler_daily"
|
||||||
|
}
|
||||||
|
ring-scheduler-weekend = {
|
||||||
|
description = "Update 3CX queue at 5pm Eastern weekends"
|
||||||
|
schedule = "cron(0 17 ? * SAT,SUN *)"
|
||||||
|
event = "ring_scheduler_weekend"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_scheduler_schedule_group" "jobs" {
|
||||||
|
name = local.project
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_scheduler_schedule" "jobs" {
|
||||||
|
for_each = local.job_schedules
|
||||||
|
|
||||||
|
name = "${local.project}-${each.key}"
|
||||||
|
group_name = aws_scheduler_schedule_group.jobs.name
|
||||||
|
description = each.value.description
|
||||||
|
schedule_expression = each.value.schedule
|
||||||
|
schedule_expression_timezone = "America/New_York"
|
||||||
|
state = var.ecs_schedules_enabled ? "ENABLED" : "DISABLED"
|
||||||
|
flexible_time_window {
|
||||||
|
mode = "OFF"
|
||||||
|
}
|
||||||
|
|
||||||
|
target {
|
||||||
|
arn = aws_sqs_queue.jobs.arn
|
||||||
|
role_arn = aws_iam_role.jobs_scheduler.arn
|
||||||
|
input = jsonencode({ event = each.value.event })
|
||||||
|
}
|
||||||
|
}
|
||||||
15
terraform/secrets.tf
Normal file
15
terraform/secrets.tf
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Secret shells only. Values are set outside Terraform. 3CX secrets may already
|
||||||
|
# exist in prod from seahaven-door-unlock-api (PLAT-76); import those names
|
||||||
|
# rather than recreating:
|
||||||
|
# terraform import 'aws_secretsmanager_secret.this["afterhours-shift-manager/3cx-domain"]' afterhours-shift-manager/3cx-domain
|
||||||
|
|
||||||
|
resource "aws_secretsmanager_secret" "this" {
|
||||||
|
for_each = toset(local.secret_names)
|
||||||
|
|
||||||
|
name = each.value
|
||||||
|
recovery_window_in_days = 30
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Purpose = "afterhours-shift-manager secret shell"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
terraform/ssm.tf
Normal file
48
terraform/ssm.tf
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
resource "aws_ssm_parameter" "deploy_artifacts_bucket" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/artifacts-bucket"
|
||||||
|
type = "String"
|
||||||
|
value = aws_s3_bucket.artifacts.id
|
||||||
|
description = "Unused leftover Lambda artifacts bucket from the dual-run zip path."
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_api_url" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/api-url"
|
||||||
|
type = "String"
|
||||||
|
value = local.api_url
|
||||||
|
description = "API origin for deploy-api.yaml health check"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_cluster" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/cluster"
|
||||||
|
type = "String"
|
||||||
|
value = aws_ecs_cluster.api.name
|
||||||
|
description = "ECS cluster name for deploy-api.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_service" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/service"
|
||||||
|
type = "String"
|
||||||
|
value = aws_ecs_service.api.name
|
||||||
|
description = "ECS service name for deploy-api.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_task_family" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/task-family"
|
||||||
|
type = "String"
|
||||||
|
value = aws_ecs_task_definition.api.family
|
||||||
|
description = "ECS task definition family for deploy-api.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_ecr_repository" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/ecr-repository"
|
||||||
|
type = "String"
|
||||||
|
value = aws_ecr_repository.api.repository_url
|
||||||
|
description = "ECR repository URL for deploy-api.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_container_name" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/container-name"
|
||||||
|
type = "String"
|
||||||
|
value = local.api_container_name
|
||||||
|
description = "Container name in the ECS task definition"
|
||||||
|
}
|
||||||
110
terraform/variables.tf
Normal file
110
terraform/variables.tf
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
variable "aws_region" {
|
||||||
|
description = "Region every resource in this configuration is created in."
|
||||||
|
type = string
|
||||||
|
default = "us-east-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "shift_channel" {
|
||||||
|
description = "Slack channel ID for schedule posts and shift notifications. Not a secret."
|
||||||
|
type = string
|
||||||
|
default = "C0APATP612N"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "queue_number" {
|
||||||
|
description = "3CX queue extension number the ring scheduler updates."
|
||||||
|
type = string
|
||||||
|
default = "801"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "timezone" {
|
||||||
|
description = "IANA timezone for schedule math and EventBridge cron comments."
|
||||||
|
type = string
|
||||||
|
default = "America/New_York"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "pay_report_user" {
|
||||||
|
description = "Slack user ID that receives the weekly pay DM."
|
||||||
|
type = string
|
||||||
|
default = "U0A3SC48T47"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "sentry_dsn" {
|
||||||
|
description = "Sentry DSN. Empty disables the SDK. Set in HCP, never in git."
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "environment" {
|
||||||
|
description = "HCP workspace stage. Selects account and workspace name."
|
||||||
|
type = string
|
||||||
|
default = "prod"
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = contains(["dev", "prod"], var.environment)
|
||||||
|
error_message = "environment must be \"dev\" or \"prod\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "domain_name" {
|
||||||
|
description = "Public hostname on the ALB when attach_custom_domain is true. Empty selects afterhours.seahaven.com or afterhours.dev.seahaven.com from environment."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "attach_custom_domain" {
|
||||||
|
description = "When true, attach an HTTPS listener using the issued wildcard ACM certificate. Keep false until public DNS points at the ALB."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "schedules_enabled" {
|
||||||
|
description = "When false, EventBridge Lambda rules exist but do not fire. Keep false until Slack and Paychex point at this stack, and after Fargate jobs are enabled."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ecs_schedules_enabled" {
|
||||||
|
description = "When false, EventBridge Scheduler jobs exist but do not fire. Enable at Fargate cutover after the image is healthy; keep Lambda EventBridge rules disabled."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "github_repo" {
|
||||||
|
description = "GitHub owner/name for the deploy OIDC trust."
|
||||||
|
type = string
|
||||||
|
default = "Sea-Haven-Industries/afterhours-shift-manager"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "checkcomponents_queue_url" {
|
||||||
|
description = "paychex-checkcomponents SQS URL. Empty skips the weekly SendMessage."
|
||||||
|
type = string
|
||||||
|
default = "https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "checkcomponents_queue_arn" {
|
||||||
|
description = "paychex-checkcomponents SQS ARN for WeeklyPost SendMessage."
|
||||||
|
type = string
|
||||||
|
default = "arn:aws:sqs:us-east-1:011934824531:paychex-checkcomponents"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "portal_cognito_issuer" {
|
||||||
|
description = "Trusted portal Cognito user-pool issuer for ID-token verification. Empty disables portal auth."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "portal_cognito_audience" {
|
||||||
|
description = "Trusted portal Cognito app client ID for ID-token verification."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "portal_cognito_extra_trust" {
|
||||||
|
description = "Additional portal Cognito issuer/audience pairs (dev+prod)."
|
||||||
|
type = list(object({
|
||||||
|
issuer = string
|
||||||
|
audience = string
|
||||||
|
}))
|
||||||
|
default = []
|
||||||
|
}
|
||||||
22
terraform/versions.tf
Normal file
22
terraform/versions.tf
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
terraform {
|
||||||
|
required_version = ">= 1.14.0"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = "~> 6.66"
|
||||||
|
}
|
||||||
|
archive = {
|
||||||
|
source = "hashicorp/archive"
|
||||||
|
version = "~> 2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cloud {
|
||||||
|
organization = "seahaven"
|
||||||
|
|
||||||
|
workspaces {
|
||||||
|
tags = ["app:afterhours-shift-manager"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
terraform/vpc.tf
Normal file
61
terraform/vpc.tf
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
data "aws_availability_zones" "available" {
|
||||||
|
state = "available"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_vpc" "this" {
|
||||||
|
cidr_block = local.vpc_cidr
|
||||||
|
enable_dns_support = true
|
||||||
|
enable_dns_hostnames = true
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.project}-vpc"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First apply updates the live hcptf apply role before CreateVpc.
|
||||||
|
depends_on = [
|
||||||
|
aws_iam_role_policy.hcptf_apply_services,
|
||||||
|
aws_iam_role_policy_attachments_exclusive.hcptf_apply,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_internet_gateway" "this" {
|
||||||
|
vpc_id = aws_vpc.this.id
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.project}-igw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_subnet" "public" {
|
||||||
|
count = length(local.public_subnet_cidrs)
|
||||||
|
|
||||||
|
vpc_id = aws_vpc.this.id
|
||||||
|
cidr_block = local.public_subnet_cidrs[count.index]
|
||||||
|
availability_zone = data.aws_availability_zones.available.names[count.index]
|
||||||
|
map_public_ip_on_launch = true
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.project}-public-${count.index}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route_table" "public" {
|
||||||
|
vpc_id = aws_vpc.this.id
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.project}-public"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route" "public_default" {
|
||||||
|
route_table_id = aws_route_table.public.id
|
||||||
|
destination_cidr_block = "0.0.0.0/0"
|
||||||
|
gateway_id = aws_internet_gateway.this.id
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route_table_association" "public" {
|
||||||
|
count = length(local.public_subnet_cidrs)
|
||||||
|
|
||||||
|
subnet_id = aws_subnet.public[count.index].id
|
||||||
|
route_table_id = aws_route_table.public.id
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,8 @@ def aws_env(monkeypatch):
|
||||||
monkeypatch.delenv("QUEUE_NUMBER", raising=False)
|
monkeypatch.delenv("QUEUE_NUMBER", raising=False)
|
||||||
monkeypatch.delenv("TCX_SECRET_PREFIX", raising=False)
|
monkeypatch.delenv("TCX_SECRET_PREFIX", raising=False)
|
||||||
monkeypatch.delenv("SENTRY_DSN", raising=False)
|
monkeypatch.delenv("SENTRY_DSN", raising=False)
|
||||||
|
monkeypatch.delenv("JOBS_QUEUE_URL", raising=False)
|
||||||
|
monkeypatch.delenv("JOBS_QUEUE_ARN", raising=False)
|
||||||
|
|
||||||
|
|
||||||
def _create_table(dynamodb):
|
def _create_table(dynamodb):
|
||||||
|
|
|
||||||
226
tests/infra/test_hcp_contract.py
Normal file
226
tests/infra/test_hcp_contract.py
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
"""Contracts for the HCP Terraform seam (PLAT-74 / PLAT-216)."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
TERRAFORM = ROOT / "terraform"
|
||||||
|
HCP_IAM = (TERRAFORM / "hcp_iam.tf").read_text()
|
||||||
|
CI = (ROOT / ".github" / "workflows" / "ci.yaml").read_text()
|
||||||
|
LOCALS = (TERRAFORM / "locals.tf").read_text()
|
||||||
|
VARIABLES = (TERRAFORM / "variables.tf").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def _tf_without_comments(text: str) -> str:
|
||||||
|
return "\n".join(line.split("#", 1)[0] for line in text.splitlines())
|
||||||
|
|
||||||
|
|
||||||
|
def test_sam_template_removed():
|
||||||
|
assert not (ROOT / "template.yaml").exists()
|
||||||
|
assert not (ROOT / "samconfig.toml.example").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_zip_cd_removed():
|
||||||
|
assert not (ROOT / ".github" / "workflows" / "deploy.yaml").exists()
|
||||||
|
for path in TERRAFORM.glob("*.tf"):
|
||||||
|
assert 'resource "aws_lambda_function"' not in path.read_text()
|
||||||
|
assert not (TERRAFORM / "apigateway.tf").exists()
|
||||||
|
assert not (TERRAFORM / "events.tf").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedules_disabled_by_default():
|
||||||
|
chunk = (
|
||||||
|
(TERRAFORM / "variables.tf")
|
||||||
|
.read_text()
|
||||||
|
.split('variable "schedules_enabled"')[1]
|
||||||
|
)
|
||||||
|
chunk = chunk.split("variable ")[0]
|
||||||
|
assert "default = false" in chunk or "default = false" in chunk
|
||||||
|
|
||||||
|
|
||||||
|
def test_ecs_schedules_disabled_by_default():
|
||||||
|
chunk = (
|
||||||
|
(TERRAFORM / "variables.tf")
|
||||||
|
.read_text()
|
||||||
|
.split('variable "ecs_schedules_enabled"')[1]
|
||||||
|
)
|
||||||
|
chunk = chunk.split("variable ")[0]
|
||||||
|
assert "default = false" in chunk or "default = false" in chunk
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspaces_use_app_tag():
|
||||||
|
versions = (TERRAFORM / "versions.tf").read_text()
|
||||||
|
assert 'tags = ["app:afterhours-shift-manager"]' in versions
|
||||||
|
assert 'name = "afterhours-shift-manager-prod"' not in versions
|
||||||
|
assert 'hcp_workspace = "${local.project}-${var.environment}"' in LOCALS
|
||||||
|
assert "seahaven-${var.environment}" in LOCALS
|
||||||
|
|
||||||
|
|
||||||
|
def test_ecs_ignore_changes_and_task_size():
|
||||||
|
ecs = (TERRAFORM / "ecs.tf").read_text()
|
||||||
|
assert "ignore_changes = [container_definitions]" in ecs
|
||||||
|
assert "ignore_changes = [task_definition, desired_count]" in ecs
|
||||||
|
assert 'cpu = "512"' in ecs
|
||||||
|
assert 'memory = "1024"' in ecs
|
||||||
|
assert 'cpu_architecture = "ARM64"' in ecs
|
||||||
|
assert 'path = "/api/health"' in ecs
|
||||||
|
|
||||||
|
|
||||||
|
def test_stack_owns_a_vpc_instead_of_looking_up_default():
|
||||||
|
vpc = (TERRAFORM / "vpc.tf").read_text()
|
||||||
|
ecs = (TERRAFORM / "ecs.tf").read_text()
|
||||||
|
assert 'resource "aws_vpc" "this"' in vpc
|
||||||
|
assert "cidr_block = local.vpc_cidr" in vpc
|
||||||
|
assert 'vpc_cidr = "10.70.0.0/16"' in LOCALS
|
||||||
|
assert 'data "aws_vpc" "default"' not in ecs
|
||||||
|
assert "data.aws_vpc.default" not in ecs
|
||||||
|
assert "data.aws_subnets.default" not in ecs
|
||||||
|
assert "aws_vpc.this.id" in ecs
|
||||||
|
assert "aws_subnet.public[*].id" in ecs
|
||||||
|
assert "ec2:CreateVpc" in HCP_IAM
|
||||||
|
assert 'sid = "RefreshVpc"' in HCP_IAM
|
||||||
|
assert "afterhours-shift-manager-ecs" in HCP_IAM
|
||||||
|
assert "hcptf_apply_ecs" in HCP_IAM
|
||||||
|
assert 'resource "aws_iam_policy" "hcptf_apply_ecs"' in HCP_IAM
|
||||||
|
assert 'resource "aws_iam_role_policy" "hcptf_apply_ecs"' not in HCP_IAM
|
||||||
|
assert "aws_iam_policy.hcptf_apply_ecs.arn" in HCP_IAM
|
||||||
|
assert 'sid = "CreateElbAndEcsServiceLinkedRoles"' in HCP_IAM
|
||||||
|
assert "iam:CreateServiceLinkedRole" in HCP_IAM
|
||||||
|
|
||||||
|
|
||||||
|
def test_ecs_task_boundary_uses_static_arns():
|
||||||
|
iam = (TERRAFORM / "iam.tf").read_text()
|
||||||
|
chunk = iam.split('data "aws_iam_policy_document" "ecs_task_boundary"')[1]
|
||||||
|
chunk = chunk.split("resource ")[0]
|
||||||
|
assert "aws_dynamodb_table.shifts" not in chunk
|
||||||
|
assert "aws_sqs_queue.jobs" not in chunk
|
||||||
|
assert "aws_ecr_repository.api" not in chunk
|
||||||
|
assert "aws_cloudwatch_log_group.api" not in chunk
|
||||||
|
assert "table/${local.table_name}" in chunk
|
||||||
|
assert "log-group:/ecs/${local.project}" in chunk
|
||||||
|
|
||||||
|
|
||||||
|
def test_deploy_api_workflow_exists():
|
||||||
|
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
|
||||||
|
pin = "cd-hcp-fargate.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19"
|
||||||
|
assert deploy_api.count(pin) == 2
|
||||||
|
assert "ssm-prefix: /afterhours-shift-manager/deploy" in deploy_api
|
||||||
|
assert "docker-platform: linux/arm64" in deploy_api
|
||||||
|
assert "ship-gate: true" in deploy_api
|
||||||
|
assert "gh release create" in deploy_api
|
||||||
|
assert "needs.target.outputs.environment" not in deploy_api
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_repo_hcptf_roles():
|
||||||
|
assert 'apply_role = "hcptf-afterhours-shift-manager"' in LOCALS
|
||||||
|
assert 'plan_role = "hcptf-afterhours-shift-manager-plan"' in LOCALS
|
||||||
|
assert "hcptf_apply" in HCP_IAM
|
||||||
|
assert "DenyCreatePolicy" in HCP_IAM
|
||||||
|
|
||||||
|
|
||||||
|
def test_ci_runs_pytest_and_terraform_validate():
|
||||||
|
assert "ci-python-sam" not in CI
|
||||||
|
assert "ci-python-app.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" in CI
|
||||||
|
assert "ci-terraform.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" in CI
|
||||||
|
assert "ci-autofix.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" in CI
|
||||||
|
assert "name: ci-complete" in CI
|
||||||
|
assert "pytest" in CI
|
||||||
|
assert "terraform fmt -check" not in CI
|
||||||
|
assert "openapi:lint" in CI
|
||||||
|
assert "npm ci" in CI
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_uses_redocly_recommended():
|
||||||
|
redocly = (ROOT / ".redocly.yaml").read_text()
|
||||||
|
package = (ROOT / "package.json").read_text()
|
||||||
|
spec = (ROOT / "openapi.yaml").read_text()
|
||||||
|
assert "extends:" in redocly
|
||||||
|
assert "- recommended" in redocly
|
||||||
|
assert "operation-2xx-response: off" in redocly
|
||||||
|
assert "operation-4xx-response: error" in redocly
|
||||||
|
assert "rule/operation-2xx-or-3xx-response" in redocly
|
||||||
|
assert "severity: error" in redocly
|
||||||
|
assert "optionsShifts" not in spec
|
||||||
|
health = spec.split("/api/health:", 1)[1].split("\n /", 1)[0]
|
||||||
|
assert '"403":' in health
|
||||||
|
assert '"400":' not in health
|
||||||
|
assert "root: openapi.yaml" in redocly
|
||||||
|
assert '"openapi:lint"' in package
|
||||||
|
assert '"@redocly/cli":' in package
|
||||||
|
assert "openapi: 3.1.0" in spec
|
||||||
|
assert "required: [stage, sha]" in spec
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkcomponents_queue_arn_variable_matches_iam_references():
|
||||||
|
assert 'variable "checkcomponents_queue_arn"' in VARIABLES
|
||||||
|
iam = (TERRAFORM / "iam.tf").read_text()
|
||||||
|
assert "var.checkcomponents_queue_arn" in iam
|
||||||
|
boundary = (TERRAFORM / "lambda_boundary.tf").read_text()
|
||||||
|
assert "var.checkcomponents_queue_arn" in boundary
|
||||||
|
data_tf = (TERRAFORM / "data.tf").read_text()
|
||||||
|
assert "dev_has_no_paychex" in data_tf
|
||||||
|
assert "checkcomponents_pair" in data_tf
|
||||||
|
|
||||||
|
|
||||||
|
def test_leftover_lambda_iam_roles_removed():
|
||||||
|
for path in TERRAFORM.glob("*.tf"):
|
||||||
|
body = _tf_without_comments(path.read_text())
|
||||||
|
assert 'resource "aws_iam_role" "lambda"' not in body
|
||||||
|
assert 'resource "aws_iam_role_policy" "lambda"' not in body
|
||||||
|
assert 'resource "aws_iam_role_policy_attachment" "lambda_basic"' not in body
|
||||||
|
assert 'data "aws_iam_policy_document" "lambda_assume"' not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_lambda_boundary_policy_remains():
|
||||||
|
boundary = (TERRAFORM / "lambda_boundary.tf").read_text()
|
||||||
|
assert 'resource "aws_iam_policy" "lambda_boundary"' in boundary
|
||||||
|
assert "afterhours-shift-manager-lambda-boundary" in boundary
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_functions_still_lists_packaging_keys():
|
||||||
|
for name in (
|
||||||
|
"afterhours-shift-manager",
|
||||||
|
"afterhours-weekly-post",
|
||||||
|
"afterhours-roster-sync",
|
||||||
|
"afterhours-roster-api",
|
||||||
|
"afterhours-ring-scheduler",
|
||||||
|
"afterhours-holiday-router",
|
||||||
|
"afterhours-portal-api",
|
||||||
|
):
|
||||||
|
assert name in LOCALS
|
||||||
|
assert "afterhours-release-notifier" not in LOCALS
|
||||||
|
assert "weekly_post" in LOCALS
|
||||||
|
|
||||||
|
|
||||||
|
def test_github_deploy_trust_covers_image_only():
|
||||||
|
iam = (TERRAFORM / "iam_github_deploy.tf").read_text()
|
||||||
|
assert "environment:prod" in iam or "environment:prod" in LOCALS
|
||||||
|
assert "environment:dev" in iam or "environment:dev" in LOCALS
|
||||||
|
assert "deploy.yaml@" not in iam
|
||||||
|
assert "deploy-api.yaml@" not in iam
|
||||||
|
assert "Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*" in iam
|
||||||
|
assert "ecs:ListTasks" in iam
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_refresh_includes_provider6_s3_gets():
|
||||||
|
assert "s3:GetLifecycleConfiguration" in HCP_IAM
|
||||||
|
assert "s3:GetReplicationConfiguration" in HCP_IAM
|
||||||
|
assert "s3:GetBucketReplication" in HCP_IAM
|
||||||
|
|
||||||
|
|
||||||
|
def test_origins_use_fargate_url():
|
||||||
|
outputs = (TERRAFORM / "outputs.tf").read_text()
|
||||||
|
assert "aws_apigatewayv2_api.http" not in outputs
|
||||||
|
assert "${local.api_url}/slack/events" in outputs
|
||||||
|
assert "value = local.api_url" in outputs
|
||||||
|
assert 'output "vpc_id"' in outputs
|
||||||
|
assert 'output "public_subnet_ids"' in outputs
|
||||||
|
assert "aws_vpc.this.id" in outputs
|
||||||
|
|
||||||
|
|
||||||
|
def test_alb_alarms_remain():
|
||||||
|
alarms = (TERRAFORM / "alarms.tf").read_text()
|
||||||
|
assert "ALB-5xx-" in alarms
|
||||||
|
assert "ALB-Latency-" in alarms
|
||||||
|
assert "ALB-UnhealthyHost-" in alarms
|
||||||
|
assert "ApiGateway-" not in alarms
|
||||||
|
assert "Lambda-" not in alarms
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Load src/release-notifier/app.py under a unique module name."""
|
"""Load src/portal-api/app.py under a unique module name."""
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
@ -18,5 +18,5 @@ def _load(name, relpath):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def notifier_app():
|
def portalapi_app():
|
||||||
return _load("release_notifier_app", "src/release-notifier/app.py")
|
return _load("portalapi_app", "src/portal-api/app.py")
|
||||||
101
tests/portal_api/test_handler.py
Normal file
101
tests/portal_api/test_handler.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""Tests for the portal shift API handler."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _event(method="GET", path="/api/shifts", body=None, token="id-token", origin=None):
|
||||||
|
headers = {"authorization": f"Bearer {token}"}
|
||||||
|
if origin:
|
||||||
|
headers["origin"] = origin
|
||||||
|
payload = None
|
||||||
|
if body is not None:
|
||||||
|
payload = json.dumps(body)
|
||||||
|
return {
|
||||||
|
"version": "2.0",
|
||||||
|
"routeKey": f"{method} {path}",
|
||||||
|
"rawPath": path,
|
||||||
|
"headers": headers,
|
||||||
|
"queryStringParameters": {},
|
||||||
|
"requestContext": {"http": {"method": method, "path": path}},
|
||||||
|
"body": payload,
|
||||||
|
"isBase64Encoded": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def identity(portalapi_app, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"shared.portal_http.verify_cognito_id_token",
|
||||||
|
lambda _token: {"name": "Alice", "email": "alice@seahavenind.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlinked_email_returns_linked_false(portalapi_app, schedule, identity):
|
||||||
|
result = portalapi_app.handler(_event(), None)
|
||||||
|
assert result["statusCode"] == 200
|
||||||
|
body = json.loads(result["body"])
|
||||||
|
assert body["linked"] is False
|
||||||
|
assert body["email"] == "alice@seahavenind.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_401_without_bearer(portalapi_app, schedule):
|
||||||
|
event = _event()
|
||||||
|
event["headers"] = {}
|
||||||
|
result = portalapi_app.handler(event, None)
|
||||||
|
assert result["statusCode"] == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_linked_snapshot_and_admin_flag(portalapi_app, schedule, seed, identity):
|
||||||
|
seed.roster(
|
||||||
|
"114",
|
||||||
|
"Alice",
|
||||||
|
slack_user_id="U_ADMIN",
|
||||||
|
email="alice@seahavenind.com",
|
||||||
|
)
|
||||||
|
seed.config(admin_users=["U_ADMIN"])
|
||||||
|
seed.weekly("Monday", "114", "Alice")
|
||||||
|
result = portalapi_app.handler(_event(), None)
|
||||||
|
assert result["statusCode"] == 200
|
||||||
|
body = json.loads(result["body"])
|
||||||
|
assert body["linked"] is True
|
||||||
|
assert body["isAdmin"] is True
|
||||||
|
assert body["me"]["extension"] == "114"
|
||||||
|
assert len(body["days"]) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_cannot_override(portalapi_app, schedule, seed, identity):
|
||||||
|
seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com")
|
||||||
|
seed.config(admin_users=["U_OTHER"])
|
||||||
|
result = portalapi_app.handler(
|
||||||
|
_event(
|
||||||
|
method="POST",
|
||||||
|
path="/api/shifts/admin/override",
|
||||||
|
body={"date": "2026-06-10", "extension": "114", "shiftType": "night"},
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert result["statusCode"] == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_header_for_portal_origin(portalapi_app, schedule, identity):
|
||||||
|
result = portalapi_app.handler(_event(origin="https://internal.seahaven.com"), None)
|
||||||
|
assert (
|
||||||
|
result["headers"]["Access-Control-Allow-Origin"]
|
||||||
|
== "https://internal.seahaven.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_failure_keeps_cors(portalapi_app, identity, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"shared.portal_http.ShiftSchedule",
|
||||||
|
lambda: (_ for _ in ()).throw(RuntimeError("ddb down")),
|
||||||
|
)
|
||||||
|
result = portalapi_app.handler(_event(origin="https://internal.seahaven.com"), None)
|
||||||
|
assert result["statusCode"] == 500
|
||||||
|
assert (
|
||||||
|
result["headers"]["Access-Control-Allow-Origin"]
|
||||||
|
== "https://internal.seahaven.com"
|
||||||
|
)
|
||||||
|
assert json.loads(result["body"])["error"]["code"] == "INTERNAL"
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
"""Tests for the release-notifier Lambda handler."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def slack(notifier_app, monkeypatch):
|
|
||||||
"""Fake Slack WebClient; chat_postMessage returns a message ts."""
|
|
||||||
fake = MagicMock(name="slack")
|
|
||||||
fake.chat_postMessage.return_value = {"ts": "111.222"}
|
|
||||||
monkeypatch.setattr(notifier_app, "WebClient", MagicMock(return_value=fake))
|
|
||||||
monkeypatch.setattr(notifier_app, "get_secret", lambda _id: "xoxb-test")
|
|
||||||
return fake
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def env(monkeypatch):
|
|
||||||
monkeypatch.setenv(
|
|
||||||
"SLACK_BOT_TOKEN_SECRET", "afterhours-shift-manager/slack-bot-token"
|
|
||||||
)
|
|
||||||
monkeypatch.setenv("SHIFT_CHANNEL", "C_RELEASES")
|
|
||||||
|
|
||||||
|
|
||||||
def test_posts_announcement_to_channel(notifier_app, slack, env):
|
|
||||||
result = notifier_app.handler(
|
|
||||||
{
|
|
||||||
"version": "1.10.0",
|
|
||||||
"notes": "**Release notes** now self-announce.",
|
|
||||||
"date_label": "June 11, 2026",
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"announced": True, "version": "1.10.0", "ts": "111.222"}
|
|
||||||
slack.chat_postMessage.assert_called_once()
|
|
||||||
kwargs = slack.chat_postMessage.call_args.kwargs
|
|
||||||
assert kwargs["channel"] == "C_RELEASES"
|
|
||||||
assert kwargs["text"] == "What's New — v1.10.0"
|
|
||||||
# Markdown was converted to Slack mrkdwn in the rendered blocks.
|
|
||||||
rendered = str(kwargs["blocks"])
|
|
||||||
assert "*Release notes*" in rendered
|
|
||||||
|
|
||||||
|
|
||||||
def test_uses_the_slack_bot_token_secret(notifier_app, slack, env, monkeypatch):
|
|
||||||
seen = {}
|
|
||||||
monkeypatch.setattr(
|
|
||||||
notifier_app, "get_secret", lambda sid: seen.setdefault("id", sid) or "xoxb"
|
|
||||||
)
|
|
||||||
notifier_app.handler({"version": "2.0.0", "notes": "Big."}, None)
|
|
||||||
assert seen["id"] == "afterhours-shift-manager/slack-bot-token"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"event",
|
|
||||||
[
|
|
||||||
{},
|
|
||||||
{"version": "1.10.0"}, # missing notes
|
|
||||||
{"notes": "x"}, # missing version
|
|
||||||
{"version": "", "notes": "x"}, # empty version
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_rejects_incomplete_event(notifier_app, slack, env, event):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
notifier_app.handler(event, None)
|
|
||||||
slack.chat_postMessage.assert_not_called()
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
# Test-only dependencies. The CI reusable workflow (ci-python-sam.yaml) installs
|
# Test-only dependencies. CI's pytest job installs this file plus the runtime
|
||||||
# every requirements.txt it finds when run-tests is true, so this file is picked
|
# requirements.txt files the imports need.
|
||||||
# up automatically alongside each Lambda's runtime requirements.
|
|
||||||
pytest>=9.1.1
|
pytest>=9.1.1
|
||||||
moto[dynamodb,ses,secretsmanager]>=5.2.2
|
moto[dynamodb,ses,secretsmanager]>=5.2.2
|
||||||
responses>=0.26.2
|
responses>=0.26.2
|
||||||
freezegun>=1.5.5
|
freezegun>=1.5.5
|
||||||
sentry-sdk==2.68.1
|
sentry-sdk==2.68.1
|
||||||
|
PyJWT[crypto]==2.14.0
|
||||||
|
flask==3.1.3
|
||||||
|
|
||||||
|
|
|
||||||
26
tests/roster_api/conftest.py
Normal file
26
tests/roster_api/conftest.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Load src/roster-api/app.py under a unique module name."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name, relpath):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, _ROOT / relpath)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[name] = mod
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rosterapi_app():
|
||||||
|
mod = _load("rosterapi_app", "src/roster-api/app.py")
|
||||||
|
yield mod
|
||||||
|
import shared.roster_http as roster_http
|
||||||
|
|
||||||
|
roster_http._cached_token = None
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue