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"