fix(portal-api): preserve shift and deployment invariants (DEV-287)

Co-authored-by: adam <adam@seahavenind.com>
This commit is contained in:
Cursor Agent 2026-09-21 00:47:27 +00:00
parent 1ab787308e
commit 7fdad3a6d5
No known key found for this signature in database
4 changed files with 179 additions and 6 deletions

View file

@ -2,11 +2,12 @@
from __future__ import annotations
import re
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
from shared.schedule import WEEKEND_DAYS, ShiftSchedule
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
from shared.shift_clock import (
EASTERN,
holiday_window_active,
@ -113,7 +114,7 @@ def snapshot(schedule: ShiftSchedule, employee: dict, week: str = "this") -> dic
date_str = day.strftime(DATE_FMT)
day_name = day.strftime("%A")
slots = []
if day_name in WEEKEND_DAYS:
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(
@ -317,6 +318,8 @@ def drop(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str
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:
@ -483,6 +486,16 @@ def respond_swap(
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"],
@ -538,8 +551,13 @@ def admin_open(schedule, employee, date_str, shift_type) -> dict:
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, "message": "Shift marked open."}
return {
"ok": True,
"repointed": repointed,
"message": "Shift marked open.",
}
def admin_clear(schedule, employee, date_str, shift_type) -> dict:
@ -548,8 +566,16 @@ def admin_clear(schedule, employee, date_str, shift_type) -> dict:
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, "message": "Override cleared."}
return {
"ok": True,
"repointed": repointed,
"message": "Override cleared.",
}
def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) -> dict:
@ -567,6 +593,16 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) ->
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(
@ -574,7 +610,7 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) ->
slots=slot_count,
label=name,
created_by=employee.get("slack_user_id") or employee.get("email") or "",
multiplier=multiplier,
multiplier=multiplier_value,
schedule_names=names,
)
if not created:

View file

@ -59,6 +59,12 @@ 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."
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

View file

@ -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,6 +69,13 @@ def test_ci_runs_pytest_and_terraform_validate():
assert "terraform validate" in CI
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",

View file

@ -5,7 +5,17 @@ from zoneinfo import ZoneInfo
import freezegun
import pytest
from shared.portal_ops import ActionError, drop, pick, snapshot, swap
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")
@ -30,6 +40,12 @@ def _alice(schedule, seed):
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")
@ -71,3 +87,110 @@ def test_late_pickup_creates_request(schedule, seed, quiet_slack):
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