feat(portal-api): add Cognito shift API for the employee portal (DEV-287)

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

Co-authored-by: adam <adam@seahavenind.com>
This commit is contained in:
Cursor Agent 2026-09-21 00:36:31 +00:00
parent 9d43e3be9d
commit 1ab787308e
No known key found for this signature in database
28 changed files with 1941 additions and 30 deletions

View file

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

View file

@ -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%%:*}"

View file

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

View file

@ -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` | `<extension>` | Employee: name, extension, slack_user_id |
| `ROSTER` | `<extension>` | Employee: name, extension, slack_user_id, optional email |
| `WEEKLY` | `<DayName>` | Default weekly schedule: extension, name |
| `OVERRIDE` | `<YYYY-MM-DD>` | Date override from pickup/drop (or `OPEN`) |
| `SWAP` | `<YYYY-MM-DD>` | Pending/verified swap request: requester, target, status, `expires_at` (TTL) |
@ -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-<Metric>-<fn>`
(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 |
|---|---|---|---|

View file

@ -0,0 +1,12 @@
# Locked 2026-09-21 against Adam's answers.
Employee self-service plus admin After Hours in `internal-portal`. Slack stays.
Decisions:
- After Hours is a primary nav item (`/shifts`, `/shifts/admin`).
- Identity is roster `email` from Paychex `PUT /roster` (optional for backward compatibility; portal mapping needs it). Admin remains `get_admin_users()` Slack IDs after that lookup.
- Swap and late-pickup are in-portal pending actions and still DM on Slack.
- Slack App Home admin modals stay as-is.
Tickets: DEV-286 (epic), DEV-287 (API), DEV-288 (portal UI).

View file

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

261
src/portal-api/app.py Normal file
View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
Runs daily via EventBridge. Pulls members from the configured 3CX group,
filters to Extension type only (excludes RingGroups, IVRs, Voicemail, etc.),
and syncs to DynamoDB. Preserves existing slack_user_id links.
and syncs to DynamoDB. Preserves existing slack_user_id and email links.
"""
import logging

View file

@ -0,0 +1,175 @@
"""Verification for portal Cognito ID tokens (environment-configured trust)."""
from __future__ import annotations
import json
import logging
import os
import re
from functools import lru_cache
import jwt
from jwt import PyJWKClient
from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError, PyJWTError
logger = logging.getLogger(__name__)
ALLOWED_EMAIL_DOMAINS = {"seahavenind.com", "seahaven.com"}
_COGNITO_ISSUER_RE = re.compile(
r"^https://cognito-idp\.[a-z0-9-]+\.amazonaws\.com/[A-Za-z0-9_-]+$"
)
_UNVERIFIED_DECODE = {
"verify_signature": False,
"verify_exp": False,
"verify_aud": False,
}
class CognitoVerificationUnavailable(RuntimeError):
"""Trusted Cognito configuration or JWKS could not be loaded."""
def looks_like_cognito_token(token: str) -> bool:
try:
claims = jwt.decode(
token,
options=_UNVERIFIED_DECODE,
algorithms=["RS256"],
)
except PyJWTError:
return False
issuer = claims.get("iss")
return (
isinstance(issuer, str)
and bool(_COGNITO_ISSUER_RE.fullmatch(issuer.rstrip("/")))
and claims.get("token_use") == "id"
)
@lru_cache(maxsize=4)
def _jwk_client(issuer: str) -> PyJWKClient:
return PyJWKClient(
f"{issuer}/.well-known/jwks.json",
cache_keys=True,
lifespan=300,
timeout=5,
)
def verify_cognito_id_token(token: str) -> dict | None:
"""Verify a portal token and return trusted identity claims."""
if not token:
return None
try:
trusted = _trusted_clients()
except CognitoVerificationUnavailable:
raise
except Exception as exc:
logger.error("Failed to load Cognito verification configuration: %s", exc)
raise CognitoVerificationUnavailable from exc
if not trusted:
logger.error("Cognito verification is not configured")
return None
try:
unverified = jwt.decode(
token,
options=_UNVERIFIED_DECODE,
algorithms=["RS256"],
)
except PyJWTError as exc:
logger.warning("Cognito token rejected: %s", type(exc).__name__)
return None
issuer = unverified.get("iss")
if not isinstance(issuer, str):
logger.warning("Cognito token rejected: missing issuer")
return None
issuer = issuer.rstrip("/")
audience = trusted.get(issuer)
if not audience:
logger.warning("Cognito token rejected: untrusted issuer")
return None
try:
signing_key = _jwk_client(issuer).get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=audience,
issuer=issuer,
options={
"require": [
"aud",
"email",
"exp",
"iat",
"iss",
"token_use",
]
},
)
except PyJWKClientConnectionError as exc:
logger.error("Cognito JWKS is unavailable: %s", type(exc).__name__)
raise CognitoVerificationUnavailable from exc
except OSError as exc:
logger.error("Cognito JWKS is unavailable: %s", type(exc).__name__)
raise CognitoVerificationUnavailable from exc
except (PyJWKClientError, PyJWTError, TypeError, ValueError) as exc:
logger.warning("Cognito token rejected: %s", type(exc).__name__)
return None
return _identity_from_claims(claims)
def _trusted_clients() -> dict[str, str]:
trusted: dict[str, str] = {}
raw_trust = os.environ.get("PORTAL_COGNITO_TRUST", "").strip()
if raw_trust:
items = json.loads(raw_trust)
if not isinstance(items, list):
raise CognitoVerificationUnavailable
for item in items:
if not isinstance(item, dict):
continue
issuer = str(item.get("issuer", "")).rstrip("/")
audience = str(item.get("audience", "")).strip()
if _COGNITO_ISSUER_RE.fullmatch(issuer) and audience:
trusted[issuer] = audience
issuer = os.environ.get("PORTAL_COGNITO_ISSUER", "").rstrip("/")
audience = os.environ.get("PORTAL_COGNITO_AUDIENCE", "").strip()
if issuer and audience:
if not _COGNITO_ISSUER_RE.fullmatch(issuer):
if not trusted:
logger.error("Cognito issuer is invalid")
raise CognitoVerificationUnavailable
else:
trusted.setdefault(issuer, audience)
return trusted
def _identity_from_claims(claims: dict) -> dict | None:
if claims.get("token_use") != "id":
return None
email = claims.get("email")
if not isinstance(email, str) or not email.strip() or "@" not in email:
return None
email = email.strip()
if email.rsplit("@", 1)[1].lower() not in ALLOWED_EMAIL_DOMAINS:
return None
name = _display_name(claims, email)
if not name:
return None
return {"name": name, "email": email}
def _display_name(claims: dict, email: str) -> str | None:
for key in ("name", "given_name"):
value = claims.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
local = email.split("@", 1)[0].strip()
return local or None

View file

@ -0,0 +1,661 @@
"""Employee and admin shift mutations for the portal API."""
from __future__ import annotations
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
from shared.schedule import 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:
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 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:
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)
effects.refresh_schedule_post(schedule, effects.slack_token())
return {"ok": True, "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)
effects.refresh_schedule_post(schedule, effects.slack_token())
return {"ok": True, "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.")
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,
schedule_names=names,
)
if not created:
effects.delete_holiday_schedules(names)
raise ActionError(409, "EXISTS", "A holiday already exists on that date.")
if holiday_window_active(date_str):
effects.activate_holiday_inline(schedule, date_str)
holiday = schedule.get_holiday(date_str)
effects.post_holiday_added(
token, date_str, name, slot_count, holiday["multiplier"] if holiday else multiplier
)
effects.refresh_schedule_post(schedule, token)
return {"ok": True, "message": f"Scheduled {name}."}
def admin_holiday_remove(schedule, employee, date_str) -> dict:
require_admin(schedule, employee)
date = _parse_date(date_str)
date_str = date.strftime(DATE_FMT)
holiday = schedule.get_holiday(date_str)
if not holiday:
raise ActionError(404, "NOT_FOUND", "No holiday on that date.")
effects.delete_holiday_schedules(list(holiday.get("schedule_names") or []))
schedule.remove_holiday(date_str)
effects.refresh_schedule_post(schedule, effects.slack_token())
return {"ok": True, "message": "Holiday removed."}
def admin_pickup(schedule, employee, date_str, shift_type, extension, approve: bool) -> dict:
require_admin(schedule, employee)
date = _parse_date(date_str)
date_str = date.strftime(DATE_FMT)
shift_type = _shift_type(shift_type)
ext = (extension or "").strip()
req = schedule.get_pickup_request(date_str, shift_type, ext)
if not req or req.get("status") != "pending":
raise ActionError(404, "NOT_FOUND", "This pickup request is no longer pending.")
token = effects.slack_token()
if not approve:
schedule.clear_pickup_request(date_str, shift_type, ext)
if req.get("requester_slack"):
effects.dm_text(
token,
req["requester_slack"],
f"Your late pickup for {date_str} was denied.",
)
return {"ok": True, "message": "Pickup denied."}
if shift_ended(date_str, shift_type):
schedule.clear_pickup_request(date_str, shift_type, ext)
raise ActionError(409, "EXPIRED", "This pickup request expired.")
if not schedule.approve_pickup_request(date_str, shift_type, ext):
raise ActionError(409, "NOT_FOUND", "This pickup request is no longer pending.")
requester_name = req.get("requester_name", ext)
if req.get("is_holiday"):
claimed = schedule.claim_holiday_slot(date_str, ext, requester_name)
if not claimed:
schedule.clear_pickup_request(date_str, shift_type, ext)
raise ActionError(409, "FULL", "Couldn't assign the holiday slot.")
if holiday_window_active(date_str):
effects.set_holiday_queue_agents(schedule, date_str)
else:
claimed = schedule.claim_open_shift(date_str, ext, requester_name, shift_type)
if not claimed:
schedule.clear_pickup_request(date_str, shift_type, ext)
raise ActionError(409, "COVERED", "Couldn't assign the shift.")
effects.maybe_repoint_today(date_str, shift_type, ext)
schedule.clear_pickup_request(date_str, shift_type, ext)
if req.get("requester_slack"):
effects.dm_text(
token,
req["requester_slack"],
f"Your late pickup for {date_str} was approved.",
)
effects.post_shift_change(
token,
req.get("requester_slack", ""),
date_str,
"picked_up",
ext,
requester_name,
shift_type,
)
effects.refresh_schedule_post(schedule, token)
return {"ok": True, "message": "Pickup approved."}

View file

@ -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.
"""
self.table.update_item(
Key={"PK": "ROSTER", "SK": extension},
UpdateExpression="SET #n = :name, extension = :ext, slack_user_id = :sid",
ExpressionAttributeNames={"#n": "name"},
ExpressionAttributeValues={
names = {"#n": "name"}
values = {
":name": name,
":ext": extension,
":sid": slack_user_id,
},
}
expression = "SET #n = :name, extension = :ext, slack_user_id = :sid"
if email is not None:
expression += ", email = :email"
values[":email"] = email
self.table.update_item(
Key={"PK": "ROSTER", "SK": extension},
UpdateExpression=expression,
ExpressionAttributeNames=names,
ExpressionAttributeValues=values,
)
def remove_roster_entry(self, extension: str) -> None:

View file

@ -0,0 +1,47 @@
"""Eastern-time shift window helpers shared by Slack and the portal API."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from shared.schedule import determine_shift_type
EASTERN = ZoneInfo("America/New_York")
def is_today(date_str: str) -> bool:
return date_str == datetime.now(EASTERN).strftime("%Y-%m-%d")
def is_active_shift_type(shift_type: str) -> bool:
return determine_shift_type() == shift_type
def shift_start(date_str: str, shift_type: str) -> datetime:
d = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=EASTERN)
return d.replace(hour=8 if shift_type == "day" else 17)
def shift_started(date_str: str, shift_type: str) -> bool:
return datetime.now(EASTERN) >= shift_start(date_str, shift_type)
def shift_end(date_str: str, shift_type: str) -> datetime:
d = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=EASTERN)
if shift_type == "day":
return d.replace(hour=17)
return d.replace(hour=8) + timedelta(days=1)
def shift_ended(date_str: str, shift_type: str) -> bool:
return datetime.now(EASTERN) >= shift_end(date_str, shift_type)
def holiday_window_active(date_str: str) -> bool:
now = datetime.now(EASTERN)
return shift_start(date_str, "day") <= now < shift_end(date_str, "day")
def within_drop_lock(date_str: str, shift_type: str) -> bool:
return datetime.now(EASTERN) >= shift_start(date_str, shift_type) - timedelta(
hours=24
)

View file

@ -0,0 +1,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}",
)

View file

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

View file

@ -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}/*/*"
}

View file

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

View file

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

View file

@ -59,8 +59,23 @@ variable "checkcomponents_queue_url" {
default = "https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents"
}
variable "checkcomponents_queue_arn" {
description = "paychex-checkcomponents SQS ARN for WeeklyPost SendMessage."
variable "portal_cognito_issuer" {
description = "Trusted portal Cognito user-pool issuer for ID-token verification. Empty disables portal auth."
type = string
default = "arn:aws:sqs:us-east-1:011934824531:paychex-checkcomponents"
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 = []
}

View file

@ -68,7 +68,7 @@ def test_ci_runs_pytest_and_terraform_validate():
assert "terraform validate" in CI
def test_seven_functions_named():
def test_eight_functions_named():
for name in (
"afterhours-shift-manager",
"afterhours-weekly-post",
@ -77,6 +77,7 @@ def test_seven_functions_named():
"afterhours-ring-scheduler",
"afterhours-holiday-router",
"afterhours-release-notifier",
"afterhours-portal-api",
):
assert name in LOCALS

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,73 @@
"""Tests for portal shift mutations."""
from zoneinfo import ZoneInfo
import freezegun
import pytest
from shared.portal_ops import ActionError, drop, pick, 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 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"

View file

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