diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c343804..e496392 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -30,6 +30,7 @@ jobs: 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 - name: Pytest run: pytest diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 9230425..8b8294f 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -75,6 +75,7 @@ jobs: "ring_scheduler", "holiday_router", "release_notifier", + "portal_api", ] for name in names: path = Path("build/packages") / f"{name}.zip" @@ -111,6 +112,7 @@ jobs: echo "ring_scheduler=$(aws ssm get-parameter --name "${prefix}/ring_scheduler-function-name" --query Parameter.Value --output text)" echo "holiday_router=$(aws ssm get-parameter --name "${prefix}/holiday_router-function-name" --query Parameter.Value --output text)" echo "release_notifier=$(aws ssm get-parameter --name "${prefix}/release_notifier-function-name" --query Parameter.Value --output text)" + echo "portal_api=$(aws ssm get-parameter --name "${prefix}/portal_api-function-name" --query Parameter.Value --output text)" } >> "${GITHUB_OUTPUT}" - name: Upload zips and update function code @@ -124,6 +126,7 @@ jobs: RING_SCHEDULER: ${{ steps.deploy.outputs.ring_scheduler }} HOLIDAY_ROUTER: ${{ steps.deploy.outputs.holiday_router }} RELEASE_NOTIFIER: ${{ steps.deploy.outputs.release_notifier }} + PORTAL_API: ${{ steps.deploy.outputs.portal_api }} run: | set -euo pipefail keys=( @@ -134,6 +137,7 @@ jobs: ring_scheduler:"${RING_SCHEDULER}" holiday_router:"${HOLIDAY_ROUTER}" release_notifier:"${RELEASE_NOTIFIER}" + portal_api:"${PORTAL_API}" ) for pair in "${keys[@]}"; do name="${pair%%:*}" diff --git a/CHANGELOG.md b/CHANGELOG.md index bc1eef0..8c701e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ fine and still supported. --- +## 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 diff --git a/README.md b/README.md index 902fc86..8b9c580 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ The weekly schedule post is updated live when shifts change, and the previous we 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 | @@ -70,7 +72,7 @@ Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules | `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-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX | -| `afterhours-roster-api` | API Gateway (PUT /roster, DELETE /roster/{extension}) | Bearer-authenticated roster upsert/delete for the identity processor | +| `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` | Skeleton only until tagging exists | Posts a "What's New" announcement to the shift channel | @@ -83,6 +85,7 @@ src/ 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) release-notifier/ Posts release announcements to Slack @@ -98,7 +101,7 @@ Single table with `PK` / `SK` keys: | PK | SK | Description | |---|---|---| -| `ROSTER` | `` | Employee: name, extension, slack_user_id | +| `ROSTER` | `` | Employee: name, extension, slack_user_id, optional email | | `WEEKLY` | `` | Default weekly schedule: extension, name | | `OVERRIDE` | `` | Date override from pickup/drop (or `OPEN`) | | `SWAP` | `` | Pending/verified swap request: requester, target, status, `expires_at` (TTL) | @@ -188,11 +191,33 @@ Identity hire/offboard in `paychex-integrations` calls this API. It is a separat | Method | Path | Body | Success | |---|---|---|---| -| PUT | `/roster` | `{"name","extension","slack_user_id"}` (all required strings) | 200 `{"ok":true}` | +| 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. @@ -218,10 +243,10 @@ All CloudWatch alarms are defined in `terraform/alarms.tf` and notify the shared is not paged. Alarm names follow `Lambda--` (e.g. `Lambda-Errors-afterhours-ring-scheduler`). -**Lambda alarms** (all seven functions: `afterhours-shift-manager`, +**Lambda alarms** (all eight functions: `afterhours-shift-manager`, `afterhours-weekly-post`, `afterhours-roster-sync`, `afterhours-roster-api`, `afterhours-ring-scheduler`, `afterhours-holiday-router`, -`afterhours-release-notifier`): +`afterhours-release-notifier`, `afterhours-portal-api`): | Alarm | Metric | Condition | Notes | |---|---|---|---| diff --git a/docs/portal-module-spec.md b/docs/portal-module-spec.md new file mode 100644 index 0000000..c107295 --- /dev/null +++ b/docs/portal-module-spec.md @@ -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). diff --git a/scripts/package_lambdas.py b/scripts/package_lambdas.py index 23ae68b..6f6fe23 100644 --- a/scripts/package_lambdas.py +++ b/scripts/package_lambdas.py @@ -28,6 +28,7 @@ FUNCTIONS = { "ring_scheduler": ROOT / "src" / "ring-scheduler", "holiday_router": ROOT / "src" / "holiday-router", "release_notifier": ROOT / "src" / "release-notifier", + "portal_api": ROOT / "src" / "portal-api", } SKIP_INSTALL_PREFIXES = ("boto3", "botocore") diff --git a/src/portal-api/app.py b/src/portal-api/app.py new file mode 100644 index 0000000..f0375df --- /dev/null +++ b/src/portal-api/app.py @@ -0,0 +1,261 @@ +"""HTTP API v2 handler — Cognito-authenticated employee/admin shift API.""" + +from __future__ import annotations + +import json +import logging +from decimal import Decimal +from typing import Any +from urllib.parse import unquote + +import shared.sentry_init # noqa: F401 +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() +logger.setLevel(logging.INFO) + +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(event: dict) -> dict[str, str]: + headers = event.get("headers") or {} + origin = "" + for key, value in headers.items(): + if str(key).lower() == "origin": + origin = "" if value is None else str(value) + break + allowed = origin if origin in CORS_ORIGINS else "" + out = { + "Content-Type": "application/json", + "Vary": "Origin", + } + if allowed: + out["Access-Control-Allow-Origin"] = allowed + out["Access-Control-Allow-Headers"] = "Authorization,Content-Type" + out["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS" + return out + + +def _response(event: dict, status: int, body: dict[str, Any] | None = None) -> dict: + return { + "statusCode": status, + "headers": _cors_headers(event), + "body": json.dumps({} if body is None else body, cls=DecimalEncoder, 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.rstrip("/") or "/" + + +def _json_body(event: dict) -> dict: + raw = event.get("body") + if raw in (None, ""): + return {} + if event.get("isBase64Encoded"): + import base64 + + raw = base64.b64decode(raw).decode("utf-8") + 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 _identity(event: dict) -> dict: + presented = _header(event, "authorization") + 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 _employee(schedule: ShiftSchedule, identity: dict) -> dict: + 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.", + ) + return employee + + +def handler(event, context): + try: + method, path = _route(event) + if method == "OPTIONS": + return _response(event, 204, {}) + identity = _identity(event) + schedule = ShiftSchedule() + if method == "GET" and path == "/api/shifts": + employee = schedule.get_employee_by_email(identity["email"]) + if not employee: + return _response( + event, + 200, + { + "linked": False, + "email": identity["email"], + "isAdmin": False, + }, + ) + params = event.get("queryStringParameters") or {} + week = (params.get("week") if isinstance(params, dict) else None) or "this" + return _response(event, 200, snapshot(schedule, employee, week)) + employee = _employee(schedule, identity) + body = _json_body(event) if method in {"POST", "PUT", "PATCH"} else {} + return _dispatch(event, method, path, schedule, employee, body) + except ActionError as exc: + return _response(event, exc.status, {"error": {"code": exc.code, "message": exc.message}}) + except Exception: + logger.exception("portal api unexpected failure") + return _response(event, 500, {"error": {"code": "INTERNAL", "message": "Internal error"}}) + + +def _dispatch(event, method, path, schedule, employee, body): + parts = [p for p in path.split("/") if p] + if method == "POST" and path == "/api/shifts/pick": + return _response( + event, + 200, + ops.pick(schedule, employee, body.get("date", ""), body.get("shiftType")), + ) + if method == "POST" and path == "/api/shifts/drop": + return _response( + event, + 200, + ops.drop(schedule, employee, body.get("date", ""), body.get("shiftType")), + ) + if method == "POST" and path == "/api/shifts/swap": + return _response( + event, + 200, + ops.swap( + schedule, + employee, + body.get("date", ""), + body.get("targetExtension", ""), + body.get("shiftType"), + ), + ) + if method == "POST" and len(parts) == 6 and parts[:3] == ["api", "shifts", "swaps"] and parts[5] in ("accept", "decline"): + return _response( + event, + 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 _response( + event, + 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 _response( + event, + 200, + ops.admin_open(schedule, employee, body.get("date", ""), body.get("shiftType")), + ) + if method == "POST" and path == "/api/shifts/admin/clear": + return _response( + event, + 200, + ops.admin_clear(schedule, employee, body.get("date", ""), body.get("shiftType")), + ) + if method == "POST" and path == "/api/shifts/admin/holidays": + return _response( + event, + 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 _response( + event, + 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 _response( + event, + 200, + ops.admin_pickup( + schedule, + employee, + unquote(parts[4]), + unquote(parts[5]), + unquote(parts[6]), + approve=parts[7] == "approve", + ), + ) + return _response(event, 405, {"error": {"code": "METHOD", "message": "Method not allowed"}}) diff --git a/src/portal-api/requirements.txt b/src/portal-api/requirements.txt new file mode 100644 index 0000000..ba4feb2 --- /dev/null +++ b/src/portal-api/requirements.txt @@ -0,0 +1,3 @@ +PyJWT[crypto]==2.14.0 +boto3>=1.43.93 +requests>=2.34.2 diff --git a/src/roster-api/app.py b/src/roster-api/app.py index ba7502c..924b891 100644 --- a/src/roster-api/app.py +++ b/src/roster-api/app.py @@ -1,6 +1,6 @@ """HTTP API v2 handler — Bearer-authenticated roster PUT/DELETE. -PUT /roster upsert name, extension, slack_user_id +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 @@ -25,10 +25,13 @@ logger = logging.getLogger() logger.setLevel(logging.INFO) 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 @@ -137,7 +140,20 @@ def _authorize(event: dict) -> None: raise AuthError -def _validate_put(raw: bytes) -> tuple[str, str, str]: +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: @@ -146,7 +162,8 @@ def _validate_put(raw: bytes) -> tuple[str, str, str]: raise ValueError("invalid json") from None if not isinstance(parsed, dict): raise TypeError("invalid json") - if set(parsed) != set(PUT_FIELDS): + 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: @@ -161,6 +178,15 @@ def _validate_put(raw: bytes) -> tuple[str, str, str]: 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") @@ -176,7 +202,7 @@ def _validate_put(raw: bytes) -> tuple[str, str, str]: or _has_disallowed_chars(slack_user_id, allow_space=False) ): raise ValueError("fields") - return name, extension, slack_user_id + return name, extension, slack_user_id, email def _delete_extension(event: dict) -> str: @@ -213,10 +239,12 @@ def handler(event, context): if raw is None: return _json_response(400, {"error": "invalid request"}) try: - name, extension, slack_user_id = _validate_put(raw) + name, extension, slack_user_id, email = _validate_put(raw) except (TypeError, ValueError): return _json_response(400, {"error": "invalid request"}) - ShiftSchedule().upsert_roster_entry(extension, name, slack_user_id) + ShiftSchedule().upsert_roster_entry( + extension, name, slack_user_id, email=email + ) logger.info("roster upserted extension=%s", extension) return _json_response(200, {"ok": True}) diff --git a/src/roster-sync/app.py b/src/roster-sync/app.py index 5461bf2..1b29d0b 100644 --- a/src/roster-sync/app.py +++ b/src/roster-sync/app.py @@ -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 diff --git a/src/shared/shared/cognito.py b/src/shared/shared/cognito.py new file mode 100644 index 0000000..11d4df4 --- /dev/null +++ b/src/shared/shared/cognito.py @@ -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 diff --git a/src/shared/shared/portal_ops.py b/src/shared/shared/portal_ops.py new file mode 100644 index 0000000..df12747 --- /dev/null +++ b/src/shared/shared/portal_ops.py @@ -0,0 +1,697 @@ +"""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 zoneinfo import ZoneInfo + +from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule +from shared.shift_clock import ( + EASTERN, + holiday_window_active, + is_today, + 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]: + now = datetime.now(EASTERN) + this_monday = (now - timedelta(days=now.weekday())).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + start = this_monday if which != "next" else this_monday + 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 + + +def _swap_payload(item: dict, my_ext: str) -> dict: + date_str, shift_type = _sk_to_date_shift(item.get("SK", "")) + return { + "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, + } + + +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, +) -> 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.") + expires_at = int(shift_start(date_str, resolved).timestamp()) + schedule.create_pending_swap(date_str, resolved, employee, target, expires_at) + 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"], + ) + 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."} diff --git a/src/shared/shared/schedule.py b/src/shared/shared/schedule.py index be19042..91db439 100644 --- a/src/shared/shared/schedule.py +++ b/src/shared/shared/schedule.py @@ -70,6 +70,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. @@ -280,6 +291,10 @@ 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 +379,10 @@ 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: @@ -701,23 +720,34 @@ class ShiftSchedule: return False def upsert_roster_entry( - self, extension: str, name: str, slack_user_id: str + 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``. An existing + 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="SET #n = :name, extension = :ext, slack_user_id = :sid", - ExpressionAttributeNames={"#n": "name"}, - ExpressionAttributeValues={ - ":name": name, - ":ext": extension, - ":sid": slack_user_id, - }, + UpdateExpression=expression, + ExpressionAttributeNames=names, + ExpressionAttributeValues=values, ) def remove_roster_entry(self, extension: str) -> None: diff --git a/src/shared/shared/shift_clock.py b/src/shared/shared/shift_clock.py new file mode 100644 index 0000000..22ab0c5 --- /dev/null +++ b/src/shared/shared/shift_clock.py @@ -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 + ) diff --git a/src/shared/shared/side_effects.py b/src/shared/shared/side_effects.py new file mode 100644 index 0000000..2a12c65 --- /dev/null +++ b/src/shared/shared/side_effects.py @@ -0,0 +1,290 @@ +"""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, + build_swap_request_blocks, + build_week_schedule, +) +from shared.ring_scheduler import update_queue_routing +from shared.schedule import FALLBACK_EXTENSION +from shared.secrets import get_secret +from shared.shift_clock import holiday_window_active, is_active_shift_type, is_today +from shared.three_cx_client import ThreeCXClient + +logger = logging.getLogger(__name__) +EASTERN = ZoneInfo("America/New_York") +SLACK_API = "https://slack.com/api" + + +def slack_token() -> str | 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: + 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: + 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: + 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 ThreeCXClient( + domain=get_secret(f"{secret_prefix}domain"), + auth_mode="oauth", + 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: + 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 create_holiday_schedules(date_str: str) -> list[str]: + 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 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={ + "Arn": router_arn, + "RoleArn": role_arn, + "Input": json.dumps({"action": action, "date": date_str}), + }, + ) + created.append(name) + except Exception: + logger.exception("Failed to create %s schedule for %s", action, date_str) + return created + + +def delete_holiday_schedules(schedule_names: list[str]) -> None: + 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) + this_monday = now - timedelta(days=now.weekday()) + end_date = this_monday + timedelta(days=13) + return ( + f"After-Hours Schedule — {this_monday.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) -> bool: + if not token or not target_slack: + return False + return slack_call( + "chat.postMessage", + token, + channel=target_slack, + blocks=build_swap_request_blocks(requester_slack, date_str, shift_type), + text=f"{requester_name} wants to swap you the {date_str} shift", + ) + + +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}", + ) diff --git a/src/slack-bot/CHANGELOG.md b/src/slack-bot/CHANGELOG.md index bc1eef0..8c701e1 100644 --- a/src/slack-bot/CHANGELOG.md +++ b/src/slack-bot/CHANGELOG.md @@ -10,6 +10,19 @@ fine and still supported. --- +## 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 diff --git a/terraform/apigateway.tf b/terraform/apigateway.tf index 92df917..6273f83 100644 --- a/terraform/apigateway.tf +++ b/terraform/apigateway.tf @@ -1,9 +1,22 @@ -# HTTP API: Slack events plus the Paychex roster contract. +# HTTP API: Slack events, Paychex roster contract, and portal shift API. resource "aws_apigatewayv2_api" "http" { name = local.project protocol_type = "HTTP" - description = "afterhours-shift-manager Slack and roster API" + description = "afterhours-shift-manager Slack, roster, and portal API" + + cors_configuration { + allow_origins = [ + "https://internal.seahaven.com", + "https://internal.dev.seahaven.com", + "http://localhost:5173", + "http://localhost:4173", + ] + allow_methods = ["GET", "POST", "DELETE", "OPTIONS"] + allow_headers = ["Authorization", "Content-Type"] + allow_credentials = false + max_age = 3600 + } } resource "aws_apigatewayv2_integration" "slack_bot" { @@ -24,6 +37,15 @@ resource "aws_apigatewayv2_integration" "roster_api" { timeout_milliseconds = 30000 } +resource "aws_apigatewayv2_integration" "portal_api" { + api_id = aws_apigatewayv2_api.http.id + integration_type = "AWS_PROXY" + integration_method = "POST" + integration_uri = aws_lambda_function.this["portal_api"].invoke_arn + payload_format_version = "2.0" + timeout_milliseconds = 30000 +} + resource "aws_apigatewayv2_route" "slack_events" { api_id = aws_apigatewayv2_api.http.id route_key = "POST /slack/events" @@ -42,6 +64,18 @@ resource "aws_apigatewayv2_route" "delete_roster" { target = "integrations/${aws_apigatewayv2_integration.roster_api.id}" } +resource "aws_apigatewayv2_route" "portal_shifts" { + api_id = aws_apigatewayv2_api.http.id + route_key = "ANY /api/shifts" + target = "integrations/${aws_apigatewayv2_integration.portal_api.id}" +} + +resource "aws_apigatewayv2_route" "portal_shifts_proxy" { + api_id = aws_apigatewayv2_api.http.id + route_key = "ANY /api/shifts/{proxy+}" + target = "integrations/${aws_apigatewayv2_integration.portal_api.id}" +} + resource "aws_apigatewayv2_stage" "default" { api_id = aws_apigatewayv2_api.http.id name = "$default" @@ -61,6 +95,8 @@ resource "aws_apigatewayv2_stage" "default" { aws_apigatewayv2_route.slack_events, aws_apigatewayv2_route.put_roster, aws_apigatewayv2_route.delete_roster, + aws_apigatewayv2_route.portal_shifts, + aws_apigatewayv2_route.portal_shifts_proxy, aws_iam_role_policy.hcptf_apply_services, ] } @@ -80,3 +116,11 @@ resource "aws_lambda_permission" "api_roster_api" { principal = "apigateway.amazonaws.com" source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*" } + +resource "aws_lambda_permission" "api_portal_api" { + statement_id = "AllowApiGatewayInvokePortalApi" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.this["portal_api"].function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*" +} diff --git a/terraform/lambda.tf b/terraform/lambda.tf index 6af6479..c7e3f9d 100644 --- a/terraform/lambda.tf +++ b/terraform/lambda.tf @@ -141,6 +141,42 @@ locals { 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 = { @@ -197,6 +233,23 @@ locals { 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, + )) + } } } diff --git a/terraform/locals.tf b/terraform/locals.tf index 7862357..1d68883 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -83,5 +83,12 @@ locals { timeout = 30 duration_ms = 24000 } + portal_api = { + function_name = "afterhours-portal-api" + role_name = "afterhours-shift-manager-portal-api" + handler = "app.handler" + timeout = 30 + duration_ms = 24000 + } } } diff --git a/terraform/variables.tf b/terraform/variables.tf index f6d1488..5cda090 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -64,3 +64,24 @@ variable "checkcomponents_queue_arn" { 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 = [] +} diff --git a/tests/infra/test_hcp_contract.py b/tests/infra/test_hcp_contract.py index 5f5cb9b..9198988 100644 --- a/tests/infra/test_hcp_contract.py +++ b/tests/infra/test_hcp_contract.py @@ -9,6 +9,7 @@ HCP_IAM = (TERRAFORM / "hcp_iam.tf").read_text() DEPLOY = (ROOT / ".github" / "workflows" / "deploy.yaml").read_text() CI = (ROOT / ".github" / "workflows" / "ci.yaml").read_text() LOCALS = (TERRAFORM / "locals.tf").read_text() +VARIABLES = (TERRAFORM / "variables.tf").read_text() def test_sam_template_removed(): @@ -68,7 +69,14 @@ def test_ci_runs_pytest_and_terraform_validate(): assert "terraform validate" in CI -def test_seven_functions_named(): +def test_checkcomponents_queue_arn_variable_matches_iam_references(): + assert 'variable "checkcomponents_queue_arn"' in VARIABLES + assert "var.checkcomponents_queue_arn" in LAMBDA_TF + boundary = (TERRAFORM / "lambda_boundary.tf").read_text() + assert "var.checkcomponents_queue_arn" in boundary + + +def test_eight_functions_named(): for name in ( "afterhours-shift-manager", "afterhours-weekly-post", @@ -77,6 +85,7 @@ def test_seven_functions_named(): "afterhours-ring-scheduler", "afterhours-holiday-router", "afterhours-release-notifier", + "afterhours-portal-api", ): assert name in LOCALS diff --git a/tests/portal_api/conftest.py b/tests/portal_api/conftest.py new file mode 100644 index 0000000..21555d2 --- /dev/null +++ b/tests/portal_api/conftest.py @@ -0,0 +1,22 @@ +"""Load src/portal-api/app.py under a unique module name.""" + +import importlib.util +import pathlib +import sys + +import pytest + +_ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def _load(name, relpath): + spec = importlib.util.spec_from_file_location(name, _ROOT / relpath) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def portalapi_app(): + return _load("portalapi_app", "src/portal-api/app.py") diff --git a/tests/portal_api/test_handler.py b/tests/portal_api/test_handler.py new file mode 100644 index 0000000..64b6c76 --- /dev/null +++ b/tests/portal_api/test_handler.py @@ -0,0 +1,89 @@ +"""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( + portalapi_app, + "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" diff --git a/tests/requirements.txt b/tests/requirements.txt index 0053fe4..e5fcf51 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -5,4 +5,5 @@ 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 diff --git a/tests/roster_api/test_handler.py b/tests/roster_api/test_handler.py index ae67e25..f2625d3 100644 --- a/tests/roster_api/test_handler.py +++ b/tests/roster_api/test_handler.py @@ -84,6 +84,32 @@ def test_put_writes_all_three_fields(rosterapi_app, schedule, secrets): assert emp["slack_user_id"] == "U123ABCDE" +def test_put_with_email_stores_lowercased_value(rosterapi_app, schedule, secrets): + result = rosterapi_app.handler( + _event(body=_put_body(email="Pat.Smith@Seahaven.com")), None + ) + assert result["statusCode"] == 200 + emp = schedule.get_employee_by_extension("110") + assert emp["email"] == "pat.smith@seahaven.com" + + +def test_put_without_email_preserves_existing_email( + rosterapi_app, schedule, seed, secrets +): + seed.roster("110", "Old Name", slack_user_id="", email="pat@seahavenind.com") + result = rosterapi_app.handler(_event(body=_put_body()), None) + assert result["statusCode"] == 200 + emp = schedule.get_employee_by_extension("110") + assert emp["email"] == "pat@seahavenind.com" + assert emp["slack_user_id"] == "U123ABCDE" + + +def test_400_invalid_email_domain(rosterapi_app, schedule, secrets): + result = rosterapi_app.handler(_event(body=_put_body(email="pat@gmail.com")), None) + assert result["statusCode"] == 400 + assert schedule.get_employee_by_extension("110") is None + + def test_put_updates_existing_and_preserves_shift_rate( rosterapi_app, schedule, seed, secrets ): diff --git a/tests/roster_sync/test_handler.py b/tests/roster_sync/test_handler.py index fd9b013..885acbb 100644 --- a/tests/roster_sync/test_handler.py +++ b/tests/roster_sync/test_handler.py @@ -26,7 +26,7 @@ def fake_3cx(rostersync_app, monkeypatch): def test_adds_removes_and_preserves_links( rostersync_app, schedule, seed, env, fake_3cx ): - seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com") seed.roster("115", "Bob", slack_user_id="U_BOB") # departs fake_3cx( [ @@ -60,7 +60,9 @@ def test_adds_removes_and_preserves_links( def test_renames_changed_member_preserving_link( rostersync_app, schedule, seed, env, fake_3cx ): - seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.roster( + "114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com" + ) fake_3cx([{"Number": "114", "MemberName": "Alicia", "Type": "Extension"}]) result = rostersync_app.handler({"force": True}, None) @@ -69,6 +71,7 @@ def test_renames_changed_member_preserving_link( emp = schedule.get_employee_by_extension("114") assert emp["name"] == "Alicia" assert emp["slack_user_id"] == "U_ALICE" + assert emp["email"] == "alice@seahavenind.com" def test_empty_3cx_result_does_not_wipe_roster( diff --git a/tests/shared/test_portal_ops.py b/tests/shared/test_portal_ops.py new file mode 100644 index 0000000..27966d9 --- /dev/null +++ b/tests/shared/test_portal_ops.py @@ -0,0 +1,196 @@ +"""Tests for portal shift mutations.""" + +from zoneinfo import ZoneInfo + +import freezegun +import pytest + +from shared.portal_ops import ( + ActionError, + admin_clear, + admin_holiday_add, + admin_open, + drop, + pick, + respond_swap, + snapshot, + swap, +) +from shared import side_effects as effects + +ET = ZoneInfo("America/New_York") + + +@pytest.fixture +def quiet_slack(monkeypatch): + monkeypatch.setattr(effects, "slack_token", lambda: None) + monkeypatch.setattr(effects, "refresh_schedule_post", lambda *a, **k: None) + monkeypatch.setattr(effects, "post_shift_change", lambda *a, **k: None) + monkeypatch.setattr(effects, "dm_late_pickup_admins", lambda *a, **k: 1) + monkeypatch.setattr(effects, "dm_swap_request", lambda *a, **k: True) + monkeypatch.setattr(effects, "dm_text", lambda *a, **k: None) + monkeypatch.setattr(effects, "maybe_repoint_today", lambda *a, **k: False) + + +def _alice(schedule, seed): + seed.roster( + "114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com" + ) + seed.roster("115", "Bob", slack_user_id="U_BOB", email="bob@seahavenind.com") + return schedule.get_employee_by_extension("114") + + +def _admin(schedule, seed): + employee = _alice(schedule, seed) + seed.config(admin_users=["U_ALICE"]) + return employee + + +def test_pick_open_shift(schedule, seed, quiet_slack): + employee = _alice(schedule, seed) + seed.open_shift("2026-06-10") + with freezegun.freeze_time("2026-06-08 12:00:00-04:00"): + result = pick(schedule, employee, "2026-06-10", "night") + assert result["ok"] is True + assert schedule.get_override("2026-06-10")["extension"] == "114" + + +def test_drop_lock(schedule, seed, quiet_slack): + employee = _alice(schedule, seed) + seed.override("2026-06-10", "114", "Alice") + with freezegun.freeze_time("2026-06-10 12:00:00-04:00"): + with pytest.raises(ActionError) as err: + drop(schedule, employee, "2026-06-10", "night") + assert err.value.code == "DROP_LOCK" + + +def test_swap_creates_pending(schedule, seed, quiet_slack): + employee = _alice(schedule, seed) + seed.override("2026-06-12", "114", "Alice") + with freezegun.freeze_time("2026-06-08 12:00:00-04:00"): + result = swap(schedule, employee, "2026-06-12", "115", "night") + assert result["ok"] is True + pending = schedule.get_swap("2026-06-12", "night") + assert pending["target_ext"] == "115" + snap = snapshot(schedule, schedule.get_employee_by_extension("115")) + assert snap["pendingSwaps"][0]["requesterExt"] == "114" + assert snap["pendingSwaps"][0]["incoming"] is True + mine = snapshot(schedule, employee) + assert mine["pendingSwaps"][0]["incoming"] is False + + +def test_late_pickup_creates_request(schedule, seed, quiet_slack): + employee = _alice(schedule, seed) + seed.open_shift("2026-06-10") + with freezegun.freeze_time("2026-06-10 20:00:00-04:00"): + result = pick(schedule, employee, "2026-06-10", "night") + assert result["latePickup"] is True + req = schedule.get_pickup_request("2026-06-10", "night", "114") + assert req["status"] == "pending" + + +def test_snapshot_includes_weekday_holiday_day_shift(schedule, seed): + employee = _alice(schedule, seed) + seed.holiday( + "2026-06-10", + assignees={"114": {"name": "Alice", "claimed_at": "now"}}, + ) + with freezegun.freeze_time("2026-06-08 12:00:00-04:00"): + snap = snapshot(schedule, employee) + wednesday = next(day for day in snap["days"] if day["date"] == "2026-06-10") + assert wednesday["slots"][0]["kind"] == "holiday" + assert wednesday["slots"][0]["shiftType"] == "day" + assert wednesday["slots"][0]["mine"] is True + + +def test_drop_accepts_day_for_holiday_slot(schedule, seed, quiet_slack): + employee = _alice(schedule, seed) + seed.holiday( + "2026-06-10", + assignees={"114": {"name": "Alice", "claimed_at": "now"}}, + ) + with freezegun.freeze_time("2026-06-08 06:00:00-04:00"): + result = drop(schedule, employee, "2026-06-10", "day") + assert result["ok"] is True + assert schedule.get_holiday("2026-06-10")["assignees"] == {} + + +def test_swap_accept_rechecks_resolved_holder(schedule, seed, quiet_slack): + employee = _alice(schedule, seed) + seed.roster("116", "Charlie", slack_user_id="U_CHARLIE") + seed.weekly("Friday", "114", "Alice") + with freezegun.freeze_time("2026-06-08 12:00:00-04:00"): + swap(schedule, employee, "2026-06-12", "115", "night") + seed.weekly("Friday", "116", "Charlie") + with pytest.raises(ActionError) as err: + respond_swap( + schedule, + schedule.get_employee_by_extension("115"), + "2026-06-12", + "night", + True, + ) + assert err.value.code == "CONFLICT" + assert schedule.get_override("2026-06-12") is None + assert schedule.get_swap("2026-06-12", "night") is None + + +def test_admin_open_repoints_active_shift_to_fallback( + schedule, seed, quiet_slack, monkeypatch +): + employee = _admin(schedule, seed) + calls = [] + monkeypatch.setattr( + effects, + "maybe_repoint_today", + lambda date, shift_type, extension: calls.append( + (date, shift_type, extension) + ) + or True, + ) + with freezegun.freeze_time("2026-06-10 20:00:00-04:00"): + result = admin_open(schedule, employee, "2026-06-10", "night") + assert result["repointed"] is True + assert calls == [("2026-06-10", "night", "100")] + + +def test_admin_clear_repoints_active_shift_to_resolved_holder( + schedule, seed, quiet_slack, monkeypatch +): + employee = _admin(schedule, seed) + seed.weekly("Wednesday", "115", "Bob") + seed.override("2026-06-10", "114", "Alice") + calls = [] + monkeypatch.setattr( + effects, + "maybe_repoint_today", + lambda date, shift_type, extension: calls.append( + (date, shift_type, extension) + ) + or True, + ) + with freezegun.freeze_time("2026-06-10 20:00:00-04:00"): + result = admin_clear(schedule, employee, "2026-06-10", "night") + assert result["repointed"] is True + assert calls == [("2026-06-10", "night", "115")] + + +def test_invalid_holiday_multiplier_rejected_before_schedules( + schedule, seed, quiet_slack, monkeypatch +): + employee = _admin(schedule, seed) + monkeypatch.setattr( + effects, "create_holiday_schedules", lambda _date: pytest.fail("called") + ) + with freezegun.freeze_time("2026-06-08 12:00:00-04:00"): + with pytest.raises(ActionError) as err: + admin_holiday_add( + schedule, + employee, + "2026-12-25", + 2, + "Christmas", + "not-a-number", + ) + assert err.value.code == "INVALID_MULTIPLIER" + assert schedule.get_holiday("2026-12-25") is None diff --git a/tests/shared/test_schedule.py b/tests/shared/test_schedule.py index 56b774d..d8a5955 100644 --- a/tests/shared/test_schedule.py +++ b/tests/shared/test_schedule.py @@ -217,7 +217,20 @@ class TestRoster: assert emp["slack_user_id"] == "U_ALICE" assert emp["shift_rate"] == "75" - def test_add_roster_entry_stays_create_only(self, schedule, seed): + def test_upsert_writes_email_and_lookup_is_case_insensitive(self, schedule): + schedule.upsert_roster_entry( + "114", "Alice", "U_ALICE", email="Alice@Seahavenind.com" + ) + emp = schedule.get_employee_by_email("alice@seahavenind.com") + assert emp["extension"] == "114" + assert emp["email"] == "Alice@Seahavenind.com" + + def test_upsert_without_email_preserves_existing_email(self, schedule, seed): + seed.roster("114", "Alice", slack_user_id="U_OLD", email="alice@seahavenind.com") + schedule.upsert_roster_entry("114", "Alicia", "U_ALICE") + emp = schedule.get_employee_by_extension("114") + assert emp["email"] == "alice@seahavenind.com" + assert emp["slack_user_id"] == "U_ALICE" seed.roster("114", "Alice", slack_user_id="U_ALICE", shift_rate="75") assert schedule.add_roster_entry("114", "Overwrite") is False emp = schedule.get_employee_by_extension("114")