feat(portal): store an optional note on swap requests (IP-132) (#283)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled

* feat(portal): store an optional note on swap requests (IP-132)

* fix(portal): address review feedback

* fix(portal): address review feedback
This commit is contained in:
Adam Moussa 2026-09-25 20:42:19 +00:00 • committed by GitHub
parent 470e00affb
commit edfa34bfbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 173 additions and 25 deletions

View file

@ -542,6 +542,9 @@ components:
targetExtension: targetExtension:
type: string type: string
minLength: 1 minLength: 1
note:
type: string
description: Optional. Stored after trim, and the trimmed value must be 500 characters or fewer.
AdminOverrideBody: AdminOverrideBody:
type: object type: object
@ -698,6 +701,9 @@ components:
type: string type: string
incoming: incoming:
type: boolean type: boolean
note:
type: string
maxLength: 500
PendingPickup: PendingPickup:
type: object type: object

View file

@ -254,8 +254,15 @@ def build_shift_change_message(
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def _mrkdwn_text(value: str) -> str:
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def build_swap_request_blocks( def build_swap_request_blocks(
requester_slack: str, date_str: str, shift_type: str = "night" requester_slack: str,
date_str: str,
shift_type: str = "night",
note: str | None = None,
) -> list[dict]: ) -> list[dict]:
"""Build the interactive Accept / Decline message DMed to a swap target.""" """Build the interactive Accept / Decline message DMed to a swap target."""
dt = datetime.strptime(date_str, "%Y-%m-%d") dt = datetime.strptime(date_str, "%Y-%m-%d")
@ -266,15 +273,18 @@ def build_swap_request_blocks(
else "" else ""
) )
action_suffix = "_day" if shift_type == "day" else "" action_suffix = "_day" if shift_type == "day" else ""
text = (
f"<@{requester_slack}> wants you to cover the "
f"*{day_label}*{type_label} shift. Accept to take it on."
)
if note:
text += f"\n\nNote: {_mrkdwn_text(note)}"
return [ return [
{ {
"type": "section", "type": "section",
"text": { "text": {
"type": "mrkdwn", "type": "mrkdwn",
"text": ( "text": text,
f"<@{requester_slack}> wants you to cover the "
f"*{day_label}*{type_label} shift. Accept to take it on."
),
}, },
}, },
{ {

View file

@ -100,6 +100,7 @@ def dispatch(
body.get("date", ""), body.get("date", ""),
body.get("targetExtension", ""), body.get("targetExtension", ""),
body.get("shiftType"), body.get("shiftType"),
note=body.get("note"),
) )
if ( if (
method == "POST" method == "POST"

View file

@ -160,9 +160,25 @@ def snapshot(schedule: ShiftSchedule, employee: dict, week: str = "this") -> dic
return payload return payload
NOTE_MAX = 500
def _clean_swap_note(note: str | None) -> str | None:
if note is None:
return None
if not isinstance(note, str):
raise ActionError(400, "INVALID_NOTE", "Note must be text.")
cleaned = note.strip()
if not cleaned:
return None
if len(cleaned) > NOTE_MAX:
raise ActionError(400, "NOTE_TOO_LONG", "Note must be 500 characters or fewer.")
return cleaned
def _swap_payload(item: dict, my_ext: str) -> dict: def _swap_payload(item: dict, my_ext: str) -> dict:
date_str, shift_type = _sk_to_date_shift(item.get("SK", "")) date_str, shift_type = _sk_to_date_shift(item.get("SK", ""))
return { payload = {
"date": date_str, "date": date_str,
"shiftType": item.get("shift_type") or shift_type, "shiftType": item.get("shift_type") or shift_type,
"requesterExt": item.get("requester_ext", ""), "requesterExt": item.get("requester_ext", ""),
@ -171,6 +187,10 @@ def _swap_payload(item: dict, my_ext: str) -> dict:
"targetName": item.get("target_name", ""), "targetName": item.get("target_name", ""),
"incoming": item.get("target_ext") == my_ext, "incoming": item.get("target_ext") == my_ext,
} }
note = item.get("note")
if isinstance(note, str) and note.strip():
payload["note"] = note.strip()
return payload
def _pickup_payload(item: dict) -> dict: def _pickup_payload(item: dict) -> dict:
@ -419,6 +439,7 @@ def swap(
date_str: str, date_str: str,
target_extension: str, target_extension: str,
shift_type: str | None, shift_type: str | None,
note: str | None = None,
) -> dict: ) -> dict:
date = _parse_date(date_str) date = _parse_date(date_str)
date_str = date.strftime(DATE_FMT) date_str = date.strftime(DATE_FMT)
@ -454,8 +475,11 @@ def swap(
raise ActionError(400, "UNKNOWN_TARGET", "That extension is not on the roster.") raise ActionError(400, "UNKNOWN_TARGET", "That extension is not on the roster.")
if target["extension"] == employee["extension"]: if target["extension"] == employee["extension"]:
raise ActionError(400, "SELF_SWAP", "That shift is already yours.") raise ActionError(400, "SELF_SWAP", "That shift is already yours.")
cleaned_note = _clean_swap_note(note)
expires_at = int(shift_start(date_str, resolved).timestamp()) expires_at = int(shift_start(date_str, resolved).timestamp())
schedule.create_pending_swap(date_str, resolved, employee, target, expires_at) schedule.create_pending_swap(
date_str, resolved, employee, target, expires_at, note=cleaned_note
)
token = effects.slack_token() token = effects.slack_token()
if target.get("slack_user_id"): if target.get("slack_user_id"):
effects.dm_swap_request( effects.dm_swap_request(
@ -465,6 +489,7 @@ def swap(
date_str, date_str,
resolved, resolved,
employee["name"], employee["name"],
note=cleaned_note,
) )
return { return {
"ok": True, "ok": True,

View file

@ -261,15 +261,16 @@ class ShiftSchedule:
requester: dict, requester: dict,
target: dict, target: dict,
expires_at: int, expires_at: int,
note: str | None = None,
) -> None: ) -> None:
"""Create (or supersede) a pending swap request for a shift. """Create (or supersede) a pending swap request for a shift.
One swap per shift (unique SK), so a new request overwrites any prior One swap per shift (unique SK), so a new request overwrites any prior
pending one. ``expires_at`` is an epoch timestamp used for DynamoDB TTL. pending one. ``expires_at`` is an epoch timestamp used for DynamoDB TTL.
``note`` is omitted when empty so Slack ``/oncall swap`` stays unchanged.
""" """
sk = f"{date_str}-DAY" if shift_type == "day" else date_str sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.put_item( item = {
Item={
"PK": "SWAP", "PK": "SWAP",
"SK": sk, "SK": sk,
"shift_type": shift_type, "shift_type": shift_type,
@ -283,7 +284,9 @@ class ShiftSchedule:
"created_at": datetime.now(EASTERN).isoformat(), "created_at": datetime.now(EASTERN).isoformat(),
"expires_at": expires_at, "expires_at": expires_at,
} }
) if note:
item["note"] = note
self.table.put_item(Item=item)
def get_swap(self, date_str: str, shift_type: str = "night") -> dict | None: def get_swap(self, date_str: str, shift_type: str = "night") -> dict | None:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str sk = f"{date_str}-DAY" if shift_type == "day" else date_str

View file

@ -15,6 +15,7 @@ from shared.blocks import (
build_holiday_added_blocks, build_holiday_added_blocks,
build_pickup_request_blocks, build_pickup_request_blocks,
build_shift_change_message, build_shift_change_message,
_mrkdwn_text,
build_swap_request_blocks, build_swap_request_blocks,
build_week_schedule, build_week_schedule,
) )
@ -272,15 +273,19 @@ def dm_swap_request(
date_str: str, date_str: str,
shift_type: str, shift_type: str,
requester_name: str, requester_name: str,
note: str | None = None,
) -> bool: ) -> bool:
if not token or not target_slack: if not token or not target_slack:
return False return False
text = f"{requester_name} wants to swap you the {date_str} shift"
if note:
text += f"\nNote: {_mrkdwn_text(note)}"
return slack_call( return slack_call(
"chat.postMessage", "chat.postMessage",
token, token,
channel=target_slack, channel=target_slack,
blocks=build_swap_request_blocks(requester_slack, date_str, shift_type), blocks=build_swap_request_blocks(requester_slack, date_str, shift_type, note),
text=f"{requester_name} wants to swap you the {date_str} shift", text=text,
) )

View file

@ -259,6 +259,37 @@ class TestBuildSwapRequestBlocks:
assert elements[0]["style"] == "primary" # Accept assert elements[0]["style"] == "primary" # Accept
assert elements[1]["style"] == "danger" # Decline assert elements[1]["style"] == "danger" # Decline
def test_note_is_appended_without_changing_actions(self):
blocks = build_swap_request_blocks(
"U_REQ", "2026-06-03", "night", note="Family <commitment> & more"
)
assert "Note: Family &lt;commitment&gt; &amp; more" in blocks[0]["text"]["text"]
ids = self._action_ids(blocks)
assert ids == ["swap_accept_2026-06-03", "swap_decline_2026-06-03"]
def test_dm_swap_fallback_escapes_the_note(monkeypatch):
from shared import side_effects as effects
captured = {}
def fake_slack_call(_method, _token, **kwargs):
captured.update(kwargs)
return True
monkeypatch.setattr(effects, "slack_call", fake_slack_call)
assert effects.dm_swap_request(
"tok",
"U_REQ",
"U_TGT",
"2026-06-03",
"night",
"Alice",
note="ping <@U_ADMIN>",
)
assert "Note: ping &lt;@U_ADMIN&gt;" in captured["text"]
assert "<@U_ADMIN>" not in captured["text"]
class TestBuildSwapResolvedBlocks: class TestBuildSwapResolvedBlocks:
def test_renders_text_no_buttons(self): def test_renders_text_no_buttons(self):

View file

@ -75,6 +75,73 @@ def test_swap_creates_pending(schedule, seed, quiet_slack):
assert snap["pendingSwaps"][0]["incoming"] is True assert snap["pendingSwaps"][0]["incoming"] is True
mine = snapshot(schedule, employee) mine = snapshot(schedule, employee)
assert mine["pendingSwaps"][0]["incoming"] is False assert mine["pendingSwaps"][0]["incoming"] is False
assert "note" not in pending
assert "note" not in snap["pendingSwaps"][0]
def test_swap_stores_returns_and_dms_note(schedule, seed, quiet_slack, monkeypatch):
employee = _alice(schedule, seed)
seed.override("2026-06-12", "114", "Alice")
calls = []
def capture(*args, **kwargs):
calls.append((args, kwargs))
return True
monkeypatch.setattr(effects, "dm_swap_request", capture)
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
swap(
schedule,
employee,
"2026-06-12",
"115",
"night",
note=" Family commitment ",
)
pending = schedule.get_swap("2026-06-12", "night")
assert pending["note"] == "Family commitment"
snap = snapshot(schedule, employee)
assert snap["pendingSwaps"][0]["note"] == "Family commitment"
assert calls[0][1]["note"] == "Family commitment"
def test_swap_accepts_note_that_trims_to_500(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"):
swap(schedule, employee, "2026-06-12", "115", "night", note=f" {'a' * 500} ")
assert schedule.get_swap("2026-06-12", "night")["note"] == "a" * 500
def test_swap_omits_blank_note(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"):
swap(schedule, employee, "2026-06-12", "115", "night", note=" ")
pending = schedule.get_swap("2026-06-12", "night")
assert "note" not in pending
snap = snapshot(schedule, employee)
assert "note" not in snap["pendingSwaps"][0]
def test_swap_rejects_long_note(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"):
with pytest.raises(ActionError) as err:
swap(schedule, employee, "2026-06-12", "115", "night", note="a" * 501)
assert err.value.code == "NOTE_TOO_LONG"
assert schedule.get_swap("2026-06-12", "night") is None
def test_swap_rejects_non_string_note(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"):
with pytest.raises(ActionError) as err:
swap(schedule, employee, "2026-06-12", "115", "night", note=12)
assert err.value.code == "INVALID_NOTE"
assert schedule.get_swap("2026-06-12", "night") is None
def test_late_pickup_creates_request(schedule, seed, quiet_slack): def test_late_pickup_creates_request(schedule, seed, quiet_slack):