mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-07 16:19:00 +00:00
Compare commits
2 commits
a392433291
...
9ccde1e9f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ccde1e9f2 | ||
|
|
612572bde7 |
7 changed files with 435 additions and 27 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -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
200
docs/admin-ui-evaluation.md
Normal 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.
|
||||
114
docs/sticky-schedule-post.md
Normal file
114
docs/sticky-schedule-post.md
Normal 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).
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue