Compare commits

...

2 commits

Author SHA1 Message Date
seahaven-openswe[bot]
9ccde1e9f2 Add admin UI vs. in-Slack commands evaluation
Spike for #136: weigh a separate admin web UI against expanding the
in-Slack admin commands with Block Kit modals and an App Home admin
section. Recommends a phased path (Slack-native now, web UI only if
admin volume grows) and scopes both phases, keeping get_admin_users as
the authorization source of truth.

Refs: #136
2026-06-26 19:50:50 +00:00
seahaven-openswe[bot]
612572bde7 Reuse weekly schedule post instead of reposting
The Monday rollover deleted last week's schedule message and posted a
fresh one every week, which re-notified the channel, changed the
permalink, and dropped any thread/reactions. Roll the two-week window
forward by editing the stored message in place (the same chat_update
path in-week shift changes already use), falling back to a fresh, pinned
post on first run or when the edit fails. A native pin keeps it reachable
from the channel header now that it no longer rises on the weekly repost.

Records the evaluation behind this in docs/sticky-schedule-post.md.

Refs: #135
2026-06-26 19:33:05 +00:00
7 changed files with 435 additions and 27 deletions

View file

@ -10,6 +10,18 @@ fine and still supported.
---
## v1.13.0 — June 27, 2026
**The two-week schedule post now stays put instead of being reposted every
week.** Each Monday the bot used to delete last week's schedule message and post
a brand-new one — which pinged the channel again, changed the message's link,
and dropped any replies or reactions. Now it simply edits the existing post to
roll the two-week window forward, so the link stays stable, there's no weekly
re-notification, and any thread stays attached. The post is also pinned, so you
can always reach it from the channel header even after a busy day of chat. (The
pin needs a one-time app reinstall to switch on; until then everything else still
works.)
## v1.12.0 — June 26, 2026
**Clearer shift drops and an easier-to-read schedule.** Two small quality-of-life

200
docs/admin-ui-evaluation.md Normal file
View file

@ -0,0 +1,200 @@
# Evaluation: admin web UI vs. clarifying/expanding in-Slack admin commands
_Spike for issue #136 — decide how to make administration easier: a separate
**admin web UI**, or **clarifying/expanding the in-Slack admin commands** (Block
Kit modals + an App Home admin section). This document records the options, a
recommendation, and the scope of the follow-up build. It is a "decide and
recommend" spike — no admin-surface code ships in this PR. The route is left for
sign-off (see "Decision needed" at the end)._
## Current admin surface
Every admin action is a positional-text subcommand of `/oncall admin …`,
authorized by `is_admin = user_id in schedule.get_admin_users()`
(`src/shared/shared/schedule.py`, `get_admin_users`) and handled in
`_handle_admin()` / `_handle_admin_holiday()` (`src/slack-bot/app.py`):
- `admin override <date> <ext> [day|night]` — assign a shift
- `admin open <date> [day|night]` — mark a shift open
- `admin clear <date> [day|night]` — remove an override (revert to weekly)
- `admin roster add|remove|rename <ext> [name]`
- `admin holiday add <date> <slots> [x<mult>] <label>` / `holiday remove <date>` /
`holiday list`
A same-day `override` / `open` / `clear` also repoints 3CX (`_update_3cx_routing`),
and `holiday add` provisions the 08:00/17:00 one-off schedules and may activate
inline — so these commands have real side effects, which makes a confirmation
preview valuable.
### Pain points (from the issue, confirmed in code)
- **Positional and error-prone.** Order-sensitive args — most acutely
`holiday add <date> <slots> [x<mult>] <label>`, which hand-parses an optional
`x<mult>` token out of the middle of the label (`_admin_holiday_add`). On any
mistake the only feedback is a usage string.
- **Discoverability.** The single source of documentation is the static admin
block in `build_help_blocks(is_admin=True)` (`src/shared/shared/blocks.py`).
No pickers, no validation hints, no previews.
- **No structured input.** No date picker, no roster dropdown, no
confirmation/preview before a change lands (and, same-day, repoints 3CX).
- **No overview.** No calendar/grid of upcoming overrides + holidays to scan.
### What already exists (lowers the cost of the Slack-native path)
- **App Home tab is live.** `home_tab_enabled: true` in
`slack-app-manifest.yaml`; `build_home_view()` renders it and
`app_home_opened` re-publishes it (`src/slack-bot/app.py`). It currently shows
only the non-admin help — there is a natural place to add an admin section.
- **Interactivity is wired.** `settings.interactivity.is_enabled: true`, and the
bot already registers `@app.action(...)` handlers (pickup/swap buttons). Adding
`@app.action(...)` for admin buttons and `@app.view(...)` for modal submits is
the same mechanism — **no new hosting, scope, or reinstall** is required for
modals (`views.open`/`view_submission` ride the existing interactivity URL).
- **All mutations already have typed methods** on the `Schedule` class
(`set_override`, `mark_open`, `remove_override`, `add_roster_entry`,
`remove_roster_entry`, `rename_roster_entry`, `create_holiday`,
`remove_holiday`). Modals and a web API would both call the same methods, so
the authorization and side-effect logic does not get duplicated.
## Options considered
### (a) Expand/clarify inside Slack — **recommended (phase 1)**
Add Block Kit **modals** (`views.open`) for the error-prone, high-value actions,
an **admin section in App Home**, and keep the text subcommands as a fallback.
- **Pros:** reuses existing interactivity + App Home; **no new infra, auth,
hosting, or OAuth scope**; native date pickers / dropdowns / numeric inputs
remove the positional-arg footguns; a confirmation preview before same-day
3CX repoints; one deploy target; the modal handlers call the same `Schedule`
methods the text commands do, so authorization stays in one place.
- **Cons:** still lives in Slack; modal layout is constrained (no true
calendar/grid — an upcoming list is the practical "overview"); a small amount
of new view-state plumbing.
### (b) Full admin web UI
A separate hosted app (following the internal-portal pattern: Google OAuth,
CDK-hosted, SHOC design system) talking to the schedule DynamoDB table through a
new API.
- **Pros:** full calendar/grid, bulk edits, the richest UX; not constrained by
Block Kit.
- **Cons:** significant effort and ongoing cost — a new auth path (Google OAuth),
hosting (CDK), an API surface in front of DynamoDB, a **second deploy target**,
and a security / cross-family review. It also **forks the authorization model**:
`get_admin_users()` holds Slack user IDs, so a Google-OAuth UI needs an
identity mapping (Google account → Slack admin) to keep one source of truth.
Likely overkill unless admin volume is high.
### (c) Status quo — keep text-only commands
- **Pros:** zero work.
- **Cons:** every pain point above persists; the `holiday add` arg order keeps
biting.
## Recommendation: phased — (a) now, (b) only if admin volume grows
Adopt **(a)** as phase 1 and treat **(b)** as a deferred phase 2 gated on real
demand. Rationale:
- **Admin actions are low-frequency and well-bounded.** Overrides, opens, clears,
roster edits, and a handful of holidays a year are individually quick. The cost
today is *correctness/discoverability per action* (getting the positional args
right), not throughput — which is exactly what modals fix, and which a web UI
fixes at far greater cost.
- **(a) reuses everything; (b) builds a new stack.** The Slack-native path needs
no new hosting, scope, auth, or review and lands a large UX win for the effort
of a few modals plus an App Home section. (b) is a new auth + hosting + API +
review surface for a marginal gain at current volume, and it splits the
authorization model.
- **(a) does not block (b).** If admin volume later justifies a calendar/grid or
bulk edits, the `Schedule` methods and `get_admin_users()` authorization are
already the clean seam a web API would sit on. Phase 1 is not throwaway.
Either way, **`get_admin_users()` stays the single authorization source of
truth**, and the **text commands stay as a fallback** so no current workflow
breaks.
## Scope of phase 1 (option a) — for the follow-up build
The phase-1 build is intentionally *not* in this PR. Scoped here so the
follow-up issue can be sized:
### Modals to build (`views.open` + `view_submission`)
Start with the two highest-pain actions, then optionally extend:
1. **Override modal** — replaces/supplements `admin override`.
- `datepicker` for the date, a roster **dropdown** (`static_select` built from
`Schedule.get_roster()` so the extension/name can't be mistyped), and a
day/night `radio_buttons`/`static_select`.
- A confirmation line in the submit response when the date is today (it will
repoint 3CX).
2. **Holiday-add modal** — replaces/supplements `admin holiday add` (the worst
positional offender).
- `datepicker` (date), `number_input`/plain-text **slots**, an explicit
**multiplier** input (its own field instead of a parsed `x<mult>` token),
and a **label** input.
- `view_submission` validates (date not in the past, slots ≥ 1) and surfaces
errors as Block Kit field `errors` instead of a usage string.
Optional follow-ons once the pattern is proven: **open**, **clear**, and
**roster add/remove/rename** modals. These are lower-pain (fewer args) so they
can stay text-only initially.
### Handlers / wiring
- A new `@app.action(...)` per admin button to call `client.views_open(...)` with
the modal view (built by new `build_*_modal()` helpers in
`src/shared/shared/blocks.py`, mirroring the existing `build_*` block builders).
- A new `@app.view(...)` per modal `callback_id` to read `view.state.values`,
**re-check `is_admin` against `get_admin_users()`** (modals can be opened from
Home — never trust the surface), call the same `Schedule` method the text
command uses, run the same 3CX/announce side effects, and `ack()` (with field
`errors` on validation failure).
- Reuse `_refresh_schedule_post(...)` after a successful mutation, exactly as the
text handlers do.
### App Home admin section
- In `build_home_view()`, when the viewer is an admin (look them up via
`get_admin_users()` before publishing), append an **Admin** section:
buttons that open the modals above, plus a compact **upcoming
overrides + holidays** list (the practical substitute for a calendar/grid).
- `publish_home(...)` already runs on `app_home_opened`; it needs the viewer's
user id (it has `event["user"]`) to decide whether to include the admin
section.
### Help / validation
- Keep `build_help_blocks(is_admin=True)` as the text fallback reference, and
point it at the new modals ("or open the Admin tab in Home").
### Manifest / scope impact
- **None.** Modals and App Home interactivity use the **already-enabled**
`interactivity` request URL and the existing `app_home` config. No new OAuth
scope and **no reinstall** are required (unlike the `pins:write` change in the
sibling #135 work).
## Scope of phase 2 (option b) — only if later justified
Recorded so the deferral is a decision, not an omission:
- **Auth:** Google OAuth following the internal-portal pattern, plus a Google →
Slack-admin identity mapping so `get_admin_users()` remains the source of
truth (do **not** introduce a parallel admin list).
- **Hosting:** CDK-hosted app on the SHOC design system, a second deploy target
alongside the SAM stack.
- **Data path:** a thin API in front of the schedule DynamoDB table that calls
the same `Schedule` methods (no direct table writes from the browser).
- **Reviews:** security / cross-family review for the new public surface and
auth path.
## Decision needed
Recommendation is **(a) now, (b) later if volume grows**. The build route — ship
phase 1 (Slack-native modals + App Home admin), commit to the full phased path,
or jump straight to a web UI — is left for sign-off on the issue/PR before any
admin-surface code is written.

View file

@ -0,0 +1,114 @@
# Evaluation: sticky / reused weekly schedule post
_Spike for issue #135 — decide how the Monday two-week schedule post should
behave across weeks. This document records the decision and the rationale; the
recommended option (a) is implemented in the same PR._
## Background
`src/weekly-post/app.py` (the Monday 7am ET Lambda) currently, every week:
1. `chat_delete` the previous week's schedule post (stored `ts` from
`get_schedule_post`),
2. `chat_postMessage` a brand-new two-week schedule message, and
3. `save_schedule_post(channel_id, new_ts, …)`.
So every Monday the post gets a new `ts` (new permalink), re-notifies the
channel, and loses any thread/reactions. During the week it steadily sinks as
people chat.
The infrastructure to edit in place **already exists**: in-week shift changes
(`pick` / `drop` / `swap` / `admin`) call `_refresh_schedule_post()`
(`src/slack-bot/app.py`), which does a `chat_update` against the stored `ts`.
The weekly rollover is the *only* place that creates a fresh message.
## Options considered
### (a) Reuse the same post across weeks — **recommended**
On Monday, `chat_update` the existing message to roll the two-week window
forward instead of delete + repost.
- **Pros:** stable permalink; no weekly re-notification; keeps any
thread/reactions; trivial change (reuses the `_refresh_schedule_post`
`chat_update` pattern already in the codebase).
- **Cons:** the message stays where it was first posted — it does **not** rise
to the bottom as the channel gets new activity.
### (b) True stickybot behavior — keep it pinned to the bottom
Slack has no native "sticky" message. Stickybot-style bots subscribe to channel
message events and **delete + repost** the bot message whenever someone else
posts, so it is always last.
- **Pros:** always visible at the bottom of the channel.
- **Cons:** directly conflicts with (a)'s stable permalink (every repost = new
`ts`); requires a new event subscription (`message.channels`) and the
`channels:history` scope (a reinstall); needs dedupe/debounce plus Slack
rate-limit handling; constant repost churn; many more moving parts and a new
always-on event path to operate. The benefit (bottom-of-channel visibility)
is marginal for a low-traffic on-call channel.
### (c) Status quo — delete + repost weekly
- **Pros:** the weekly repost bumps the post to the bottom once a week.
- **Cons:** new permalink every week; a weekly re-notification; loses
thread/reactions; the post still sinks for the rest of the week.
## Decision
Adopt **(a) reuse-in-place**, complemented by a **native Slack pin** so the post
stays reachable from the channel header even as the channel fills with chatter.
Reject **(b)** — its only advantage over a pin is keeping the post at the literal
bottom, which is not worth a new event subscription, an extra scope, and
rate-limit/debounce machinery for this channel. The pin recovers most of the
discoverability that the weekly bump used to provide, at the cost of one extra
API call and one extra scope (`pins:write`), with no new event path to operate.
## What changes (option a)
In `src/weekly-post/app.py`, on the Monday rollover:
- **Drop the unconditional `chat_delete`.** There is no previous message to
remove when we reuse the same one.
- **`chat_update` the stored post** to roll the two-week window forward, keeping
the same `ts` (stable permalink, no re-notification).
- **First-run / recovery fallback:** when there is no stored post, or the
`chat_update` fails (e.g. the message was deleted manually), fall back to
`chat_postMessage` and **pin** the new message.
- **Keep `save_schedule_post` / `get_schedule_post`.** Still needed — they hold
the `ts` that both the weekly rollover and `_refresh_schedule_post` edit. On
reuse the stored `ts` is unchanged; on a fresh post it is updated to the new
`ts` and the new `week_start`.
### Interaction with `_refresh_schedule_post`
No change needed. Both paths now converge on a single long-lived message keyed
by the stored `ts`: the weekly Lambda rolls the window forward each Monday, and
in-week shift changes keep editing the same message. They never fight because
neither creates a new `ts` while one already exists.
### The "ever-growing edited message" / retention question
A `chat_update` does not grow channel history — it rewrites one message in place
rather than appending. Slack keeps the message's original post time, so an
edited-forever post sorts by where it was first posted (hence the pin). There is
no retention or history-size concern from editing the same message indefinitely.
## Scope / manifest impact
- `pins:write` is added to `slack-app-manifest.yaml` for the native pin. This is
a new OAuth scope, so the app must be **reinstalled** once for the pin to take
effect. The pin call is best-effort: until the reinstall happens (or if the
bot lacks the scope) it logs and is skipped, and the reuse-in-place behavior
still works without it.
- No new event subscriptions. Option (b)'s `message.channels` event and
`channels:history` scope are **not** added.
## Follow-ups (not in this PR)
- After deploy, reinstall the Slack app so `pins:write` takes effect, then
confirm the first rolled-forward post gets pinned.
- Optional: a one-line threaded note on the rolled-forward post if we later
decide we still want a light weekly nudge (kept out here on purpose — the whole
point of (a) is to stop the weekly re-notification).

View file

@ -5,11 +5,14 @@
# reconcile it against the live app config (App settings → App Manifest) so no
# existing scope or setting is dropped.
#
# The ONLY delta this release introduces is the App Home tab:
# This release adds the bot `pins:write` scope so the Monday rollover can natively
# pin the reused two-week schedule post. A new OAuth scope means a one-time
# reinstall is required for the pin to take effect (the pin is best-effort and is
# skipped until then). Replace <API_URL> with the SlackBotApiUrl stack output.
#
# A prior release added the App Home tab (no new scope):
# - settings.event_subscriptions.bot_events: + app_home_opened
# - features.app_home.home_tab_enabled: true
# Neither needs a new OAuth scope, so no reinstall is required (see README →
# "Releases & versioning"). Replace <API_URL> with the SlackBotApiUrl stack output.
display_information:
name: After-Hours Shift Manager
description: Manage after-hours on-call phone duty from Slack.
@ -36,6 +39,10 @@ oauth_config:
- chat:write
- im:write
- users:read
# pins:write lets the Monday rollover natively pin the reused schedule post
# so it stays reachable from the channel header. Adding it requires a
# one-time reinstall; the pin is best-effort until then.
- pins:write
settings:
event_subscriptions:

View file

@ -10,6 +10,18 @@ fine and still supported.
---
## v1.13.0 — June 27, 2026
**The two-week schedule post now stays put instead of being reposted every
week.** Each Monday the bot used to delete last week's schedule message and post
a brand-new one — which pinged the channel again, changed the message's link,
and dropped any replies or reactions. Now it simply edits the existing post to
roll the two-week window forward, so the link stays stable, there's no weekly
re-notification, and any thread stays attached. The post is also pinned, so you
can always reach it from the channel header even after a busy day of chat. (The
pin needs a one-time app reinstall to switch on; until then everything else still
works.)
## v1.12.0 — June 26, 2026
**Clearer shift drops and an easier-to-read schedule.** Two small quality-of-life

View file

@ -247,6 +247,16 @@ def _send_pay_email(week_label: str, pay_record: dict) -> None:
logger.info("Sent pay email to %s", recipients)
def _pin_schedule_post(slack, channel_id, message_ts):
"""Best-effort native pin so a freshly posted schedule stays reachable from
the channel header even as the channel fills with chatter."""
try:
slack.pins_add(channel=channel_id, timestamp=message_ts)
logger.info("Pinned schedule post %s", message_ts)
except Exception:
logger.info("Could not pin schedule post %s", message_ts, exc_info=True)
def handler(event, context):
now = datetime.now(EASTERN)
@ -307,35 +317,57 @@ def handler(event, context):
week_key,
)
# --- Delete previous week's schedule post ---
old_post = schedule.get_schedule_post(channel_id)
if old_post and old_post.get("message_ts"):
try:
slack.chat_delete(channel=channel_id, ts=old_post["message_ts"])
logger.info("Deleted previous schedule post %s", old_post["message_ts"])
except Exception:
logger.warning("Could not delete old schedule post", exc_info=True)
# --- Two-week schedule (always starts on Monday of this week) ---
this_monday = now - timedelta(days=now.weekday())
week_start = this_monday.strftime("%Y-%m-%d")
blocks = build_week_schedule(schedule, start_date=this_monday)
end_date = this_monday + timedelta(days=13)
result = slack.chat_postMessage(
channel=channel_id,
blocks=blocks,
text=f"After-Hours Schedule — {this_monday.strftime('%b %-d')} to {end_date.strftime('%b %-d')}",
fallback_text = (
f"After-Hours Schedule — {this_monday.strftime('%b %-d')} "
f"to {end_date.strftime('%b %-d')}"
)
schedule.save_schedule_post(
channel_id, result["ts"], this_monday.strftime("%Y-%m-%d")
)
logger.info(
"Posted weekly schedule to channel %s (ts=%s)", channel_id, result["ts"]
)
# Reuse the same post across weeks: roll the two-week window forward by
# editing the existing message in place (stable permalink, no weekly
# re-notification, keeps any thread/reactions) instead of delete + repost.
# Fall back to a fresh, pinned post when there is no stored message or the
# edit fails (e.g. the message was deleted manually).
old_post = schedule.get_schedule_post(channel_id)
message_ts = None
if old_post and old_post.get("message_ts"):
try:
slack.chat_update(
channel=channel_id,
ts=old_post["message_ts"],
blocks=blocks,
text=fallback_text,
)
message_ts = old_post["message_ts"]
logger.info(
"Rolled schedule post %s forward to week of %s",
message_ts,
week_start,
)
except Exception:
logger.warning(
"Could not update existing schedule post; reposting", exc_info=True
)
if message_ts is None:
result = slack.chat_postMessage(
channel=channel_id, blocks=blocks, text=fallback_text
)
message_ts = result["ts"]
_pin_schedule_post(slack, channel_id, message_ts)
logger.info(
"Posted new weekly schedule to channel %s (ts=%s)", channel_id, message_ts
)
schedule.save_schedule_post(channel_id, message_ts, week_start)
return {
"posted": True,
"channel": channel_id,
"message_ts": result["ts"],
"message_ts": message_ts,
"pay_calculated": bool(pay_record["breakdown"]),
}

View file

@ -82,11 +82,42 @@ def test_pay_email_failure_does_not_block_schedule_post(
@freeze_time(MON_0800)
def test_deletes_previous_schedule_post(weeklypost_app, schedule, seed, slack, env):
def test_rolls_existing_post_forward(weeklypost_app, schedule, seed, slack, env):
# An existing post is edited in place (stable permalink), never deleted.
seed.schedule_post("C_TEST", "111.111")
weeklypost_app.handler({"force": True}, None)
slack.chat_delete.assert_called_once()
assert slack.chat_delete.call_args.kwargs["ts"] == "111.111"
result = weeklypost_app.handler({"force": True}, None)
slack.chat_delete.assert_not_called()
slack.chat_update.assert_called_once()
assert slack.chat_update.call_args.kwargs["ts"] == "111.111"
# The stored ts is unchanged, so the permalink stays stable.
assert result["message_ts"] == "111.111"
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "111.111"
@freeze_time(MON_0800)
def test_pins_post_on_first_run(weeklypost_app, schedule, seed, slack, env):
# No existing post → post a fresh message and natively pin it.
result = weeklypost_app.handler({"force": True}, None)
slack.chat_postMessage.assert_called()
slack.pins_add.assert_called_once()
assert slack.pins_add.call_args.kwargs["timestamp"] == "999.000"
assert result["message_ts"] == "999.000"
@freeze_time(MON_0800)
def test_reposts_when_update_fails(weeklypost_app, schedule, seed, slack, env):
# A stored post that can no longer be edited (e.g. deleted manually) falls
# back to a fresh, pinned post and re-saves the new ts.
seed.schedule_post("C_TEST", "111.111")
slack.chat_update.side_effect = Exception("message_not_found")
result = weeklypost_app.handler({"force": True}, None)
slack.pins_add.assert_called_once()
assert result["message_ts"] == "999.000"
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000"
@freeze_time(MON_0800)