Compare commits

...

106 commits

Author SHA1 Message Date
Adam Moussa
54ad5a7e34
fix(side-effects): keep non-prod off Slack and 3CX (DEV-306) (#287)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
* fix(side-effects): keep non-prod off Slack and 3CX

Dev portal actions could still name the production Slack channel and phone queue. Skip those calls unless STAGE is prod, and leave the identifiers empty on non-prod tasks.

* style: apply formatter

---------

Co-authored-by: sea-haven-auto-fix[bot] <332630863+sea-haven-auto-fix[bot]@users.noreply.github.com>
2026-09-29 19:10:21 +00:00
renovate[bot]
3030b134fa
chore(deps): update sea-haven-industries/.github action to v1.0.19 (#285)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
* chore(deps): update sea-haven-industries/.github action to v1.0.19

* test(ci): expect org workflow pin v1.0.19

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-09-28 16:45:27 +00:00
renovate[bot]
9ca1ef48bd
chore(deps): update dependency @redocly/cli to v2.54.2 (#286)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-28 16:05:07 +00:00
Adam Moussa
e9893a6f7b
docs(agents): drop security review gates (#284)
Agents no longer treat a security review or a cross-family review as a merge gate.
2026-09-26 17:17:13 -04:00
Adam Moussa
edfa34bfbf
feat(portal): store an optional note on swap requests (IP-132) (#283)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
* feat(portal): store an optional note on swap requests (IP-132)

* fix(portal): address review feedback

* fix(portal): address review feedback
2026-09-25 20:42:19 +00:00
Adam Moussa
470e00affb
feat(schedule): align the work week with Sunday-Saturday payroll (DEV-300) (#282)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
* feat(schedule): align the work week with Sunday-Saturday payroll

Saturday night stays in the week that ends Saturday, and the first Flex close skips dates already sent.

* fix(slack-bot): show the last pay close on Sunday

The Monday 7am row for the week that just ended is not written yet, so /oncall pay now falls back to the prior close.
2026-09-25 17:56:46 +00:00
Adam Moussa
7b5009fb55
fix(3cx): omit the bearer token on client-credentials login (#281)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
A refresh was posting the expired access token to /connect/token, and 3CX answered 400. The login request now drops that header.
2026-09-25 15:46:10 +00:00
renovate[bot]
b3fa705d8c
chore(deps): update dependency boto3 to >=1.43.99 (#278)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-24 23:29:34 +00:00
Adam Moussa
5981776178
fix(3cx): refresh the OAuth token before it expires (DEV-298) (#280)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
* fix(3cx): refresh the OAuth token before it expires

The worker kept one 3CX access token for the life of the process, so the 8am queue update failed with 401 after the one-hour token lifetime.

* fix(3cx): ignore a client secret this process already replaced

A slower caller still holding the pre-rotation secret could write it back over the new one. The swap now happens under the auth lock, and a retired secret is dropped.

* fix(3cx): adopt a new client secret only after login succeeds

A candidate secret is tried before it replaces the current one, so a revoked secret cannot stick and a later revert to a working secret still takes effect. A failed re-login after 401 returns the original API response.
2026-09-24 23:03:50 +00:00
renovate[bot]
5a173e911e
chore(deps): update dependency @redocly/cli to v2.53.3 (#277)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
* chore(deps): update dependency @redocly/cli to v2.53.3

* test(infra): stop pinning the Redocly CLI version

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-09-24 17:31:17 +00:00
renovate[bot]
9dd1dfc4c1
chore(deps): update terraform aws to ~> 6.66 (#279)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-24 17:17:17 +00:00
renovate[bot]
01c4902985
chore(deps): update dependency gunicorn to v26 (#272)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-24 17:14:01 +00:00
Adam Moussa
8a23d06a64
ci(workflows): call org reusable CI and Fargate CD (#276)
* ci(workflows): call org reusable CI and Fargate CD

Local CI and the image deploy duplicated the org workflows and still required ci / ci. Pin the callers to those workflows and trust the reusable deploy ref.

* test(ci): probe ruff with an undefined name

* test(ci): remove the undefined-name ruff probe

* ci: retrigger checks after removing the ruff probe

* test(ci): probe ruff with an unused import

* style: apply formatter

* test(ci): remove the autofix probe

---------

Co-authored-by: sea-haven-auto-fix[bot] <332630863+sea-haven-auto-fix[bot]@users.noreply.github.com>
2026-09-24 16:46:40 +00:00
Adam Moussa
e600dd47a3
chore(ci): remove unused Mergify stub (#275)
Some checks failed
Deploy API / Resolve target (push) Has been cancelled
Deploy API / Deploy API to (push) Has been cancelled
2026-09-22 18:41:40 +00:00
renovate[bot]
682f272c5a
chore(deps): update dependency boto3 to >=1.43.98 (#270)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-22 14:24:13 +00:00
renovate[bot]
b8079a1707
chore(deps): update github actions (#271)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-22 14:24:10 +00:00
renovate[bot]
4988fbe706
chore(deps): pin python docker tag to 2f17fc0 (#269)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-22 14:23:38 +00:00
Adam Moussa
c611fd5d2b
feat(api): add OpenAPI Redocly contract and VPC outputs (DEV-289) (#268)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
* feat(infra): export vpc_id and public subnet outputs (DEV-289)

Portal Fargate and meals already attach to this VPC. These outputs are
the HCP existing_vpc_id / existing_public_subnet_ids values.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* feat(api): add OpenAPI 3.1 and Redocly lint in CI (DEV-289)

Same extends: recommended ruleset and @redocly/cli 2.52.1 as
internal-portal. Covers health, roster, and portal /api/shifts.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(api): document 4xx and treat 302 as success in Redocly (DEV-289)

Health and CORS preflight document 400. Recommended only counted 2XX,
so login-style 302s use a shared 2XX-or-3XX rule.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

* fix(api): fail Redocly on missing 4xx and 2xx/3xx (DEV-289)

Promote operation-4xx-response and the 2xx-or-3xx success rule to error.
Drop unused 400s on health and CORS OPTIONS. Health documents 403 like the
portal. CORS stays in Flask and is not part of the employee contract.

Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
2026-09-22 00:34:15 +00:00
Adam Moussa
df3f0c037d
chore(iam): delete leftover Lambda execution roles (PLAT-218) (#267)
* chore(iam): delete leftover Lambda execution roles (PLAT-218)

* test(iam): keep leftover Lambda boundary after role deletion (PLAT-218)
2026-09-21 23:48:47 +00:00
Adam Moussa
26e9716df4
fix(ci): drop leftover changelog-guard and release-notifier (PLAT-219) (#266)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
Prod is already a human GitHub Release. Remove the SAM tagging path so CHANGELOG.md stays App Home copy and leftover notifier IAM is destroyed on the next apply.
2026-09-21 23:24:05 +00:00
Adam Moussa
be1160192c
fix(infra): allow hcptf apply to create ECS and ELB service-linked roles (PLAT-216) (#265) 2026-09-21 22:56:09 +00:00
Adam Moussa
cbda46ba87
chore(infra): remove API Gateway and Lambda dual-run (PLAT-216) (#264)
Origins already point at the Fargate hostnames. Drop the HTTP API, eight
functions, zip CD, and Lambda/API Gateway alarms while keeping leftover
Lambda IAM so Paychex can still name weekly-post.
2026-09-21 22:37:13 +00:00
Adam Moussa
6c4018d6b8
fix(infra): move hcptf ECS apply perms to a managed policy (PLAT-216) (#263)
Some checks failed
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
Deploy / Deploy to prod (push) Has been cancelled
PutRolePolicy cannot add a third inline on the prod apply role; CreatePolicy of /tf-managed/afterhours-shift-manager-ecs still needs the bootstrap window.
2026-09-21 20:47:25 +00:00
Adam Moussa
1362a6cd90
fix(infra): keep ecs-task-boundary CreatePolicy off live resources (PLAT-216) (#262)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
Deploy / Deploy to prod (push) Waiting to run
Bootstrap is an IAM factory. The boundary must not wait on DynamoDB, SQS, ECR, or the ECS log group.
2026-09-21 19:57:12 +00:00
Adam Moussa
d49c4bb4f2
fix(infra): split hcptf apply policy and omit empty queue ARNs (PLAT-216) (#261)
The combined services inline policy exceeded 10KB, and an empty
checkcomponents ARN made CreatePolicy reject the Lambda boundary in dev.
2026-09-21 19:46:36 +00:00
Adam Moussa
593cab66ef
fix(infra): own a dedicated VPC for Fargate (PLAT-216) (#260)
Prod has no default VPC; 10.70 is unused and matches the meals seam.
2026-09-21 19:30:54 +00:00
Adam Moussa
26adb8e6c0
feat(api): collapse Slack, portal, and jobs onto Fargate (PLAT-216) (#259)
* feat(api): collapse Slack, portal, and jobs onto Fargate (PLAT-216)

Move HTTP and scheduled work onto one always-on Flask task so after-hours
loses Lambda cold start without changing the Cognito or roster contracts.

* fix(portal-api): keep CORS headers on unexpected 500s

Portal SPA error handling needs Access-Control-Allow-Origin even when
DynamoDB or other internals fail, otherwise the browser hides the 500.

* fix(api): retarget holidays per account and ship App Home changelog (PLAT-216)

* fix(iam): list ECS tasks and fail closed on non-prod Paychex (PLAT-216)

* fix(portal-api): serve portal JSON with an explicit JSON content type
2026-09-21 19:13:30 +00:00
Adam Moussa
969ebf90de
feat(portal-api): add Cognito shift API for the employee portal (DEV-287) (#255)
Some checks are pending
Deploy / Deploy to prod (push) Waiting to run
* feat(portal-api): add Cognito shift API for the employee portal (DEV-287)

Employees and admins can pick, drop, swap, and manage coverage through
GET/POST/DELETE /api/shifts. Roster PUT accepts optional email for portal
identity. Slack slash commands and App Home admin modals stay in place.

Co-authored-by: adam <adam@seahavenind.com>

* fix(portal-api): preserve shift and deployment invariants (DEV-287)

Co-authored-by: adam <adam@seahavenind.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-21 17:50:57 +00:00
renovate[bot]
f1695cadfa
chore(deps): update pip minor and patch (#256)
Some checks are pending
Deploy / Deploy to prod (push) Waiting to run
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-21 15:42:56 +00:00
renovate[bot]
ea2910906f
chore(deps): update aws-actions/configure-aws-credentials action to v6.3.0 (#257)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-21 15:42:43 +00:00
renovate[bot]
0690767509
chore(deps): update terraform aws to ~> 6.65 (#258)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-21 15:42:29 +00:00
renovate[bot]
9d43e3be9d
chore(deps): update sea-haven-industries/.github action to v1.0.11 (#250)
Some checks failed
Deploy / Deploy to prod (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-16 16:38:44 +00:00
renovate[bot]
b53d856a75
chore(deps): update pip minor and patch (#251)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-16 16:38:24 +00:00
Adam Moussa
9544dd696a
fix(slack-bot): return 400 on malformed request bodies (#254)
Bolt parse_body raises JSONDecodeError for empty or non-JSON form
payload fields, which turned probe POSTs into unhandled Lambda 500s.

Fixes AFTERHOURS-SHIFT-MANAGER-2
2026-09-16 16:37:54 +00:00
Adam Moussa
7a513b30ca
fix(infra): match live githubdeploy OIDC and plan-role S3 Gets (PLAT-74) (#253)
Some checks are pending
Deploy / Deploy to prod (push) Waiting to run
* fix(infra): match live githubdeploy OIDC and plan-role S3 Gets (PLAT-74)

* fix(infra): pin githubdeploy job_workflow_ref to main (PLAT-74)
2026-09-16 00:48:37 +00:00
Adam Moussa
13350b72d0
feat(infra): migrate afterhours to HCP Terraform (PLAT-74) (#252)
Some checks are pending
Deploy / Deploy to prod (push) Waiting to run
* fix(cutover): write Slack secrets into empty Terraform shells

DescribeSecret succeeds on HCP-created shells with no version, so skip-if-exists left roster and Slack tokens unset.

* feat(infra): migrate afterhours to HCP Terraform (PLAT-74)

Replace the mgmt SAM stack with a prod-only HCP workspace, in-repo hcptf IAM, stub Lambdas, and zip CD on push to main.

* fix(cutover): retry DDB unprocessed items and skip past at() holidays

Unprocessed BatchWriteItem rows and leftover past at() schedules would drop roster data or abort holiday recreation during prod cutover.
2026-09-15 23:31:59 +00:00
Adam Moussa
dbef3bda52
chore(pay): remove weekly-post payroll email code, env, and failure alarm (#249)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
PR #248 disabled the SES send by blanking PAYROLL_RECIPIENTS and dropping the
ses:SendEmail grant. This removes the now-dead path: _send_pay_email and
_build_pay_email_html, the SES_SENDER and PAYROLL_RECIPIENTS env, and the
PayrollEmailFailure metric filter and alarm that only fired on that path.
Slack schedule post, pay-summary DM, and checkcomponents enqueue unchanged.
2026-09-10 19:58:59 +00:00
Adam Moussa
2dbe99ef0b
chore(pay): disable weekly-post payroll SES email (PLAT-135) (#248)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
Empty PAYROLL_RECIPIENTS and drop ses:SendEmail so Monday Slack posts stay, SES pay mail stops.
2026-09-10 19:30:00 +00:00
Adam Moussa
23bad9bee6
feat(roster): add HTTP PUT/DELETE roster API (PLAT-180) (#247)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
* feat(roster): add Bearer PUT/DELETE roster API

Identity hire needs to write Slack IDs onto roster rows without a stale
daily 3CX sync clearing them, using the existing HTTP client contract.

* fix(roster): strip Secrets Manager token whitespace

A file:// secret commonly includes a trailing newline, so compare_digest
must strip the cached value the same way it strips the Bearer header.
2026-09-09 21:13:27 +00:00
renovate[bot]
37f12421fd
chore(deps): update pip minor and patch (#246)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-09 18:40:59 +00:00
renovate[bot]
c52cfc2d0e
chore(deps): update aws-actions/configure-aws-credentials action to v6.2.4 (#245)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-09 18:40:16 +00:00
Adam Moussa
4ffa09bd3e
feat(pay): send after-hours lines to paychex checkcomponents (PLAT-154) (#244)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
* feat(pay): send after-hours lines to paychex checkcomponents (PLAT-154)

* style(pay): drop trailing blank line in weekly post tests

* fix(pay): skip duplicate checkcomponents send on weekly-post retry
2026-09-03 22:12:55 +00:00
renovate[bot]
244ceaff10
chore(deps): update github actions (#242)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-31 16:07:13 +00:00
renovate[bot]
42921aa2ba
chore(deps): update pip minor and patch (#243)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-31 16:06:56 +00:00
Adam Moussa
6eb2e52a74
feat(observability): add Sentry error reporting to Lambdas (#241)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
Unhandled errors and timeout warnings go to Sentry when SENTRY_DSN is set; Slack and 3CX secrets are stripped before send.
2026-08-29 20:52:28 +00:00
Adam Moussa
b048bfeaa1
chore(deps): remove dependabot version updates (#240)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
Renovate is the version-update bot on this Interactive repo. GitHub Dependabot alerts stay.
2026-08-25 11:50:55 -04:00
renovate[bot]
b30828b701
chore(deps): update dependency boto3 to >=1.43.78 (#238)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
2026-08-24 21:54:44 +00:00
Adam Moussa
f42a0f7505
chore(ci): remove pr policy workflow caller (#237)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
2026-08-24 15:11:53 -04:00
Adam Moussa
036267b98d
chore(ci): switch auto-merge from seahaven-bot to Mergify (#236)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
2026-08-24 13:52:43 -04:00
Adam Moussa
a91a1aa718
ci: enable squash auto-merge on ready PRs (PLAT-108) (#234)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
* ci: enable squash auto-merge on ready PRs

* fix(ci): serialize auto-merge enable and ignore already-enabled

* fix(ci): enqueue merge queue as seahaven-bot

* fix(ci): re-arm auto-merge as seahaven-bot after failed enqueue
2026-08-24 14:47:46 +00:00
Adam Moussa
50ab00947b
ci: add merge_group trigger for required ci / ci (#233)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
2026-08-21 17:42:19 -04:00
dependabot[bot]
dc96388fa6
chore(deps): update boto3 requirement in /src/slack-bot (#231)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
2026-08-19 03:56:51 +00:00
dependabot[bot]
84964aa983
chore(deps): update boto3 requirement in /src/shared (#230) 2026-08-19 03:54:30 +00:00
dependabot[bot]
b35730faa0
chore(deps): update boto3 requirement in /src/roster-sync (#229) 2026-08-19 03:51:45 +00:00
dependabot[bot]
1590aca846
chore(deps): update boto3 requirement in /src/ring-scheduler (#228) 2026-08-19 03:48:47 +00:00
dependabot[bot]
f599294e75
chore(deps): update boto3 requirement in /src/release-notifier (#227) 2026-08-19 03:46:07 +00:00
dependabot[bot]
f6369e71fd
chore(deps): update boto3 requirement in /src/holiday-router (#226) 2026-08-19 03:43:24 +00:00
dependabot[bot]
58795d90a9
chore(deps): update boto3 requirement in /src/weekly-post (#232) 2026-08-19 03:41:01 +00:00
dependabot[bot]
1748238ef3
chore(deps): bump the minor-and-patch group with 5 updates (#225)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
Bumps the minor-and-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml](https://github.com/sea-haven-industries/.github) | `1.0.6` | `1.0.7` |
| [Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml](https://github.com/sea-haven-industries/.github) | `1.0.6` | `1.0.7` |
| [Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml](https://github.com/sea-haven-industries/.github) | `1.0.6` | `1.0.7` |
| [Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml](https://github.com/sea-haven-industries/.github) | `1.0.6` | `1.0.7` |
| [Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml](https://github.com/sea-haven-industries/.github) | `1.0.6` | `1.0.7` |


Updates `Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

Updates `Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

---
updated-dependencies:
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-18 19:26:58 -04:00
dependabot[bot]
ece9947840
chore(deps): bump the minor-and-patch group with 5 updates (#217)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
Bumps the minor-and-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml](https://github.com/sea-haven-industries/.github) | `1.0.3` | `1.0.6` |
| [Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml](https://github.com/sea-haven-industries/.github) | `1.0.3` | `1.0.6` |
| [Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml](https://github.com/sea-haven-industries/.github) | `1.0.3` | `1.0.6` |
| [Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml](https://github.com/sea-haven-industries/.github) | `1.0.3` | `1.0.6` |
| [Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml](https://github.com/sea-haven-industries/.github) | `1.0.5` | `1.0.6` |


Updates `Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml` from 1.0.3 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](3f74677422...7ac3528750)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml` from 1.0.3 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](3f74677422...7ac3528750)

Updates `Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml` from 1.0.3 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](3f74677422...7ac3528750)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml` from 1.0.3 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](3f74677422...7ac3528750)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml` from 1.0.5 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](9c1ecf9428...7ac3528750)

---
updated-dependencies:
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-12 00:12:07 +00:00
dependabot[bot]
f3cd27aa18
chore(deps): update boto3 requirement in /src/holiday-router (#218)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.62...1.43.67)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.67
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-12 00:09:56 +00:00
dependabot[bot]
6141cd38be
chore(deps): update boto3 requirement in /src/release-notifier (#219)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.62...1.43.67)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.67
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-12 00:06:43 +00:00
dependabot[bot]
2b79148ded
chore(deps): update boto3 requirement in /src/ring-scheduler (#220)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.62...1.43.67)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.67
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-12 00:03:59 +00:00
dependabot[bot]
813c134ae9
chore(deps): update boto3 requirement in /src/roster-sync (#221)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.62...1.43.67)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.67
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-12 00:01:37 +00:00
dependabot[bot]
c1df33dbdc
chore(deps): update boto3 requirement in /src/shared (#222)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.62...1.43.67)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.67
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 23:59:11 +00:00
dependabot[bot]
7f5b85c774
chore(deps): update boto3 requirement in /src/slack-bot (#223)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.62...1.43.67)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.67
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 23:56:50 +00:00
dependabot[bot]
71c3dc1093
chore(deps): update boto3 requirement in /src/weekly-post (#224)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.62...1.43.67)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.67
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 19:54:19 -04:00
Adam Moussa
c28c63f06e
chore(deps): batch bumps for slack-bolt and test deps (DEV-27) (#216)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
* chore(deps): update slack-bolt requirement in /src/slack-bot

Updates the requirements on [slack-bolt](https://github.com/slackapi/bolt-python) to permit the latest version.
- [Release notes](https://github.com/slackapi/bolt-python/releases)
- [Commits](https://github.com/slackapi/bolt-python/compare/v1.29.0...v1.30.0)

---
updated-dependencies:
- dependency-name: slack-bolt
  dependency-version: 1.30.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): update pytest requirement from >=8.0 to >=9.1.1 in /tests

Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.0.0...9.1.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): update freezegun requirement in /tests

Updates the requirements on [freezegun](https://github.com/spulec/freezegun) to permit the latest version.
- [Release notes](https://github.com/spulec/freezegun/releases)
- [Changelog](https://github.com/spulec/freezegun/blob/master/CHANGELOG)
- [Commits](https://github.com/spulec/freezegun/compare/1.5.0...1.5.5)

---
updated-dependencies:
- dependency-name: freezegun
  dependency-version: 1.5.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): update responses requirement in /tests

Updates the requirements on [responses](https://github.com/getsentry/responses) to permit the latest version.
- [Release notes](https://github.com/getsentry/responses/releases)
- [Changelog](https://github.com/getsentry/responses/blob/master/CHANGES)
- [Commits](https://github.com/getsentry/responses/compare/0.25.0...0.26.2)

---
updated-dependencies:
- dependency-name: responses
  dependency-version: 0.26.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): update moto requirement from >=5.0 to >=5.2.2 in /tests

Updates the requirements on [moto](https://github.com/getmoto/moto) to permit the latest version.
- [Release notes](https://github.com/getmoto/moto/releases)
- [Changelog](https://github.com/getmoto/moto/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getmoto/moto/compare/5.0.0...5.2.2)

---
updated-dependencies:
- dependency-name: moto
  dependency-version: 5.2.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 20:26:37 -04:00
Adam Moussa
4b6c15644f
chore(deps): batch boto3 bumps to >=1.43.62 (DEV-25) (#210)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
* chore(deps): update boto3 requirement in /src/holiday-router

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.58...1.43.62)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.62
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): update boto3 requirement in /src/release-notifier

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.58...1.43.62)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.62
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): update boto3 requirement in /src/ring-scheduler

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.58...1.43.62)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.62
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): update boto3 requirement in /src/roster-sync

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.58...1.43.62)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.62
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): update boto3 requirement in /src/shared

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.58...1.43.62)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.62
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): update boto3 requirement in /src/slack-bot

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.58...1.43.62)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.62
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): update boto3 requirement in /src/weekly-post

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.58...1.43.62)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.62
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 14:24:23 -04:00
Adam Moussa
0b18972b20
ci: add org PR policy caller (#201)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
Refs: PLAT-62
2026-08-04 11:29:45 -04:00
Adam Moussa
6eea9a6f70
ci: pin checkout and setup-python actions to commit SHAs (#200)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
2026-07-29 15:52:39 +00:00
Adam Moussa
79a8f51ae1
Merge pull request #199 from Sea-Haven-Industries/chore/boto3-bump
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
chore: batch Dependabot boto3 bumps (#192, #193, #194, #195, #196)
2026-07-28 19:54:05 -04:00
dependabot[bot]
802fe88f53
chore(deps): update boto3 requirement in /src/holiday-router
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.53...1.43.58)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.58
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 19:46:51 -04:00
dependabot[bot]
94ab731331
chore(deps): update boto3 requirement in /src/release-notifier
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.53...1.43.58)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.58
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 19:46:51 -04:00
dependabot[bot]
3542e58f3d
chore(deps): update boto3 requirement in /src/ring-scheduler
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.53...1.43.58)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.58
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 19:46:51 -04:00
dependabot[bot]
af6c12026a
chore(deps): update boto3 requirement in /src/roster-sync
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.53...1.43.58)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.58
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 19:46:51 -04:00
dependabot[bot]
b0273e765f
chore(deps): update boto3 requirement in /src/shared
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.53...1.43.58)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.58
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 19:46:51 -04:00
dependabot[bot]
091d667d9c
chore(deps): update boto3 requirement in /src/slack-bot (#197)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.53...1.43.58)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.58
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
2026-07-28 23:43:20 +00:00
dependabot[bot]
4fd8c60f52
chore(deps): update boto3 requirement in /src/weekly-post (#198)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.53...1.43.58)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.58
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
2026-07-28 23:34:14 +00:00
dependabot[bot]
caa15a705d
chore(deps): bump the minor-and-patch group with 4 updates (#191)
Bumps the minor-and-patch group with 4 updates: [Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml](https://github.com/sea-haven-industries/.github) and [Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml](https://github.com/sea-haven-industries/.github).


Updates `Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

Updates `Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

---
updated-dependencies:
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 23:26:05 +00:00
dependabot[bot]
cd5de7bec5
chore(deps): bump aws-actions/configure-aws-credentials (#185)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
Bumps the minor-and-patch group with 1 update in the / directory: [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials).


Updates `aws-actions/configure-aws-credentials` from 6.2.2 to 6.2.3
- [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases)
- [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md)
- [Commits](517a711dbc...e6de054238)

---
updated-dependencies:
- dependency-name: aws-actions/configure-aws-credentials
  dependency-version: 6.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 22:19:28 +00:00
Adam Moussa
f4712e0bcf
ci(deps): pin org reusable workflows to v1.0.2 (#190)
* ci(deps): pin org reusable workflows to v1.0.2

* style(ci): normalize workflow block spacing
2026-07-28 18:15:29 -04:00
Adam Moussa
f996f9600b
fix: resolve code-scanning alerts 11-15 (workflow permissions + log taint) (#184)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
* ci(dependency-review): set explicit read-only GITHUB_TOKEN permissions

Resolves code-scanning alert 11 (actions/missing-workflow-permissions).
The callable workflow only needs contents: read.

* fix(logging): remove taint-flagged values from 3CX and roster-sync logs

Resolves code-scanning alerts 12-15 (py/clear-text-logging-sensitive-data).
CodeQL taints the 3CX response dicts via the Secrets Manager-sourced
domain in the request URL, so entity IDs subscripted from those
responses (ivr_id, resource_id, queue_id) and the roster result dict
trip the query. None of the flagged values are secrets, but the log
lines are rewritten so the pattern cannot trip: entity IDs are dropped
in favor of the untainted destination DNs, and the roster summary logs
counts instead of the member-derived dict (which also keeps employee
names out of the logs).

* fix: update ci workflow SHA to latest version

* fix(logging): drop employee-derived DNs from forwarding log

Resolves new code-scanning alerts 16/17. The closed/holiday DNs added
in the previous commit derive from roster employee lookups in the
Slack bot, so CodeQL classifies them as private data. Log only the
resource type; ring_scheduler already logs the queue number.
2026-07-27 13:48:14 -04:00
dependabot[bot]
fafefae500
Bump actions/setup-python from 6 to 7 (#175)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 19:31:01 -04:00
Adam Moussa
e769260598
chore: batch Dependabot boto3 bumps (#176, #177, #178, #179, #180, #181, #182) (#183)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
* Update boto3 requirement in /src/holiday-router

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.48...1.43.53)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.53
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update boto3 requirement in /src/release-notifier

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.48...1.43.53)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.53
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update boto3 requirement in /src/ring-scheduler

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.48...1.43.53)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.53
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update boto3 requirement from >=1.43.48 to >=1.43.53 in /src/roster-sync

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.48...1.43.53)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.53
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update boto3 requirement from >=1.43.48 to >=1.43.53 in /src/shared

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.48...1.43.53)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.53
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update boto3 requirement from >=1.43.48 to >=1.43.53 in /src/slack-bot

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.48...1.43.53)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.53
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update boto3 requirement from >=1.43.48 to >=1.43.53 in /src/weekly-post

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.48...1.43.53)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.53
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: gitignore .idea/

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 13:08:01 -04:00
dependabot[bot]
e3031c879b
Update boto3 requirement from >=1.43.43 to >=1.43.48 in /src/shared (#172)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.43...1.43.48)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.48
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 17:02:10 +00:00
dependabot[bot]
526c421455
Update boto3 requirement from >=1.43.43 to >=1.43.48 in /src/roster-sync (#171)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.43...1.43.48)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.48
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 16:57:25 +00:00
dependabot[bot]
2eefd46410
Update boto3 requirement in /src/release-notifier (#169)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.43...1.43.48)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.48
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 16:52:52 +00:00
dependabot[bot]
690d5fd73c
Update boto3 requirement from >=1.43.43 to >=1.43.48 in /src/slack-bot (#173)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.43...1.43.48)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.48
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 16:48:45 +00:00
dependabot[bot]
47930e6b74
Update boto3 requirement in /src/ring-scheduler (#170)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.43...1.43.48)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.48
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 16:43:58 +00:00
dependabot[bot]
08cf28933f
Update boto3 requirement from >=1.43.43 to >=1.43.48 in /src/weekly-post (#174)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.43...1.43.48)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.48
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 23:51:45 +00:00
dependabot[bot]
fce0fb2bf7
Update boto3 requirement in /src/holiday-router (#168)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.43...1.43.48)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.48
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 19:47:51 -04:00
seahaven-openswe[bot]
2202cde9ed
fix: delete+repost schedule on weekly rollover for bottom placement (#167)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
* fix: delete+repost schedule on weekly rollover for bottom placement

The Monday rollover was chat_update-ing in place, which only refreshes
content without moving the message to the bottom. Now it chat_deletes
the old post and chat_postMessages a fresh one so the schedule lands
at the bottom every Monday, independent of in-week activity.

Also added info-level logging to the bump handler silent return paths
so skipped bumps are observable at runtime.

* fix: roll back weekly repost when its ts can't be persisted

The Monday rollover deletes the old post then reposts a fresh one, but only
saved the new ts as its last step. If the save failed (or the Lambda died)
after the post landed, the async retry would read the stale, already-deleted
ts, no-op its delete, and post a second schedule — orphaning the first at the
bottom of the channel.

Wrap the save so a failure after a successful repost best-effort deletes the
fresh message before re-raising, letting the retry start clean. Mirrors the
orphan-avoidance the activity bump already has.

---------

Co-authored-by: seahaven-openswe[bot] <296972425+seahaven-openswe[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-07-10 16:36:47 -04:00
seahaven-openswe[bot]
7a8133fea3
fix: prepend "Boo, " to shift-drop Slack channel notification (#166)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
Co-authored-by: seahaven-openswe[bot] <296972425+seahaven-openswe[bot]@users.noreply.github.com>
2026-07-10 18:09:56 +00:00
dependabot[bot]
97c9cf1173
Update boto3 requirement from >=1.43.42 to >=1.43.43 in /src/shared (#165)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.42...1.43.43)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.43
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 21:55:36 +00:00
dependabot[bot]
51eb2ae0a8
Update boto3 requirement from >=1.43.42 to >=1.43.43 in /src/roster-sync (#164)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.42...1.43.43)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.43
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 21:53:05 +00:00
dependabot[bot]
0ca4ffeec2
Update slack-sdk requirement in /src/release-notifier (#163)
Updates the requirements on [slack-sdk](https://github.com/slackapi/python-slack-sdk) to permit the latest version.
- [Release notes](https://github.com/slackapi/python-slack-sdk/releases)
- [Commits](https://github.com/slackapi/python-slack-sdk/compare/v3.42.0...v3.43.0)

---
updated-dependencies:
- dependency-name: slack-sdk
  dependency-version: 3.43.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 17:50:48 -04:00
dependabot[bot]
f6c6bd8f5e
Update boto3 requirement in /src/release-notifier (#162)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.27...1.43.43)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.43
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 21:16:42 +00:00
dependabot[bot]
adc1436f8c
Update boto3 requirement in /src/holiday-router (#161)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.27...1.43.43)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.43
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-08 17:12:49 -04:00
Adam Moussa
3f6ac9654a
ci: expand dependabot coverage (INFRA-130) (#160)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
2026-07-08 16:53:42 -04:00
dependabot[bot]
3741a0c107
Update boto3 requirement from >=1.43.38 to >=1.43.42 in /src/roster-sync (#155)
* Update boto3 requirement from >=1.43.38 to >=1.43.42 in /src/roster-sync

Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.38...1.43.42)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.42
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update boto3 requirement from >=1.43.38 to >=1.43.42 in /src/shared (#156)

* Update boto3 requirement from >=1.43.39 to >=1.43.43 in /src/slack-bot (#157)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
2026-07-08 20:30:32 +00:00
dependabot[bot]
c6b5b0c76b
Update boto3 requirement in /src/ring-scheduler (#154) 2026-07-08 16:23:16 -04:00
dependabot[bot]
e396530227
Bump aws-actions/configure-aws-credentials in the minor-and-patch group (#153)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
2026-07-08 02:31:42 -04:00
Adam Moussa
e19a70b5df
chore(ci): SHA-pin org reusable-workflow caller refs (INFRA-50) (#152)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
2026-07-06 18:26:46 -04:00
Adam Moussa
48a61cad66
chore(ci): SHA-pin mutable-tag third-party actions (INFRA-118) (#151) 2026-07-06 18:26:18 -04:00
Adam Moussa
14f2bc2cd5
docs: link Confluence AWS Architecture Map (INFRA-53) (#150)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
2026-07-06 17:43:58 -04:00
126 changed files with 10581 additions and 2340 deletions

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
.git
.github
.venv
.cursor
terraform
tests
docs
*.md
!src/slack-bot/CHANGELOG.md
__pycache__
.pytest_cache
.mypy_cache

View file

@ -1,30 +0,0 @@
version: 2
updates:
- package-ecosystem: "pip"
directories:
- "/src/slack-bot"
- "/src/weekly-post"
- "/src/roster-sync"
- "/src/ring-scheduler"
- "/src/shared"
- "/tests"
schedule:
interval: "weekly"
assignees:
- "amoussa1229"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
assignees:
- "amoussa1229"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"

View file

@ -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@v7
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@v6
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

View file

@ -1,14 +1,106 @@
name: CI
on:
pull_request:
branches: [main]
branches: [main, hotfix/**, release/**]
merge_group:
push:
branches: [hotfix/**, release/**]
permissions:
contents: read
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
autofix:
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:
source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/ring-scheduler src/shared/shared tests"
run-tests: true
presets: ruff,terraform
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

View file

@ -1,6 +1,10 @@
name: Dependency Review
on:
pull_request:
permissions:
contents: read
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@main
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19

70
.github/workflows/deploy-api.yaml vendored Normal file
View 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

View file

@ -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@main
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@v7
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@v6
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@v6
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 }}"

View file

@ -2,10 +2,12 @@ name: Labeler
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@main
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19

5
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules/
__pycache__/
*.pyc
.aws-sam/
@ -7,3 +8,7 @@ venv/
.env
samconfig.toml
output.json
.idea/
build/
terraform/.terraform/
terraform/build/

32
.redocly.yaml Normal file
View 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

18
AGENTS.md Normal file
View file

@ -0,0 +1,18 @@
# Sea Haven Governance
## Standards and Authority
- **Handbook**: `engineering-handbook` is the standards authority for all conventions.
- **Jira**: work-status authority. Route product work → DEV, infrastructure/platform → PLAT, security → SEC. Search for duplicates before creating a ticket.
## Branches
Use one of: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `release/` + kebab-case description. Do not include a Jira key in the branch name.
## Pull Requests
- **Title format**: `type(scope): description (DEV-123)` — every non-exempt PR must end with its Jira key.
- **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.
## CI and Workflow References
- CI must pass before merge.
- Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment.

View file

@ -10,6 +10,37 @@ 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
**Monday pay totals now queue to Flex payroll posting.** The weekly post still
emails payroll and DMs the pay summary as before. After those go out, it also
sends last week's after-hours dollar lines (by extension, previous Monday
through Sunday) to the paychex-integrations checkcomponents queue. Unassigned
fallback extension 100 and $0 totals are left out. A queue failure does not
block the Monday schedule post. A retry or forced re-run of the same week does
not send the lines twice.
## v1.14.0 — July 2, 2026
**Point-and-click admin actions, right inside Slack.** Admins no longer have to

25
Dockerfile Normal file
View 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"]

200
README.md
View file

@ -1,7 +1,7 @@
# After-Hours Shift Manager
![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white)
![AWS SAM](https://img.shields.io/badge/AWS-SAM-FF9900?logo=amazonaws&logoColor=white)
![Terraform](https://img.shields.io/badge/Terraform-844FBA?logo=terraform&logoColor=white)
![Slack](https://img.shields.io/badge/Slack-integration-4A154B?logo=slack&logoColor=white)
![CI](https://github.com/Sea-Haven-Industries/afterhours-shift-manager/actions/workflows/ci.yaml/badge.svg)
@ -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.
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 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
| 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 register <ext>` | Link your Slack account to your phone extension |
| `/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 default <amount>` | Set the default per-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
- **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`)
- **IaC**: AWS SAM (`template.yaml`) with shared Lambda Layer
- **Slack**: Slack Bolt framework with `/oncall` slash command
- **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 on `POST /slack/events`
- **3CX Integration**: Queue routing updated directly via 3CX Queue XAPI
- **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-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-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-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
```
src/
slack-bot/ Slack Bolt Lambda (handler + app); ships CHANGELOG.md for App Home
weekly-post/ Monday schedule + pay post
roster-sync/ Daily 3CX roster sync
ring-scheduler/ 3CX queue routing updates
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
release-notifier/ Posts release announcements to Slack
shared/ Lambda Layer (schedule, blocks, changelog, 3CX client, secrets)
scripts/ changelog CLI + CI guard + in-package copy sync
tests/ pytest suite (mirrors src/, one dir per Lambda + shared)
server/ Flask + gunicorn + SQS worker (Fargate)
slack-bot/ Slack Bolt app; leftover zip source for packaging; ships CHANGELOG.md for App Home
weekly-post/ Monday 7am schedule post and prior Sunday-Saturday pay (leftover zip source)
roster-sync/ Daily 3CX roster sync (leftover zip source for packaging)
roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard (leftover zip source for packaging)
portal-api/ Cognito employee/admin shift API for the internal portal (leftover zip source for packaging)
ring-scheduler/ 3CX queue routing updates (leftover zip source for packaging)
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (leftover zip source for packaging)
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
@ -95,14 +106,14 @@ Single table with `PK` / `SK` keys:
| 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 |
| `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) |
| `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) |
| `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) |
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 —
`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
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**
no-input/timeout — into `CONFIG.captured_ivr_routes` (skipped if they already
@ -177,34 +188,71 @@ 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-client-id` | 3CX OAuth2 client ID |
| `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
The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This project's `afterhours-shift-manager` stack is represented there as a Mermaid subgraph.
- **[AWS Architecture Map](https://seahaven.atlassian.net/wiki/spaces/IT/pages/1540098)** (Confluence, IT space, page 1540098)
## Deployment
Merges to `main` are automatically deployed via **GitHub Actions** using reusable SAM workflows from the Sea Haven org.
For manual deploys:
```bash
sam build
sam deploy
```
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`.
## 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
is not paged. Alarm names follow the in-template convention `Lambda-<Metric>-<fn>`
(e.g. `Lambda-Errors-afterhours-ring-scheduler`).
is not paged. Alarm names follow `ALB-<Metric>-afterhours-shift-manager` and
`DDB-<Metric>-afterhours-shifts`.
**Lambda alarms** (all six functions: `afterhours-shift-manager`,
`afterhours-weekly-post`, `afterhours-roster-sync`, `afterhours-ring-scheduler`,
`afterhours-holiday-router`, `afterhours-release-notifier`):
**ALB alarms**:
| Alarm | Metric | Condition | Notes |
|---|---|---|---|
| `Lambda-Errors-<fn>` | `Errors` (Sum) | `>= 1` over one 5-min period | `TreatMissingData: notBreaching` |
| `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) |
| `Lambda-Throttles-<fn>` | `Throttles` (Sum) | `>= 1` over one 5-min period | `TreatMissingData: notBreaching` |
| Alarm | Metric | Condition |
|---|---|---|
| `ALB-5xx-afterhours-shift-manager` | `HTTPCode_Target_5XX_Count` (Sum) | `> 0` over one 5-min period |
| `ALB-Latency-afterhours-shift-manager` | `TargetResponseTime` (p99, s) | `>= 3` s, 2 of 3 5-min periods |
| `ALB-UnhealthyHost-afterhours-shift-manager` | `UnHealthyHostCount` (Maximum) | `> 0`, 3 of 3 1-min periods |
**DynamoDB alarm** (`afterhours-shifts` table):
@ -219,54 +267,24 @@ transition normally. `ThrottledRequests` and `SystemErrors` are intentionally
**not** alarmed: AWS emits them only at `TableName`+`Operation` granularity, so a
`TableName`-only alarm would sit permanently in `INSUFFICIENT_DATA`.
**API Gateway alarms** (implicit HTTP API `ServerlessHttpApi`, `AWS/ApiGateway`
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.
**API Gateway alarms** were removed with the Fargate cutover (PLAT-216).
## Releases & Versioning
The bot is versioned with SemVer, driven entirely by **`CHANGELOG.md`** — it is
the single source of truth for both the version number and the human-readable
notes. There is no separate tagging tool.
Prod is a human GitHub Release:
**To cut a release**, in your feature PR add a new `## vX.Y.Z — Month D, YYYY`
section at the top of `CHANGELOG.md` (plain language, written for on-call staff),
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.
```bash
gh release create vX.Y.Z --target main --generate-notes
```
On the **deploy-then-merge** path, once the merge's Deploy succeeds, the Deploy
workflow's `release` job (`needs: deploy`) tags the new version, publishes a
GitHub Release with the notes, and — for **minor and major** bumps only (patches
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.
That tag applies prod infra in HCP and queues `deploy-api.yaml` behind the prod
Environment. Merge to `main` deploys **dev**. Nothing in GitHub Actions creates
the Release.
> The release job lives inside the Deploy workflow (gated on `needs: deploy`)
> rather than a separate `workflow_run`-triggered workflow. A push-to-main run is
> a trusted context, so checking out and running repo code with write/OIDC is safe
> — whereas `workflow_run` is flagged by CodeQL for untrusted checkout. Gating on
> `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.
**App Home "What's New"** still reads **`CHANGELOG.md`**. That file is product
copy, not the prod tag driver. After editing the root file, run
`python scripts/sync_changelog.py` so `src/slack-bot/CHANGELOG.md` stays in
sync. Pytest fails if the two copies drift.
## Testing
@ -279,12 +297,26 @@ python -m venv .venv && source .venv/bin/activate
pip install -r tests/requirements.txt # test-only deps
pip install -r src/slack-bot/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
```
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
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.

129
SETUP.md
View file

@ -45,32 +45,89 @@ aws secretsmanager create-secret \
aws secretsmanager create-secret \
--name afterhours-shift-manager/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`
> deploy parameter in step 3, not stored in Secrets Manager or SSM.
Create `afterhours-shift-manager/roster-api-token` **before** the first deploy that
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
# Build and deploy. ShiftChannel is the Slack channel ID for schedule posts
# (right-click the channel in Slack → Copy link → the ID is the last segment).
sam build
sam deploy --guided \
--stack-name afterhours-shift-manager \
--region us-east-1 \
--parameter-overrides ShiftChannel=C0XXXXXXX QueueNumber=801
Daily roster-sync still removes DynamoDB rows that are not in the 3CX `DEFAULT`
group. Hire stays safe because 3CX create lands the extension in that group
before the identity processor PUTs `/roster`.
# 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
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
Do this in the cutover window, not before DynamoDB is copied.
## 5. Seed the Schedule
```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.
## 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
| Command | Description |

View 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).

View file

@ -80,13 +80,16 @@ is therefore not a substitute for bottom-stickiness.
### Weekly rollover — `src/weekly-post/app.py`
- **Drop the unconditional `chat_delete` + always-repost.** On Monday,
`chat_update` the stored post to roll the two-week window forward, keeping the
same `ts` (the activity bump is what moves it to the bottom; the rollover just
refreshes content).
- **First-run / recovery fallback:** when there is no stored post or the
`chat_update` fails (e.g. the message was deleted manually), `chat_postMessage`
a fresh message and store its `ts`.
- **Delete + repost on Monday** — the previous week's stored post is
`chat_delete`d and a fresh schedule is `chat_postMessage`d so the message
lands at the bottom of the channel every Monday regardless of in-week
activity. The delete failure is non-fatal; the handler always reposts.
- **First-run:** when there is no stored post to delete, the handler
`chat_postMessage`s a fresh message and stores its `ts`.
- **Repost rollback:** if the new `ts` can't be persisted after the repost has
landed, the fresh message is `chat_delete`d before the error propagates, so an
async retry (which would read the stale, already-deleted `ts`) can't orphan a
duplicate schedule at the bottom of the channel.
- The `pins:write` scope and the `_pin_schedule_post` helper are removed — the
pin is fully replaced by bottom-stickiness.
@ -123,16 +126,18 @@ request URL. On a `message.channels` event in the schedule channel
All three paths converge on the **single stored `ts`** and never fight:
- **Monday rollover** — `chat_update` the stored `ts` (content refresh; same
message), clearing `last_bump_ts` so the next activity is free to bump.
- **In-week shift edits** — `_refresh_schedule_post` `chat_update`s the same
- **Monday rollover** — `chat_delete`s the old stored `ts`, `chat_postMessage`s
a fresh post at the bottom, and saves the new `ts` (clearing `last_bump_ts` so
the next activity is free to bump).
- **In-week shift edits** — `_refresh_schedule_post` `chat_update`s the current
stored `ts`; it does not change the `ts` or touch `last_bump_ts`.
- **Activity bump** — deletes the stored `ts`, reposts the same content at the
bottom, and saves the new `ts`. Subsequent rollovers and edits then operate on
that new `ts`.
Because only the bump ever mints a new `ts` (and it always re-saves it
immediately), the other two paths always read the current `ts` from the record.
Both the rollover and the bump mint a new `ts` on repost and re-save it
immediately, so `_refresh_schedule_post` always reads the current `ts` from the
record.
## Scope / manifest impact

777
openapi.yaml Normal file
View 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
View 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
View 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"
}
}

View file

@ -2,6 +2,6 @@
# `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
# per-package conftest (importlib mode) to avoid the four-`app.py` collision.
pythonpath = ["src/shared"]
pythonpath = ["src", "src/shared"]
testpaths = ["tests"]
addopts = "--import-mode=importlib"

2
requirements-api.txt Normal file
View file

@ -0,0 +1,2 @@
flask==3.1.3
gunicorn==26.2.0

View file

@ -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

View file

@ -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))

View file

@ -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())

View 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())

View 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())

View 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())

View 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
View 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())

View file

@ -1,10 +1,9 @@
#!/usr/bin/env python3
"""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
package ($LAMBDA_TASK_ROOT), so a copy must live under src/slack-bot/ (the
function's CodeUri). The root file is the single source of truth; run this after
editing it. CI's check_changelog.py fails if the two drift.
The bot's App Home "What's New" tab reads CHANGELOG.md next to app.py in the
Fargate image, so a copy must live under src/slack-bot/. The root file is the
single source of truth; run this after editing it. Pytest fails if the two drift.
"""
import pathlib

View file

@ -3,31 +3,15 @@
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 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 logging
import os
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
import shared.sentry_init # noqa: F401
from shared.holiday_flow import activate, deactivate
from shared.schedule import ShiftSchedule
from shared.secrets import get_secret
from shared.three_cx_client import ThreeCXClient
@ -45,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:
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 = _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,
}
return activate(schedule, date, client_factory=_make_client)
def _deactivate(schedule: ShiftSchedule, date: str) -> dict:
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 = _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),
}
return deactivate(schedule, date, client_factory=_make_client)
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")
date = event.get("date")
logger.info("Holiday router invoked: action=%s date=%s", action, date)

View file

@ -1,2 +1,2 @@
boto3>=1.43.27
boto3>=1.43.99
requests>=2.34.2

93
src/portal-api/app.py Normal file
View 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"}}
),
}

View file

@ -0,0 +1,3 @@
PyJWT[crypto]==2.14.0
boto3>=1.43.99
requests>=2.34.2

View file

@ -1,45 +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
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"]}

View file

@ -1,2 +0,0 @@
slack_sdk>=3.42.0,<4.0
boto3>=1.43.27

View file

@ -10,6 +10,7 @@ import os
from datetime import datetime
from zoneinfo import ZoneInfo
import shared.sentry_init # noqa: F401
from shared.ring_scheduler import update_queue_routing
from shared.schedule import (
FALLBACK_EXTENSION,
@ -26,6 +27,10 @@ EASTERN = ZoneInfo("America/New_York")
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)
current_hour = now.hour
day_name = now.strftime("%A")

View file

@ -1,2 +1,2 @@
boto3>=1.43.38
boto3>=1.43.99
requests>=2.34.2

124
src/roster-api/app.py Normal file
View 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"})

View file

@ -0,0 +1 @@
boto3>=1.43.99

View file

@ -2,7 +2,7 @@
Runs daily via EventBridge. Pulls members from the configured 3CX group,
filters to Extension type only (excludes RingGroups, IVRs, Voicemail, etc.),
and syncs to DynamoDB. Preserves existing slack_user_id links.
and syncs to DynamoDB. Preserves existing slack_user_id and email links.
"""
import logging
@ -10,9 +10,10 @@ import os
from datetime import datetime
from zoneinfo import ZoneInfo
from shared.three_cx_client import ThreeCXClient
import shared.sentry_init # noqa: F401
from shared.schedule import ShiftSchedule
from shared.secrets import get_secret
from shared.three_cx_client import ThreeCXClient
logger = logging.getLogger()
logger.setLevel(logging.INFO)
@ -23,6 +24,10 @@ EXCLUDE_NAMES = {"Voicemail", "IVR", "Fax"}
def handler(event, context):
if os.environ.get("STAGE", "prod") != "prod":
logger.info("Skipping roster sync because STAGE is not prod")
return {"skipped": "non_prod"}
now = datetime.now(EASTERN)
# DST guard — two EventBridge rules fire, only one is at 6am ET
@ -88,14 +93,20 @@ def handler(event, context):
)
updated.append(f"Ext {number}: {existing['name']} -> {name}")
else:
schedule.table.put_item(
Item={
"PK": "ROSTER",
"SK": number,
"name": name,
"extension": number,
"slack_user_id": "",
}
# UpdateItem so a stale get_roster snapshot cannot PutItem-overwrite
# a roster-API row that landed after the read and wipe its Slack id.
schedule.table.update_item(
Key={"PK": "ROSTER", "SK": number},
UpdateExpression=(
"SET #n = :name, extension = :ext, "
"slack_user_id = if_not_exists(slack_user_id, :empty)"
),
ExpressionAttributeNames={"#n": "name"},
ExpressionAttributeValues={
":name": name,
":ext": number,
":empty": "",
},
)
added.append(f"Ext {number}: {name}")
@ -119,5 +130,12 @@ def handler(event, context):
"updated": updated,
"removed": removed,
}
logger.info("Roster sync complete: %s", result)
logger.info(
"Roster sync complete: group=%s total_3cx=%d added=%d updated=%d removed=%d",
group_name,
len(threecx_extensions),
len(added),
len(updated),
len(removed),
)
return result

View file

@ -1,2 +1,2 @@
boto3>=1.43.38
boto3>=1.43.99
requests>=2.34.2

1
src/server/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Always-on Flask process for afterhours-shift-manager."""

134
src/server/app.py Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
"""Gunicorn entrypoint."""
from server.app import app
__all__ = ["app"]

View file

@ -1,2 +1,4 @@
boto3>=1.43.38
boto3>=1.43.99
requests>=2.34.2
sentry-sdk==2.69.2

View file

@ -4,7 +4,7 @@ import re
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from shared.schedule import WEEKEND_DAYS
from shared.schedule import WEEKEND_DAYS, week_start
EASTERN = ZoneInfo("America/New_York")
@ -32,39 +32,6 @@ def markdown_to_mrkdwn(text: str) -> str:
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 = {
"day": "Day (8am–5pm)",
"night": "Night (5pm–8am)",
@ -149,11 +116,11 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
Args:
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)
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)
today_str = now.strftime("%Y-%m-%d")
@ -278,7 +245,7 @@ def build_shift_change_message(
if action == "picked_up":
text = f":white_check_mark: <@{user_id}> picked up the *{day_label}*{type_label} shift (Ext {ext})"
elif action == "dropped":
text = f":warning: <@{user_id}> dropped the *{day_label}*{type_label} shift — it's now *Available*"
text = f"Boo, :warning: <@{user_id}> dropped the *{day_label}*{type_label} shift — it's now *Available*"
elif action == "swapped":
text = f":arrows_counterclockwise: <@{user_id}> swapped into the *{day_label}*{type_label} shift (Ext {ext})"
else:
@ -287,8 +254,15 @@ def build_shift_change_message(
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def _mrkdwn_text(value: str) -> str:
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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]:
"""Build the interactive Accept / Decline message DMed to a swap target."""
dt = datetime.strptime(date_str, "%Y-%m-%d")
@ -299,15 +273,18 @@ def build_swap_request_blocks(
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 [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"<@{requester_slack}> wants you to cover the "
f"*{day_label}*{type_label} shift. Accept to take it on."
),
"text": text,
},
},
{
@ -344,9 +321,9 @@ def build_holiday_added_blocks(
) -> list[dict]:
"""Build the channel post announcing a newly scheduled holiday.
Mirrors :func:`build_release_announcement_blocks`: a header, an optional
context line, then a section describing the holiday day-shift (08:00–17:00
ET), its open slots, and the pay multiplier so people know to pick it up.
A header, an optional context line, then a section describing the holiday
day-shift (08:00–17:00 ET), its open slots, and the pay multiplier so people
know to pick it up.
Args:
date_str: the holiday date, ``YYYY-MM-DD``.

View file

@ -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
live in exactly one place:
* the Slack App Home tab — renders the newest entry ("What's New in vX.Y.Z"),
* 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 root CHANGELOG.md is the source of truth. ``scripts/sync_changelog.py``
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
``gh release create``; the changelog is not the prod tag driver.
The parser tolerates the file's leading preamble (prose before the first ``##``
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)))
def _normalize(version: str) -> str:
return version.lstrip("v")
def _strip_rules(body: str) -> str:
"""Drop ``---`` thematic-break lines from the body's edges.
@ -95,42 +89,3 @@ def latest_entry(text: str) -> Entry | None:
if entry.versions:
return entry
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

View 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

View 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"

View 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),
}

View 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"}},
)

View 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."}

View file

@ -2,7 +2,7 @@
import logging
from shared.three_cx_client import ThreeCXClient
from shared.three_cx_client import oauth_client
logger = logging.getLogger(__name__)
@ -15,12 +15,7 @@ def update_queue_routing(
client_secret: str,
) -> dict:
"""Update 3CX queue forwarding to route calls to the given extension."""
client = ThreeCXClient(
domain=domain,
auth_mode="oauth",
client_id=client_id,
client_secret=client_secret,
)
client = oauth_client(domain, client_id, client_secret)
queue = client.get_queue(queue_number)
client.update_queue_forwarding(
queue_id=queue["Id"],

View 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)

View file

@ -10,7 +10,7 @@ Single-table design:
"""
import os
from datetime import datetime
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
@ -36,6 +36,21 @@ DEFAULT_HOLIDAY_QUEUE = "802"
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:
"""Return the currently active shift type: 'day' or 'night'.
@ -70,6 +85,17 @@ class ShiftSchedule:
return item
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:
"""Link a Slack user to a roster extension.
@ -235,29 +261,32 @@ class ShiftSchedule:
requester: dict,
target: dict,
expires_at: int,
note: str | None = None,
) -> None:
"""Create (or supersede) a pending swap request for a shift.
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.
``note`` is omitted when empty so Slack ``/oncall swap`` stays unchanged.
"""
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.put_item(
Item={
"PK": "SWAP",
"SK": sk,
"shift_type": shift_type,
"status": "pending",
"requester_ext": requester["extension"],
"requester_name": requester["name"],
"requester_slack": requester.get("slack_user_id", ""),
"target_ext": target["extension"],
"target_name": target["name"],
"target_slack": target.get("slack_user_id", ""),
"created_at": datetime.now(EASTERN).isoformat(),
"expires_at": expires_at,
}
)
item = {
"PK": "SWAP",
"SK": sk,
"shift_type": shift_type,
"status": "pending",
"requester_ext": requester["extension"],
"requester_name": requester["name"],
"requester_slack": requester.get("slack_user_id", ""),
"target_ext": target["extension"],
"target_name": target["name"],
"target_slack": target.get("slack_user_id", ""),
"created_at": datetime.now(EASTERN).isoformat(),
"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:
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
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 ─────────────────────────────────────────────
@staticmethod
@ -364,6 +399,12 @@ class ShiftSchedule:
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
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 ────────────────────────────────────────────────────────
def get_holiday(self, date_str: str) -> dict | None:
@ -620,12 +661,27 @@ class ShiftSchedule:
# ── Pay records ─────────────────────────────────────────────────────
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})
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:
"""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})
# ── Config ──────────────────────────────────────────────────────────
@ -700,6 +756,37 @@ class ShiftSchedule:
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
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:
self.table.delete_item(Key={"PK": "ROSTER", "SK": extension})

View file

@ -0,0 +1,161 @@
"""Shared Sentry SDK init for every afterhours-shift-manager Lambda.
Imported for side effect from each handler. ``init_sentry()`` is a no-op when
``SENTRY_DSN`` is unset so pytest, local invokes, and a missing SAM parameter
never talk to Sentry. ``before_send`` strips auth and Slack signing headers,
drops request/extra keys that can hold Slack payloads or 3CX credential
material, and removes exception stack-frame locals.
``include_local_variables=False`` keeps those locals out of the event in the
first place.
"""
import os
import sentry_sdk
from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration
_HEADER_DROP_NAMES = frozenset(
{
"authorization",
"x-auth-token",
"cookie",
"x-amz-security-token",
"x-slack-signature",
}
)
_DROP_REQUEST_KEYS = frozenset(
{
"body",
"Body",
"data",
"cookies",
"raw_email",
"prompt",
"secret",
"SecretString",
"hmac",
"keys",
}
)
_DROP_EXTRA_NEEDLES = (
"body",
"email",
"prompt",
"secret",
"hmac",
"token",
"mime",
"raw_email",
"password",
"signing",
)
def _drop_header(name):
lower = str(name).lower()
return lower in _HEADER_DROP_NAMES or lower.startswith("x-amz-")
def _scrub_headers(headers):
if isinstance(headers, dict):
return {k: v for k, v in headers.items() if not _drop_header(k)}
if isinstance(headers, list):
kept = []
for pair in headers:
if isinstance(pair, (list, tuple)) and pair and _drop_header(pair[0]):
continue
kept.append(pair)
return kept
return headers
def _stacktraces(event):
traces = []
stacktrace = event.get("stacktrace")
if isinstance(stacktrace, dict):
traces.append(stacktrace)
for section in ("exception", "threads"):
container = event.get(section)
if not isinstance(container, dict):
continue
values = container.get("values")
if not isinstance(values, list):
continue
for item in values:
if not isinstance(item, dict):
continue
inner = item.get("stacktrace")
if isinstance(inner, dict):
traces.append(inner)
return traces
def _strip_stack_locals(event):
"""Drop frame locals. Names like ``raw``/``item`` still hold secrets."""
for stacktrace in _stacktraces(event):
frames = stacktrace.get("frames")
if not isinstance(frames, list):
continue
for frame in frames:
if isinstance(frame, dict):
frame.pop("vars", None)
def _before_send(event, _hint):
request = event.get("request")
if isinstance(request, dict):
headers = request.get("headers")
if headers is not None:
request["headers"] = _scrub_headers(headers)
for key in list(request):
if key in _DROP_REQUEST_KEYS or str(key).lower() in {"body", "data"}:
request.pop(key, None)
extra = event.get("extra")
if isinstance(extra, dict):
for key in list(extra):
lower = str(key).lower()
if any(needle in lower for needle in _DROP_EXTRA_NEEDLES):
extra.pop(key, None)
_strip_stack_locals(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():
dsn = os.environ.get("SENTRY_DSN")
if not dsn:
return
kwargs = {
"dsn": dsn,
"integrations": _integrations(),
"send_default_pii": False,
"include_local_variables": False,
"enable_logs": False,
"traces_sample_rate": 0.0,
"before_send": _before_send,
}
sha = _git_sha()
if sha:
kwargs["release"] = sha
sentry_sdk.init(**kwargs)
init_sentry()

View 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
)

View 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}",
)

View file

@ -1,8 +1,37 @@
import logging
import threading
import time
import requests
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:
"""Client for 3CX V20 cloud-hosted management API (XAPI)."""
@ -15,7 +44,14 @@ class ThreeCXClient:
auth_kwargs: credentials — see _authenticate_user / _authenticate_oauth
"""
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._raw_request = self.session.request
self.session.request = self._request
self.session.headers.update(
{
"OData-Version": "4.0",
@ -24,12 +60,66 @@ class ThreeCXClient:
)
if auth_mode == "oauth":
self._authenticate_oauth(
auth_kwargs["client_id"], auth_kwargs["client_secret"]
)
self._authenticate_oauth(self._client_id, self._client_secret)
else:
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):
"""Authenticate via extension/user credentials (any license tier)."""
resp = self.session.post(
@ -47,17 +137,29 @@ class ThreeCXClient:
def _authenticate_oauth(self, client_id: str, client_secret: str):
"""Authenticate via OAuth2 client credentials (Enterprise license required).
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",
data={
"client_id": client_id,
"client_secret": client_secret,
"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()
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}"})
logger.info("Authenticated to 3CX via OAuth2 client credentials")
@ -102,7 +204,7 @@ class ThreeCXClient:
json=payload,
)
resp.raise_for_status()
logger.info("Updated %s %s forwarding", resource, resource_id)
logger.info("Updated %s forwarding", resource)
return resp.status_code
def get_ring_group(self, extension_number: str) -> dict:
@ -142,7 +244,7 @@ class ThreeCXClient:
json=payload,
)
resp.raise_for_status()
logger.info("Set queue %s agents to %s", queue_id, extensions)
logger.info("Set queue agents to %s", extensions)
return resp.status_code
# ── IVR (auto-attendant) routing ─────────────────────────────────────
@ -207,9 +309,7 @@ class ThreeCXClient:
json=payload,
)
resp.raise_for_status()
logger.info(
"Set IVR (Receptionist) %s key-0=%s timeout=%s", ivr_id, key0_dn, timeout_dn
)
logger.info("Set IVR (Receptionist) key-0=%s timeout=%s", key0_dn, timeout_dn)
return resp.status_code
@staticmethod

View file

@ -10,6 +10,37 @@ 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
**Monday pay totals now queue to Flex payroll posting.** The weekly post still
emails payroll and DMs the pay summary as before. After those go out, it also
sends last week's after-hours dollar lines (by extension, previous Monday
through Sunday) to the paychex-integrations checkcomponents queue. Unassigned
fallback extension 100 and $0 totals are left out. A queue failure does not
block the Monday schedule post. A retry or forced re-run of the same week does
not send the lines twice.
## v1.14.0 — July 2, 2026
**Point-and-click admin actions, right inside Slack.** Admins no longer have to

View file

@ -7,7 +7,6 @@ is a thin wiring layer that registers the Bolt routes and delegates to them.
"""
import functools
import json
import logging
import os
import re
@ -45,6 +44,7 @@ from shared.schedule import (
ExtensionAlreadyRegistered,
ShiftSchedule,
determine_shift_type,
week_start,
)
from shared.secrets import get_secret
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:
"""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 <
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)
activate_holiday_inline(schedule, date_str)
def _holiday_schedule_names(date_str: str) -> tuple[str, str]:
"""The (activate, deactivate) one-off schedule names for a holiday date."""
compact = date_str.replace("-", "")
return f"holiday-activate-{compact}", f"holiday-deactivate-{compact}"
from shared.side_effects import holiday_schedule_names
return holiday_schedule_names(date_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
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
return create_holiday_schedules(date_str)
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:
"""Notification fallback text for the two-week schedule post."""
now = datetime.now(EASTERN)
this_monday = now - timedelta(days=now.weekday())
end_date = this_monday + timedelta(days=13)
start = week_start(now)
end_date = start + timedelta(days=13)
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')}"
)
@ -461,15 +400,25 @@ def handle_channel_message(event, client, schedule, schedule_channel, retry_num=
logger.info("Ignoring retried message event (retry %s)", retry_num)
return
if not schedule_channel or event.get("channel") != schedule_channel:
logger.info(
"Skipping bump — channel mismatch (expected %s, got %s)",
schedule_channel,
event.get("channel"),
)
return
# Ignore the bot's own posts and non-user message events (edits, deletes,
# joins, …), plus thread replies — a threaded reply doesn't push the
# schedule down the main timeline, so it isn't worth a delete+repost.
if event.get("bot_id") or event.get("subtype") or event.get("thread_ts"):
logger.info(
"Skipping bump — bot message, subtype %s, or thread reply",
event.get("subtype"),
)
return
post = schedule.get_schedule_post(schedule_channel)
if not post or not post.get("message_ts"):
logger.info("Skipping bump — no stored schedule post for %s", schedule_channel)
return
now = time.time()
@ -478,6 +427,11 @@ def handle_channel_message(event, client, schedule, schedule_channel, retry_num=
last_bump is not None
and now - float(last_bump) < SCHEDULE_BUMP_DEBOUNCE_SECONDS
):
logger.info(
"Skipping bump — debounced (last bump %.0fs ago, window %ds)",
now - float(last_bump),
SCHEDULE_BUMP_DEBOUNCE_SECONDS,
)
return
# Optimistically stamp the debounce window *before* the delete/repost, so a
@ -688,25 +642,50 @@ def _show_schedule(respond, schedule):
def _show_next_week(respond, schedule):
now = datetime.now(EASTERN)
# Jump 2 weeks ahead from this week's Monday
this_monday = now - timedelta(days=now.weekday())
next_start = this_monday + timedelta(days=14)
# The default view is already two weeks, so "next" starts two Sundays ahead.
next_start = week_start(now) + timedelta(days=14)
blocks = build_week_schedule(schedule, start_date=next_start)
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):
now = datetime.now(EASTERN)
# Show last completed week's pay (previous Monday–Sunday)
this_monday = now - timedelta(days=now.weekday())
prev_monday = this_monday - timedelta(days=7)
week_key = prev_monday.strftime("%Y-%m-%d")
pay_record = schedule.get_pay_record(week_key)
# The Monday 7am close writes the Sun–Sat week that ended Saturday. On
# Sunday, and on Monday before that close, that row is not written yet, so
# also try the prior week's Sunday key and the legacy Monday keys.
prev_start = week_start(now) - timedelta(days=7)
pay_record = None
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"):
prev_sunday = prev_monday + timedelta(days=6)
label_start, label_end = _pay_week_bounds(pay_record)
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(
week_label, pay_record["breakdown"], pay_record["totals"]
@ -714,7 +693,7 @@ def _show_pay(respond, schedule):
respond(blocks=blocks)
else:
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')}."
)
@ -2215,19 +2194,31 @@ def _admin_holiday_list(respond, schedule):
@functools.lru_cache(maxsize=1)
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
degrades to "no What's New section" rather than erroring. The copy lives at
``$LAMBDA_TASK_ROOT/CHANGELOG.md`` (synced from the repo root).
degrades to "no What's New section" rather than erroring. Lambda zips and
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")
try:
with open(path, encoding="utf-8") as fh:
return fh.read()
except OSError:
logger.warning("CHANGELOG.md not found at %s — App Home omits What's New", path)
return ""
tried = []
for path in _changelog_paths():
tried.append(path)
try:
with open(path, encoding="utf-8") as fh:
return fh.read()
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

View file

@ -1,10 +1,12 @@
"""Lambda handler — Slack Bolt app entry point."""
import json
import logging
import os
from slack_bolt.adapter.aws_lambda import SlackRequestHandler
import shared.sentry_init # noqa: F401
from app import create_app
from shared.secrets import get_secret
@ -32,4 +34,8 @@ def _get_handler() -> SlackRequestHandler:
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"}

View file

@ -1,2 +1,2 @@
slack_bolt>=1.29.0,<2.0
boto3>=1.43.39
slack_bolt>=1.30.0,<2.0
boto3>=1.43.99

View file

@ -1,6 +1,8 @@
"""Lambda handler — posts the weekly on-call schedule and previous week's pay summary
to Slack every Monday at 7am ET, and emails the pay summary to payroll."""
"""Lambda handler — posts the two-week on-call schedule and the previous
Sunday–Saturday pay summary to Slack every Monday at 7am ET, and enqueues
after-hours dollars for Flex."""
import json
import logging
import os
from datetime import datetime, timedelta
@ -10,24 +12,16 @@ from zoneinfo import ZoneInfo
import boto3
from slack_sdk import WebClient
import shared.sentry_init # noqa: F401
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
logger = logging.getLogger()
logger.setLevel(logging.INFO)
EASTERN = ZoneInfo("America/New_York")
DAY_ORDER = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
]
KIND_AFTER_HOURS = "after_hours"
def _record_pay_line(
@ -133,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:
"""Calculate pay for a Mon–Sun week. Returns pay record dict."""
def _calculate_weekly_pay(schedule: ShiftSchedule, week_start_dt: datetime) -> 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()
breakdown = []
totals = {}
for i in range(7):
date = week_start + timedelta(days=i)
day_name = DAY_ORDER[i]
date = week_start_dt + timedelta(days=i)
day_name = date.strftime("%A")
date_str = date.strftime("%Y-%m-%d")
# Day shifts (8am–5pm) exist on weekends and on holidays. Holidays are
@ -154,100 +153,146 @@ def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "night")
return {
"week_start": week_start.strftime("%Y-%m-%d"),
"week_start": week_start_dt.strftime("%Y-%m-%d"),
"default_rate": str(default_rate),
"breakdown": breakdown,
"totals": totals,
}
def _build_pay_email_html(week_label: str, pay_record: dict) -> str:
"""Build an HTML email body for the weekly pay summary.
def _already_sent_dates(schedule: ShiftSchedule, week_key: str) -> set[str]:
"""Dates already included in some other pay row that Flex has received.
Holiday shifts are rendered distinctly: a per-shift breakdown section
highlights holiday rows (shaded, showing the multiplier and label) and the
totals table flags any employee who worked a holiday during the week.
The current week's own row is ignored so a retry can rebuild it.
"""
breakdown = pay_record.get("breakdown", [])
totals = pay_record.get("totals", {})
sent: set[str] = set()
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 = ""
for name, info in sorted(totals.items()):
holiday_tag = (
' <span style="color:#b8860b;">&#9733; 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 = ""
def _rebuild_totals(breakdown: list[dict]) -> dict:
totals: dict = {}
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"):
mult = line.get("multiplier", Decimal("1"))
label = line.get("holiday_label", "") or "Holiday"
base = line.get("base_rate", line["rate"])
row_style = ' style="background:#fff8e1;"'
detail = f"{label} &mdash; ${base:.2f} &times; {mult:.2f}x"
totals[name]["holiday_shifts"] += 1
return totals
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:
row_style = ""
detail = f"${line['rate']:.2f}"
breakdown_rows += (
f"<tr{row_style}><td>{line['date_label']}</td>"
f"<td>{line['day']}</td>"
f"<td>{line['name']}</td>"
f"<td>{detail}</td>"
f"<td><strong>${line['amount']:.2f}</strong></td></tr>\n"
)
return f"""<html>
<body style="font-family: Arial, sans-serif; color: #333;">
<h2>Bonus Pay Summary &mdash; {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>"""
kept_days.append(day)
if not omitted:
return pay_record
pay_record["breakdown"] = [
line for line in pay_record["breakdown"] if str(line.get("date")) not in sent
]
pay_record["totals"] = _rebuild_totals(pay_record["breakdown"])
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 pay_record
def _send_pay_email(week_label: str, pay_record: dict) -> None:
"""Send the weekly pay summary email via SES."""
sender = os.environ.get("SES_SENDER", "noreply@seahaven.com")
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}},
},
def _pay_week_label(pay_record: dict) -> str:
start = datetime.strptime(
pay_record.get("window_start") or pay_record["week_start"], "%Y-%m-%d"
)
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:
"""Sibling dollar lines only. No Flex OAuth, no payPeriodId invention."""
week_start_date = datetime.strptime(pay_record["week_start"], "%Y-%m-%d").date()
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 = []
for info in pay_record.get("totals", {}).values():
ext = str(info.get("extension") or "").strip()
if not ext or ext == FALLBACK_EXTENSION:
continue
amount = Decimal(str(info.get("total") or 0))
if amount <= 0:
continue
lines.append(
{
"extension": ext,
"amount": format(amount.quantize(Decimal("0.01")), "f"),
}
)
return {
"type": "checkcomponents",
"kind": KIND_AFTER_HOURS,
"windowStart": week_start_date.isoformat(),
"windowEnd": window_end.isoformat(),
"lines": lines,
}
def _send_checkcomponents(pay_record: dict) -> bool:
"""Enqueue sibling dollar lines. Returns True if a message was sent."""
queue_url = os.environ.get("CHECKCOMPONENTS_QUEUE_URL", "").strip()
if not queue_url:
return False
payload = build_checkcomponents_payload(pay_record)
if not payload["lines"]:
return False
boto3.client("sqs").send_message(
QueueUrl=queue_url, MessageBody=json.dumps(payload)
)
return True
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)
# DST guard — same pattern as the 3CX scheduler
@ -264,21 +309,22 @@ def handler(event, context):
schedule = ShiftSchedule()
slack = WebClient(token=bot_token)
# --- Previous week's pay summary ---
prev_monday = now - timedelta(days=7)
prev_monday = prev_monday.replace(hour=0, minute=0, second=0, microsecond=0)
pay_record = _calculate_weekly_pay(schedule, prev_monday)
# --- Previous completed Sun–Sat pay summary ---
# Monday 7am is after Saturday night ends (Sunday 8am), so this week is closed.
prev_sunday = week_start(now) - timedelta(days=7)
pay_record = _calculate_weekly_pay(schedule, prev_sunday)
pay_dm_user = os.environ.get("PAY_REPORT_USER")
week_key = prev_sunday.strftime("%Y-%m-%d")
pay_record = _exclude_sent_dates(schedule, pay_record, week_key)
if pay_record["breakdown"]:
week_key = prev_monday.strftime("%Y-%m-%d")
existing = schedule.get_pay_record(week_key)
if existing and existing.get("checkcomponents_sent"):
pay_record["checkcomponents_sent"] = True
schedule.save_pay_record(week_key, pay_record)
prev_sunday = prev_monday + timedelta(days=6)
week_label = (
f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
)
week_label = _pay_week_label(pay_record)
pay_blocks = build_pay_summary_blocks(
week_label, pay_record["breakdown"], pay_record["totals"]
)
@ -296,63 +342,89 @@ def handler(event, context):
else:
logger.warning("PAY_REPORT_USER not set — skipping Slack pay summary")
# Email pay summary to payroll. Isolated so a delivery failure (e.g.
# an SES permission/identity issue) can never abort the rest of the
# handler — the Slack schedule post below must still go out.
try:
_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 (starts on Sunday of this Sun–Sat week) ---
this_sunday = week_start(now)
schedule_week = this_sunday.strftime("%Y-%m-%d")
blocks = build_week_schedule(schedule, start_date=this_sunday)
# --- Two-week schedule (always starts on Monday of this week) ---
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)
end_date = this_sunday + timedelta(days=13)
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')}"
)
# Roll the two-week window forward by editing the stored message in place
# (same ts) so the Monday rollover, in-week shift edits, and the activity
# bump all converge on a single stored ts. Fall back to a fresh post when
# there is no stored message or the edit fails (e.g. it was deleted).
# Delete the previous week's schedule post + repost a fresh one so the
# message lands at the bottom of the channel every Monday. The activity
# bump (handle_channel_message) handles in-week bottom-stickiness; this
# ensures the rollover itself re-places the post at the bottom.
old_post = schedule.get_schedule_post(channel_id)
message_ts = None
if old_post and old_post.get("message_ts"):
try:
slack.chat_update(
channel=channel_id,
ts=old_post["message_ts"],
blocks=blocks,
text=fallback_text,
)
message_ts = old_post["message_ts"]
slack.chat_delete(channel=channel_id, ts=old_post["message_ts"])
logger.info(
"Rolled schedule post %s forward to week of %s",
message_ts,
week_start,
"Deleted previous schedule post %s for weekly rollover",
old_post["message_ts"],
)
except Exception:
logger.warning(
"Could not update existing schedule post; reposting", exc_info=True
"Could not delete old schedule post for rollover; continuing",
exc_info=True,
)
if message_ts is None:
result = slack.chat_postMessage(
channel=channel_id, blocks=blocks, text=fallback_text
)
message_ts = result["ts"]
logger.info(
"Posted new weekly schedule to channel %s (ts=%s)", channel_id, message_ts
)
result = slack.chat_postMessage(
channel=channel_id, blocks=blocks, text=fallback_text
)
message_ts = result["ts"]
logger.info(
"Posted new weekly schedule to channel %s (ts=%s)", channel_id, message_ts
)
# Persist the new ts immediately. If the write fails after the post has
# already landed, roll the fresh message back before letting the error
# propagate — an async retry would otherwise read the stale (already
# deleted) ts, no-op its delete, and post a *second* schedule, orphaning
# this one at the bottom of the channel.
try:
schedule.save_schedule_post(channel_id, message_ts, schedule_week)
except Exception:
logger.warning(
"Failed to persist schedule post %s; rolling it back to avoid an "
"orphaned duplicate on retry",
message_ts,
exc_info=True,
)
try:
slack.chat_delete(channel=channel_id, ts=message_ts)
except Exception:
logger.warning(
"Could not roll back orphaned schedule post %s",
message_ts,
exc_info=True,
)
raise
# Enqueue after the schedule post is persisted so an EventBridge retry
# caused by save_schedule_post cannot send the same week twice. A sent
# flag on the pay record covers a later timeout or a forced re-run.
if pay_record["breakdown"] and not pay_record.get("checkcomponents_sent"):
sent = False
try:
sent = _send_checkcomponents(pay_record)
except Exception:
logger.exception(
"checkcomponents send failed week=%s",
week_key,
)
if sent:
try:
pay_record["checkcomponents_sent"] = True
schedule.save_pay_record(week_key, pay_record)
except Exception:
logger.exception(
"checkcomponents sent flag failed week=%s",
week_key,
)
schedule.save_schedule_post(channel_id, message_ts, week_start)
return {
"posted": True,
"channel": channel_id,

View file

@ -1,2 +1,2 @@
slack_sdk>=3.42.0,<4.0
boto3>=1.43.38
boto3>=1.43.99
slack_sdk>=3.44.1,<4.0

File diff suppressed because it is too large Load diff

61
terraform/.terraform.lock.hcl generated Normal file
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

194
terraform/iam.tf Normal file
View 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
}

View 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
View 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.

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View file

@ -25,6 +25,9 @@ def aws_env(monkeypatch):
monkeypatch.setenv("SHIFT_CHANNEL", "C_TEST")
monkeypatch.delenv("QUEUE_NUMBER", raising=False)
monkeypatch.delenv("TCX_SECRET_PREFIX", 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):

View 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

View file

@ -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 pathlib
@ -18,5 +18,5 @@ def _load(name, relpath):
@pytest.fixture
def notifier_app():
return _load("release_notifier_app", "src/release-notifier/app.py")
def portalapi_app():
return _load("portalapi_app", "src/portal-api/app.py")

View 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"

View file

@ -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()

View file

@ -1,7 +1,10 @@
# Test-only dependencies. The CI reusable workflow (ci-python-sam.yaml) installs
# every requirements.txt it finds when run-tests is true, so this file is picked
# up automatically alongside each Lambda's runtime requirements.
pytest>=8.0
moto[dynamodb,ses,secretsmanager]>=5.0
responses>=0.25
freezegun>=1.5
# Test-only dependencies. CI's pytest job installs this file plus the runtime
# requirements.txt files the imports need.
pytest>=9.1.1
moto[dynamodb,ses,secretsmanager]>=5.2.2
responses>=0.26.2
freezegun>=1.5.5
sentry-sdk==2.68.1
PyJWT[crypto]==2.14.0
flask==3.1.3

Some files were not shown because too many files have changed in this diff Show more