Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions infra/lambda/poller/alert_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,33 @@ def resolve_alert_recipients(
if existing is None or c.priority > existing.priority:
best[c.user_id] = c
return best


def dedupe_resolved_by_key(
resolved: dict[str, AlertCandidate],
get_user_key,
) -> list[tuple[str, str, AlertCandidate]]:
"""Resolve each recipient's Pushover key and collapse duplicates.

The same human can appear under two ids: the shared-partition owner
is an IMPLICIT plan-alert recipient ("megan") and may also opt in via
the /trips web toggle under their Cognito sub — both profiles carry
the same Pushover key, so without this one event would push twice.
First id wins (dict order — plan/owner candidates are appended before
favoriter ones at every call site). Ids with no resolvable key are
dropped with a log line.

Returns [(user_id, user_key, candidate)] ready to dispatch.
"""
out: list[tuple[str, str, AlertCandidate]] = []
seen_keys: set[str] = set()
for user_id, candidate in resolved.items():
user_key = get_user_key(user_id)
if not user_key:
print(f"[poller] No pushover_user_key for user {user_id} — skipping")
continue
if user_key in seen_keys:
continue
seen_keys.add(user_key)
out.append((user_id, user_key, candidate))
return out
48 changes: 26 additions & 22 deletions infra/lambda/poller/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ def filter_to_favoriters(
f"Fanning out to {len(active_plans)} plan(s)."
)
seen_users: set[str] = set()
seen_user_keys: set[str] = set()
weather_alerts_sent = 0
for plan in active_plans:
user_id = plan["user_id"]
Expand All @@ -244,6 +245,12 @@ def filter_to_favoriters(
user_key = get_user_key(user_id)
if not user_key:
continue
# ...and by Pushover key: the owner is implicit AND may
# be opted in under their sub via the web toggle — same
# key, one push (mirrors dedupe_resolved_by_key).
if user_key in seen_user_keys:
continue
seen_user_keys.add(user_key)
# Mark BEFORE send: same pattern as the other
# cooldowns. If the Pushover send fails the
# cooldown still prevents re-fire spam within the
Expand Down Expand Up @@ -464,14 +471,13 @@ def filter_to_favoriters(
f"{len(plan_targets)} plan targets → "
f"{len(resolved)} unique recipients"
)
for target_user, candidate in resolved.items():
user_key = get_user_key(target_user)
if not user_key:
print(
f"[poller] No pushover_user_key for "
f"user {target_user} — skipping"
)
continue
# Dedupe by Pushover key (owner implicit + web
# opt-in under their sub = same key, one push).
for target_user, user_key, candidate in (
alert_routing.dedupe_resolved_by_key(
resolved, get_user_key
)
):
if candidate.notifier_fn(user_key, **candidate.kwargs):
total_alerts += 1

Expand Down Expand Up @@ -561,13 +567,12 @@ def filter_to_favoriters(
f"{len(favoriters)} favoriters, {len(plan_targets)} plan "
f"targets → {len(resolved)} unique recipients"
)
for target_user, candidate in resolved.items():
user_key = get_user_key(target_user)
if not user_key:
print(
f"[poller] No pushover_user_key for user {target_user} — skipping"
)
continue
# Dedupe by Pushover key: the owner is implicit AND may
# also be opted in under their sub via the web toggle —
# same key, one push (see alert_routing docstring).
for target_user, user_key, candidate in (
alert_routing.dedupe_resolved_by_key(resolved, get_user_key)
):
if candidate.notifier_fn(user_key, **candidate.kwargs):
total_alerts += 1

Expand Down Expand Up @@ -657,13 +662,12 @@ def filter_to_favoriters(
f"{len(favoriters)} favoriters, {len(plan_targets)} plan "
f"targets → {len(resolved)} unique recipients"
)
for target_user, candidate in resolved.items():
user_key = get_user_key(target_user)
if not user_key:
print(
f"[poller] No pushover_user_key for user {target_user} — skipping"
)
continue
# Dedupe by Pushover key: the owner is implicit AND may
# also be opted in under their sub via the web toggle —
# same key, one push (see alert_routing docstring).
for target_user, user_key, candidate in (
alert_routing.dedupe_resolved_by_key(resolved, get_user_key)
):
if candidate.notifier_fn(user_key, **candidate.kwargs):
total_alerts += 1

Expand Down
25 changes: 25 additions & 0 deletions infra/lambda/poller/tests/test_alert_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,28 @@ def test_plan_framing_beats_favorite_for_low_wait(self):
assert set(resolved.keys()) == {"alice", "bob"}
assert resolved["alice"].kwargs["plan_id"] == "PLAN#p1" # plan wins
assert "plan_id" not in resolved["bob"].kwargs


class TestDedupeResolvedByKey:
"""One human, two ids (implicit owner "megan" + web opt-in under
their sub) must get ONE push, not two — collapse by Pushover key."""

def test_same_key_collapses_first_id_wins(self):
resolved = resolve_alert_recipients([
AlertCandidate("megan", PRIORITY_PLAN, _fake_notifier, {"a": 1}),
AlertCandidate("sub-megan-uuid", PRIORITY_PLAN, _fake_notifier, {"a": 2}),
AlertCandidate("sub-jim", PRIORITY_PLAN, _fake_notifier, {"a": 3}),
])
keys = {"megan": "pk-megan", "sub-megan-uuid": "pk-megan", "sub-jim": "pk-jim"}
out = alert_routing.dedupe_resolved_by_key(resolved, keys.get)
assert [(u, k) for u, k, _ in out] == [
("megan", "pk-megan"), ("sub-jim", "pk-jim"),
]

def test_missing_key_dropped(self):
resolved = resolve_alert_recipients([
AlertCandidate("ghost", PRIORITY_PLAN, _fake_notifier, {}),
AlertCandidate("sub-jim", PRIORITY_FAVORITE, _fake_notifier, {}),
])
out = alert_routing.dedupe_resolved_by_key(resolved, {"sub-jim": "pk-jim"}.get)
assert [(u, k) for u, k, _ in out] == [("sub-jim", "pk-jim")]
60 changes: 60 additions & 0 deletions web/src/app/trips/TripAlertToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

/**
* "Get alerts for this trip" toggle — the /trips self-serve opt-in.
*
* Renders the signed-in member's subscription state for one trip (any
* un-recorded day subscribed = on) and flips it via the setTripAlerts
* server action, which writes the atomic set ADD/DELETE. The page passes
* `subscribed` from the freshly-read rows, so state survives reloads and
* reflects MCP-side changes too.
*/

import { useState, useTransition } from "react";

import { setTripAlerts } from "./actions";

export default function TripAlertToggle({
planIds,
subscribed,
}: {
planIds: string[];
subscribed: boolean;
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);

const flip = () => {
setError(null);
startTransition(async () => {
const res = await setTripAlerts(planIds, !subscribed);
if (!res.ok) setError(res.error ?? "Couldn't update.");
// On success the action revalidates /trips; the re-render brings
// the new `subscribed` down from the server rows.
});
};

return (
<div className="shrink-0 text-right">
<button
type="button"
onClick={flip}
disabled={pending || planIds.length === 0}
className={
"rounded-full px-3 py-1 text-xs font-medium border transition-colors disabled:opacity-50 " +
(subscribed
? "border-ok/40 bg-ok/15 text-ok"
: "border-line bg-bg-1 text-fg-2 hover:text-fg-1")
}
aria-pressed={subscribed}
>
{pending
? "Saving…"
: subscribed
? "Getting alerts ✓"
: "Get alerts for this trip"}
</button>
{error && <p className="mt-1 text-xs text-warn">{error}</p>}
</div>
);
}
64 changes: 64 additions & 0 deletions web/src/app/trips/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"use server";

/**
* Server actions for /trips — the "get alerts for this trip" toggle.
*
* Same gates as the page: signed in + family allowlist. The member id
* written is ALWAYS the session sub (never from the form), and the write
* itself is the atomic set ADD/DELETE in dynamodb-writes.ts — see the
* boundary note there (the web's only write into the shared partition).
*/

import { revalidatePath } from "next/cache";

import { auth } from "@/auth";
import { getUserProfile, setPlanAlertSubscription } from "@/lib/dynamodb-writes";
import { isTripsAllowed } from "@/lib/trips-access";

export interface ToggleResult {
ok: boolean;
error?: string;
}

export async function setTripAlerts(
planIds: string[],
subscribed: boolean,
): Promise<ToggleResult> {
const session = await auth();
const sub = session?.user?.id;
if (!sub) return { ok: false, error: "Not signed in." };
if (!isTripsAllowed(session.user?.email)) {
return { ok: false, error: "Family accounts only." };
}

// Sanity-bound the input (comes from the client component): plan ids
// are ISO-timestamp SK suffixes. Reject junk rather than write it.
const ids = (planIds ?? []).filter(
(p) => typeof p === "string" && p.length > 0 && p.length < 100,
);
if (ids.length === 0 || ids.length > 30) {
return { ok: false, error: "No plan days to update." };
}

// Receiving pushes needs a Pushover key on the member's profile — the
// poller reads it from USER#<sub>/PROFILE. Guard opt-IN so the toggle
// can't silently succeed at doing nothing; opt-OUT is always allowed.
if (subscribed) {
const profile = await getUserProfile(sub);
if (!profile?.pushoverUserKey) {
return {
ok: false,
error:
"Add your Pushover key on the My alerts page first — that's where trip pushes get sent.",
};
}
}

try {
await setPlanAlertSubscription(sub, ids, subscribed);
} catch {
return { ok: false, error: "Couldn't update — try again." };
}
revalidatePath("/trips");
return { ok: true };
}
39 changes: 29 additions & 10 deletions web/src/app/trips/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { getUpcomingTrips, type Trip, type TripDay } from "@/lib/dynamodb";
import { findPark } from "@/lib/parks";
import { isTripsAllowed } from "@/lib/trips-access";

import TripAlertToggle from "./TripAlertToggle";

// Shared, family-scoped, low-traffic — always render fresh.
export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -60,6 +62,7 @@ export default async function TripsPage() {
}

const trips = await getUpcomingTrips();
const viewerSub = session?.user?.id ?? "";

return (
<div className="mx-auto max-w-2xl px-6 py-12">
Expand All @@ -84,25 +87,41 @@ export default async function TripsPage() {
) : (
<div className="space-y-10">
{trips.map((trip) => (
<TripSection key={trip.trip_id} trip={trip} />
<TripSection key={trip.trip_id} trip={trip} viewerSub={viewerSub} />
))}
</div>
)}
</div>
);
}

function TripSection({ trip }: { trip: Trip }) {
function TripSection({ trip, viewerSub }: { trip: Trip; viewerSub: string }) {
// The toggle applies to days still in play (recorded days are history —
// their alerts can't fire again). Subscribed = opted in on ANY such day;
// the owner is implicitly subscribed server-side, so for them this
// toggle just adds a redundant (deduped) entry — harmless.
const openDays = trip.days.filter((d) => !d.outcome_recorded);
const subscribed = openDays.some((d) =>
d.alert_subscribers.includes(viewerSub),
);
return (
<section>
<div className="mb-3">
<h3 className="display text-xl font-medium text-fg-1">
{trip.name ?? "Trip"}
</h3>
<p className="label-meta mt-1">
{formatDay(trip.start_date)} &ndash; {formatDay(trip.end_date)} ·{" "}
{trip.days.length} {trip.days.length === 1 ? "day" : "days"}
</p>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h3 className="display text-xl font-medium text-fg-1">
{trip.name ?? "Trip"}
</h3>
<p className="label-meta mt-1">
{formatDay(trip.start_date)} &ndash; {formatDay(trip.end_date)} ·{" "}
{trip.days.length} {trip.days.length === 1 ? "day" : "days"}
</p>
</div>
{openDays.length > 0 && (
<TripAlertToggle
planIds={openDays.map((d) => d.plan_id)}
subscribed={subscribed}
/>
)}
</div>
<div className="space-y-3">
{trip.days.map((day) => (
Expand Down
31 changes: 31 additions & 0 deletions web/src/lib/dynamodb-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,34 @@ describe("getUserParkSubscriptions", () => {
expect(await getUserParkSubscriptions("sub-123")).toEqual(new Set());
});
});

describe("setPlanAlertSubscription", () => {
it("issues one atomic ADD per plan row with the session sub as a Set", async () => {
sendMock.mockResolvedValue({});
const { setPlanAlertSubscription } = await import("./dynamodb-writes");
await setPlanAlertSubscription("sub-sis", ["p1", "p2"], true);

expect(sendMock).toHaveBeenCalledTimes(2);
const inputs = sendMock.mock.calls.map((c) => c[0].input);
expect(inputs.map((i) => i.Key)).toEqual([
{ PK: "USER#megan", SK: "PLAN#p1" },
{ PK: "USER#megan", SK: "PLAN#p2" },
]);
for (const i of inputs) {
// Atomic set op — NOT a read-modify-write SET (that's what makes
// concurrent MCP/web edits safe).
expect(i.UpdateExpression).toBe("ADD alert_subscribers :m");
expect(i.ExpressionAttributeValues[":m"]).toEqual(new Set(["sub-sis"]));
expect(i.ConditionExpression).toBe("attribute_exists(PK)");
}
});

it("uses atomic DELETE on unsubscribe", async () => {
sendMock.mockResolvedValue({});
const { setPlanAlertSubscription } = await import("./dynamodb-writes");
await setPlanAlertSubscription("sub-sis", ["p1"], false);
expect(sendMock.mock.calls[0][0].input.UpdateExpression).toBe(
"DELETE alert_subscribers :m",
);
});
});
Loading
Loading