afterhours-shift-manager/src/weekly-post/app.py
Adam Moussa 6eb2e52a74
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled
feat(observability): add Sentry error reporting to Lambdas (#241)
Unhandled errors and timeout warnings go to Sentry when SENTRY_DSN is set; Slack and 3CX secrets are stripped before send.
2026-08-29 20:52:28 +00:00

376 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Lambda handler — posts the weekly on-call schedule and previous week's pay summary
to Slack every Monday at 7am ET, and emails the pay summary to payroll."""
import logging
import os
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
import boto3
from slack_sdk import WebClient
import shared.sentry_init # noqa: F401
from shared.blocks import build_pay_summary_blocks, build_week_schedule
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
from shared.secrets import get_secret
logger = logging.getLogger()
logger.setLevel(logging.INFO)
EASTERN = ZoneInfo("America/New_York")
DAY_ORDER = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
]
def _record_pay_line(
breakdown, totals, *, date, day_label, name, ext, effective, base_rate, is_holiday
):
"""Append a single breakdown line and roll it into per-employee totals.
``effective`` is the dollar value actually earned for this one shift
(base_rate * multiplier for holidays, base_rate otherwise). To stay
compatible with the existing Slack ``build_pay_summary_blocks`` consumer —
which treats ``rate`` as the already-multiplied per-shift pay — the line's
``rate`` is set to ``effective``; ``base_rate`` is preserved separately and
``amount`` mirrors ``effective``. All money is stored as ``Decimal``.
"""
line = {
"date": date.strftime("%Y-%m-%d"),
"date_label": date.strftime("%b %-d"),
"day": day_label,
"name": name,
"extension": ext,
"rate": Decimal(str(effective)),
"base_rate": Decimal(str(base_rate)),
"amount": Decimal(str(effective)),
}
breakdown.append(line)
if name not in totals:
totals[name] = {
"shifts": 0,
"total": Decimal("0"),
"extension": ext,
"rate": Decimal(str(base_rate)),
"holiday_shifts": 0,
}
totals[name]["shifts"] += 1
totals[name]["total"] += Decimal(str(effective))
if is_holiday:
totals[name]["holiday_shifts"] = totals[name].get("holiday_shifts", 0) + 1
return line
def _add_shift_to_pay(schedule, breakdown, totals, date, day_name, shift_type="night"):
"""Resolve a shift and add it to the pay breakdown if assigned.
Holidays are day-only, multi-assignee shifts paid at base_rate * multiplier;
every assignee gets a distinct breakdown line tagged with the holiday
multiplier and label. Regular shifts have a single assignee at multiplier 1.
"""
date_str = date.strftime("%Y-%m-%d")
ctx = schedule.get_shift_context(date_str, day_name, shift_type)
assignees = ctx.get("assignees") or []
if not assignees:
return
label_suffix = " (Day)" if shift_type == "day" else ""
day_label = f"{day_name[:3]}{label_suffix}"
if ctx["kind"] == "holiday":
multiplier = Decimal(str(ctx.get("multiplier", 1)))
holiday_label = ctx.get("label", "")
for assignee in assignees:
ext = assignee["extension"]
name = assignee["name"]
if ext == FALLBACK_EXTENSION:
continue
base_rate = Decimal(str(schedule.get_shift_rate(ext)))
effective = base_rate * multiplier
line = _record_pay_line(
breakdown,
totals,
date=date,
day_label=day_label,
name=name,
ext=ext,
effective=effective,
base_rate=base_rate,
is_holiday=True,
)
line["multiplier"] = Decimal(str(multiplier))
line["is_holiday"] = True
line["holiday_label"] = holiday_label
return
# Regular (override/weekly) shift — single assignee.
assignee = assignees[0]
ext = assignee["extension"]
name = assignee["name"]
if ext == FALLBACK_EXTENSION:
return
rate = Decimal(str(schedule.get_shift_rate(ext)))
_record_pay_line(
breakdown,
totals,
date=date,
day_label=day_label,
name=name,
ext=ext,
effective=rate,
base_rate=rate,
is_holiday=False,
)
def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict:
"""Calculate pay for a Mon–Sun week. Returns pay record dict."""
default_rate = schedule.get_shift_rate()
breakdown = []
totals = {}
for i in range(7):
date = week_start + timedelta(days=i)
day_name = DAY_ORDER[i]
date_str = date.strftime("%Y-%m-%d")
# Day shifts (8am–5pm) exist on weekends and on holidays. Holidays are
# day-only and may land on a weekday, so check the day shift whenever a
# holiday record exists for the date, not just on weekend days.
if day_name in WEEKEND_DAYS or schedule.get_holiday(date_str):
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "day")
# After-hours (night) shift
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "night")
return {
"week_start": week_start.strftime("%Y-%m-%d"),
"default_rate": str(default_rate),
"breakdown": breakdown,
"totals": totals,
}
def _build_pay_email_html(week_label: str, pay_record: dict) -> str:
"""Build an HTML email body for the weekly pay summary.
Holiday shifts are rendered distinctly: a per-shift breakdown section
highlights holiday rows (shaded, showing the multiplier and label) and the
totals table flags any employee who worked a holiday during the week.
"""
breakdown = pay_record.get("breakdown", [])
totals = pay_record.get("totals", {})
holiday_names = {line["name"] for line in breakdown if line.get("is_holiday")}
totals_rows = ""
for name, info in sorted(totals.items()):
holiday_tag = (
' <span style="color:#b8860b;">&#9733; holiday</span>'
if name in holiday_names
else ""
)
totals_rows += (
f"<tr><td>{name}{holiday_tag}</td>"
f"<td>${info.get('rate', 0):.2f}</td><td><strong>${info['total']:.2f}</strong></td></tr>\n"
)
breakdown_rows = ""
for line in breakdown:
if line.get("is_holiday"):
mult = line.get("multiplier", Decimal("1"))
label = line.get("holiday_label", "") or "Holiday"
base = line.get("base_rate", line["rate"])
row_style = ' style="background:#fff8e1;"'
detail = f"{label} &mdash; ${base:.2f} &times; {mult:.2f}x"
else:
row_style = ""
detail = f"${line['rate']:.2f}"
breakdown_rows += (
f"<tr{row_style}><td>{line['date_label']}</td>"
f"<td>{line['day']}</td>"
f"<td>{line['name']}</td>"
f"<td>{detail}</td>"
f"<td><strong>${line['amount']:.2f}</strong></td></tr>\n"
)
return f"""<html>
<body style="font-family: Arial, sans-serif; color: #333;">
<h2>Bonus Pay Summary &mdash; {week_label}</h2>
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
<tr style="background: #f0f0f0;"><th>Name</th><th>Rate</th><th>Total</th></tr>
{totals_rows}</table>
<h3>Shift Breakdown</h3>
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
<tr style="background: #f0f0f0;"><th>Date</th><th>Day</th><th>Name</th><th>Detail</th><th>Amount</th></tr>
{breakdown_rows}</table>
<p style="color: #888; font-size: 12px;">Holiday shifts are shaded and paid at the listed multiplier.</p>
<p style="color: #888; font-size: 12px;">This is an automated report from Sea Haven Industries.</p>
</body>
</html>"""
def _send_pay_email(week_label: str, pay_record: dict) -> None:
"""Send the weekly pay summary email via SES."""
sender = os.environ.get("SES_SENDER", "noreply@seahaven.com")
recipients = os.environ.get("PAYROLL_RECIPIENTS", "").split(",")
recipients = [r.strip() for r in recipients if r.strip()]
if not recipients:
logger.warning("No PAYROLL_RECIPIENTS configured — skipping email")
return
ses = boto3.client("ses")
html_body = _build_pay_email_html(week_label, pay_record)
ses.send_email(
Source=sender,
Destination={"ToAddresses": recipients},
Message={
"Subject": {"Data": f"Bonus Pay Summary — {week_label}"},
"Body": {"Html": {"Data": html_body}},
},
)
logger.info("Sent pay email to %s", recipients)
def handler(event, context):
now = datetime.now(EASTERN)
# DST guard — same pattern as the 3CX scheduler
force = event.get("force", False)
if not force and now.hour != 7:
logger.info(
"ET hour is %d, not 7am — skipping (wrong DST rule fired)", now.hour
)
return {"skipped": True}
bot_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"])
channel_id = os.environ["SHIFT_CHANNEL"]
schedule = ShiftSchedule()
slack = WebClient(token=bot_token)
# --- Previous week's pay summary ---
prev_monday = now - timedelta(days=7)
prev_monday = prev_monday.replace(hour=0, minute=0, second=0, microsecond=0)
pay_record = _calculate_weekly_pay(schedule, prev_monday)
pay_dm_user = os.environ.get("PAY_REPORT_USER")
if pay_record["breakdown"]:
week_key = prev_monday.strftime("%Y-%m-%d")
schedule.save_pay_record(week_key, pay_record)
prev_sunday = prev_monday + timedelta(days=6)
week_label = (
f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
)
pay_blocks = build_pay_summary_blocks(
week_label, pay_record["breakdown"], pay_record["totals"]
)
# DM pay report to designated user instead of channel
if pay_dm_user:
slack.chat_postMessage(
channel=pay_dm_user,
blocks=pay_blocks,
text=f"After-Hours Pay — {week_label}",
)
logger.info(
"Sent pay summary DM to %s for week of %s", pay_dm_user, week_key
)
else:
logger.warning("PAY_REPORT_USER not set — skipping Slack pay summary")
# Email pay summary to payroll. Isolated so a delivery failure (e.g.
# an SES permission/identity issue) can never abort the rest of the
# handler — the Slack schedule post below must still go out.
try:
_send_pay_email(week_label, pay_record)
except Exception:
logger.exception(
"Failed to send pay summary email to payroll for week of %s",
week_key,
)
# --- 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)
fallback_text = (
f"After-Hours Schedule — {this_monday.strftime('%b %-d')} "
f"to {end_date.strftime('%b %-d')}"
)
# Delete the previous week's schedule post + repost a fresh one so the
# message lands at the bottom of the channel every Monday. The activity
# bump (handle_channel_message) handles in-week bottom-stickiness; this
# ensures the rollover itself re-places the post at the bottom.
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 for weekly rollover",
old_post["message_ts"],
)
except Exception:
logger.warning(
"Could not delete old schedule post for rollover; continuing",
exc_info=True,
)
result = slack.chat_postMessage(
channel=channel_id, blocks=blocks, text=fallback_text
)
message_ts = result["ts"]
logger.info(
"Posted new weekly schedule to channel %s (ts=%s)", channel_id, message_ts
)
# Persist the new ts immediately. If the write fails after the post has
# already landed, roll the fresh message back before letting the error
# propagate — an async retry would otherwise read the stale (already
# deleted) ts, no-op its delete, and post a *second* schedule, orphaning
# this one at the bottom of the channel.
try:
schedule.save_schedule_post(channel_id, message_ts, week_start)
except Exception:
logger.warning(
"Failed to persist schedule post %s; rolling it back to avoid an "
"orphaned duplicate on retry",
message_ts,
exc_info=True,
)
try:
slack.chat_delete(channel=channel_id, ts=message_ts)
except Exception:
logger.warning(
"Could not roll back orphaned schedule post %s",
message_ts,
exc_info=True,
)
raise
return {
"posted": True,
"channel": channel_id,
"message_ts": message_ts,
"pay_calculated": bool(pay_record["breakdown"]),
}