Compare commits

...

2 commits

Author SHA1 Message Date
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
12 changed files with 955 additions and 320 deletions

View file

@ -62,9 +62,29 @@ jobs:
- name: Terraform validate
run: terraform validate
openapi:
name: OpenAPI
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:
name: ci / ci
needs: [pytest, terraform]
needs: [pytest, terraform, openapi]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 5
@ -73,6 +93,7 @@ jobs:
env:
PYTEST_RESULT: ${{ needs.pytest.result }}
TERRAFORM_RESULT: ${{ needs.terraform.result }}
OPENAPI_RESULT: ${{ needs.openapi.result }}
run: |
set -euo pipefail
fail=0
@ -91,4 +112,5 @@ jobs:
}
check pytest "${PYTEST_RESULT}"
check terraform "${TERRAFORM_RESULT}"
check openapi "${OPENAPI_RESULT}"
exit "${fail}"

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules/
__pycache__/
*.pyc
.aws-sam/

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

View file

@ -59,15 +59,19 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
## Architecture
- **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**: 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 |
@ -81,17 +85,17 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules
```
src/
server/ Flask + gunicorn + SQS worker (Fargate)
slack-bot/ Slack Bolt app; Lambda handler until cutover; ships CHANGELOG.md for App Home
weekly-post/ Monday schedule + pay post
roster-sync/ Daily 3CX roster sync
roster-api/ HTTP PUT/DELETE /roster for identity hire/offboard
portal-api/ Cognito employee/admin shift API for the internal portal
ring-scheduler/ 3CX queue routing updates
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
shared/ Bundled into each function zip and the Fargate image
terraform/ HCP Terraform (Lambda skeletons, ECS/ALB, API, DDB, IAM, schedules)
slack-bot/ Slack Bolt app; leftover zip source for packaging; ships CHANGELOG.md for App Home
weekly-post/ Monday schedule + pay post (leftover zip source for packaging)
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 Lambda + shared + server)
tests/ pytest suite (mirrors src/, one dir per leftover zip handler + shared + server)
```
### DynamoDB Schema
@ -299,7 +303,8 @@ 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 pytest plus `terraform fmt` / `init -backend=false` / `validate`.
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):

View file

@ -115,9 +115,8 @@ 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`. Add `ecs_task_role_arn` to paychex-checkcomponents
(alongside `afterhours-shift-manager-weekly-post`) before Fargate weekly_post
runs. Dual-run keeps the Lambda principal until zip CD is retired.
`ecs_task_role_arn`. Paychex checkcomponents allows `afterhours-shift-manager-api`;
leftover weekly-post principal is gone.
## 4. Set the Slack Request URL

771
openapi.yaml Normal file
View file

@ -0,0 +1,771 @@
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
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
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.52.1"
}
},
"node_modules/@redocly/cli": {
"version": "2.52.1",
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.52.1.tgz",
"integrity": "sha512-gwfx1WelVDHU/cw+GkpLV9+xksOI3obJGgL1nUVrAFUyXNDxM+aifXDUZk7BfbOIpF69Sea2ant0cOqbQfg/KQ==",
"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.52.1"
}
}

View file

@ -1,291 +1,3 @@
# Leftover Lambda IAM roles from dual-run. The seven functions are gone;
# weekly-post remains so paychex-checkcomponents can keep that principal
# until a follow-up queue-policy apply drops it.
data "aws_iam_policy_document" "lambda_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
locals {
table_arn = aws_dynamodb_table.shifts.arn
lambda_identity = {
slack_bot = [
{
sid = "DdbCrud"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
resources = [local.table_arn, "${local.table_arn}/*"]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*"]
condition = null
},
{
sid = "HolidaySchedules"
actions = ["scheduler:CreateSchedule", "scheduler:DeleteSchedule", "scheduler:GetSchedule"]
resources = ["arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*"]
condition = null
},
{
sid = "PassHolidayScheduler"
actions = ["iam:PassRole"]
resources = [local.holiday_scheduler_role_arn]
condition = {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["scheduler.amazonaws.com"]
}
},
{
sid = "InvokeHolidayRouter"
actions = ["lambda:InvokeFunction"]
resources = [local.holiday_router_arn]
condition = null
},
]
weekly_post = concat(
[
{
sid = "DdbCrud"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
resources = [local.table_arn, "${local.table_arn}/*"]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/slack-bot-token-*"]
condition = null
},
],
var.checkcomponents_queue_arn == "" ? [] : [
{
sid = "CheckcomponentsSend"
actions = ["sqs:SendMessage"]
resources = [var.checkcomponents_queue_arn]
condition = null
},
],
),
roster_sync = [
{
sid = "DdbCrud"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
resources = [local.table_arn, "${local.table_arn}/*"]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/3cx-*"]
condition = null
},
]
roster_api = [
{
sid = "DdbWrite"
actions = ["dynamodb:UpdateItem", "dynamodb:DeleteItem"]
resources = [local.table_arn]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/roster-api-token-*"]
condition = null
},
]
ring_scheduler = [
{
sid = "DdbRead"
actions = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:DescribeTable"]
resources = [local.table_arn, "${local.table_arn}/*"]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/3cx-*"]
condition = null
},
]
holiday_router = [
{
sid = "DdbCrud"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
resources = [local.table_arn, "${local.table_arn}/*"]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/3cx-*"]
condition = null
},
]
portal_api = [
{
sid = "DdbCrud"
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:ConditionCheckItem"]
resources = [local.table_arn, "${local.table_arn}/*"]
condition = null
},
{
sid = "Secrets"
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:afterhours-shift-manager/*"]
condition = null
},
{
sid = "HolidaySchedules"
actions = ["scheduler:CreateSchedule", "scheduler:DeleteSchedule", "scheduler:GetSchedule"]
resources = ["arn:aws:scheduler:${var.aws_region}:${local.account_id}:schedule/default/holiday-*"]
condition = null
},
{
sid = "PassHolidayScheduler"
actions = ["iam:PassRole"]
resources = [local.holiday_scheduler_role_arn]
condition = {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["scheduler.amazonaws.com"]
}
},
{
sid = "InvokeHolidayRouter"
actions = ["lambda:InvokeFunction"]
resources = [local.holiday_router_arn]
condition = null
},
]
}
lambda_env = {
slack_bot = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
SLACK_SIGNING_SECRET = "afterhours-shift-manager/slack-signing-secret"
SHIFT_CHANNEL = var.shift_channel
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
QUEUE_NUMBER = var.queue_number
TZ = var.timezone
HOLIDAY_ROUTER_ARN = local.holiday_router_arn
HOLIDAY_SCHEDULER_ROLE_ARN = local.holiday_scheduler_role_arn
SENTRY_DSN = var.sentry_dsn
}
weekly_post = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
SHIFT_CHANNEL = var.shift_channel
PAY_REPORT_USER = var.pay_report_user
TZ = var.timezone
CHECKCOMPONENTS_QUEUE_URL = var.checkcomponents_queue_url
SENTRY_DSN = var.sentry_dsn
}
roster_sync = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
SYNC_GROUP = "DEFAULT"
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
roster_api = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
ROSTER_API_TOKEN_SECRET = "afterhours-shift-manager/roster-api-token"
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
ring_scheduler = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
QUEUE_NUMBER = var.queue_number
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
holiday_router = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
TZ = var.timezone
SENTRY_DSN = var.sentry_dsn
}
portal_api = {
SHIFT_TABLE = aws_dynamodb_table.shifts.name
SLACK_BOT_TOKEN_SECRET = "afterhours-shift-manager/slack-bot-token"
SHIFT_CHANNEL = var.shift_channel
TCX_SECRET_PREFIX = "afterhours-shift-manager/3cx-"
QUEUE_NUMBER = var.queue_number
TZ = var.timezone
HOLIDAY_ROUTER_ARN = local.holiday_router_arn
HOLIDAY_SCHEDULER_ROLE_ARN = local.holiday_scheduler_role_arn
SENTRY_DSN = var.sentry_dsn
PORTAL_COGNITO_ISSUER = var.portal_cognito_issuer
PORTAL_COGNITO_AUDIENCE = var.portal_cognito_audience
PORTAL_COGNITO_TRUST = 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,
))
}
}
}
resource "aws_iam_role" "lambda" {
for_each = local.functions
name = each.value.role_name
path = "/tf-managed/"
description = "Lambda execution role for ${each.value.function_name}"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = aws_iam_policy.lambda_boundary.arn
}
data "aws_iam_policy_document" "lambda" {
for_each = local.functions
dynamic "statement" {
for_each = local.lambda_identity[each.key]
content {
sid = statement.value.sid
effect = "Allow"
actions = statement.value.actions
resources = statement.value.resources
dynamic "condition" {
for_each = try(statement.value.condition, null) == null ? [] : [statement.value.condition]
content {
test = condition.value.test
variable = condition.value.variable
values = condition.value.values
}
}
}
}
}
resource "aws_iam_role_policy" "lambda" {
for_each = local.functions
name = each.key
role = aws_iam_role.lambda[each.key].id
policy = data.aws_iam_policy_document.lambda[each.key].json
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
for_each = local.functions
role = aws_iam_role.lambda[each.key].name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# 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

@ -15,9 +15,8 @@ locals {
artifacts_bucket_name = "afterhours-shift-manager-artifacts-${local.account_id}"
ssm_prefix = "/afterhours-shift-manager"
# Prod has no default VPC. 10.70 is unused in 011934824531
# (10.0 proposal-system, 10.20 payments-dashboard, 10.40 syslog,
# 10.60 meal-order-manager, 10.80 apm-wo).
# 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"

View file

@ -44,7 +44,7 @@ output "jobs_queue_arn" {
}
output "ecs_task_role_arn" {
description = "ECS task role. paychex-checkcomponents queue policy must allow this ARN before Fargate weekly_post."
description = "ECS task role. Paychex already allows this ARN."
value = aws_iam_role.ecs_task.arn
}
@ -57,3 +57,13 @@ 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
}

View file

@ -4,13 +4,16 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TERRAFORM = ROOT / "terraform"
LAMBDA_TF = (TERRAFORM / "lambda.tf").read_text()
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()
@ -18,7 +21,8 @@ def test_sam_template_removed():
def test_zip_cd_removed():
assert not (ROOT / ".github" / "workflows" / "deploy.yaml").exists()
assert 'resource "aws_lambda_function"' not in LAMBDA_TF
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()
@ -116,11 +120,35 @@ def test_ci_runs_pytest_and_terraform_validate():
assert "terraform fmt -check" in CI
assert "terraform init -backend=false" in CI
assert "terraform validate" 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": "2.52.1"' 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
assert "var.checkcomponents_queue_arn" in LAMBDA_TF
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()
@ -128,7 +156,22 @@ def test_checkcomponents_queue_arn_variable_matches_iam_references():
assert "checkcomponents_pair" in data_tf
def test_seven_function_names_still_on_leftover_roles():
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",
@ -140,10 +183,7 @@ def test_seven_function_names_still_on_leftover_roles():
):
assert name in LOCALS
assert "afterhours-release-notifier" not in LOCALS
def test_weekly_post_role_is_tf_managed_name():
assert 'role_name = "afterhours-shift-manager-weekly-post"' in LOCALS
assert "weekly_post" in LOCALS
def test_github_deploy_trust_covers_image_only():
@ -167,6 +207,9 @@ def test_origins_use_fargate_url():
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():