diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c16e891..ceb0406 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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}" diff --git a/.gitignore b/.gitignore index e348d44..6d3bc0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +node_modules/ __pycache__/ *.pyc .aws-sam/ diff --git a/.redocly.yaml b/.redocly.yaml new file mode 100644 index 0000000..17d1df1 --- /dev/null +++ b/.redocly.yaml @@ -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 diff --git a/README.md b/README.md index d679592..57aa3fb 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,9 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules ## Architecture -- **Runtime**: Python 3.12 on ECS Fargate (arm64). Cutover completed 2026-09-21. seahaven-prod `011934824531`, seahaven-dev `710827005802` +- **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` @@ -301,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): diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..aa4adac --- /dev/null +++ b/openapi.yaml @@ -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" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..41fd326 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e93b89a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/terraform/locals.tf b/terraform/locals.tf index d725baf..319c9d9 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -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" diff --git a/terraform/outputs.tf b/terraform/outputs.tf index 20523e0..843b9d9 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -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 +} diff --git a/tests/infra/test_hcp_contract.py b/tests/infra/test_hcp_contract.py index 0ec1a48..1e69e9d 100644 --- a/tests/infra/test_hcp_contract.py +++ b/tests/infra/test_hcp_contract.py @@ -120,6 +120,29 @@ 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(): @@ -184,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():