From 2b335ddee8b078eb3163473d5e8d2bcc1546fc44 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 10:42:15 -0500 Subject: [PATCH 1/8] docs: plan and manual test plan for resending calendar invites --- .../resend-calendar-invite.md | 31 +++++++ .../resend_calendar_invite_manual_testing.md | 90 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 docs/implementation-plans/resend-calendar-invite.md create mode 100644 docs/test-plans/resend_calendar_invite_manual_testing.md diff --git a/docs/implementation-plans/resend-calendar-invite.md b/docs/implementation-plans/resend-calendar-invite.md new file mode 100644 index 000000000..78189253f --- /dev/null +++ b/docs/implementation-plans/resend-calendar-invite.md @@ -0,0 +1,31 @@ +# Resend calendar invite (frontend) + +Lets a coach resend the calendar invite for a series or a one-off session from the session row's ⋮ menu. The backend contract and its decisions live in `refactor-platform-rs/docs/implementation-plans/resend-calendar-invite.md`. The manual test plan is `docs/test-plans/resend_calendar_invite_manual_testing.md`. + +Branch: `resend-calendar-invite`. Built with the overseer/handoff workflow, one phase per commit. + +## UI + +- `coaching-sessions-row.tsx`: the item sits in the Share link group. + - It's only for the coach; a coachee doesn't see it. + - On a series session it reads "Resend series invite", otherwise "Resend invite". Icon: `MailPlus`. + - It is enabled when the session's series still has a session ahead (`series.last_session_date` in the future). A one-off is enabled when the session itself is upcoming. + - Otherwise it's disabled and shows the muted hint "No upcoming sessions". The hint is inline because Radix tooltips don't fire on disabled items. +- `coaching-sessions-card.tsx` keeps the resend state as a `closed | pending | sending` union, the same as the delete flow, and renders `ResendInviteDialog`. +- `resend-invite-dialog.tsx` is an AlertDialog. The title is "Resend series invite?" or "Resend invite?". The body says both people get a new invite. While sending, the action shows "Sending…" and Cancel is disabled. +- Toasts (sonner): + - Success: "Invite sent to you and {coachee}". + - 502: "Couldn't resend the invite". + - 409 `legacy_series`: "This series can't be resent. You can resend this session instead.", with a "Resend this session" action. + - 409 `no_upcoming_sessions`: "This series has no upcoming sessions." + +## Data + +- `EnrichedCoachingSession.series?: Option`, where the summary is `{ id, last_session_date: DateTime }`. The dashboard adds `series` to its `include`. +- `CoachingSessionSeriesApi.resendInvite(id)` and `CoachingSessionApi.resendInvite(id)` return `Result`. The error is a `ResendInviteFailure` enum: `LegacySeries`, `NoUpcomingSessions`, `SeriesMember`, `Failed`. + +## Phases + +- [ ] F1: types, transform, the two API calls, dashboard include (Vitest + MSW) +- [ ] F2: row gating, dialog, card state, toasts, and an isolated Playwright spec using `page.route` +- [ ] V: live run of the manual test plan through Playwright MCP against the backend branch diff --git a/docs/test-plans/resend_calendar_invite_manual_testing.md b/docs/test-plans/resend_calendar_invite_manual_testing.md new file mode 100644 index 000000000..5fa9e577f --- /dev/null +++ b/docs/test-plans/resend_calendar_invite_manual_testing.md @@ -0,0 +1,90 @@ +# Test Plan: Manually Testing Resend Calendar Invite + +Checks that a coach can resend the calendar invite for a series or a one-off session from the session row's ⋮ menu. The item has to be gated correctly, and the resent invite has to leave each calendar with exactly the sessions the app has: no duplicates, moved sessions at their new time, deleted sessions gone. The plan also covers the legacy-series, finished-series and failure paths. + +Backend counterpart: `refactor-platform-rs/docs/implementation-plans/resend-calendar-invite.md`. + +> [!IMPORTANT] +> This plan sends **real email**. The only recipients allowed are `jim@refactorgroup.com` (coach) and `james.hodapp@gmail.com` (coachee), in relationship `7adf81aa-23f4-4221-9f82-afb4a5f7512e`. Before any step marked ✉, confirm that the relationship's coach and coachee are still exactly those two addresses. Each ✉ shows how many emails the step sends. The whole plan sends about 16. + +## 1. Prerequisites + +1. Backend on `resend-calendar-invite` on `:4000`, migrations applied. `.env` must have the Resend API key and the session/recurring "scheduled", "rescheduled" and "cancelled" template ids. +2. Frontend on `resend-calendar-invite`, `npm run dev` on `:3000`. +3. Logins: the coach is `PW_ORG_ADMIN_LOGIN_*` and the coachee is `PW_USER_LOGIN_*`, both from `.env.local`. +4. Fixtures, created in the coach's browser: + 1. ✉×2 A weekly series of 6 sessions in the relationship above, starting next week. Don't accept it in either calendar. + 2. ✉×2 Reschedule occurrence 3 of that series by one day. + 3. ✉×2 Delete occurrence 4 of that series. +5. Fixtures created through SQL (no email): + 1. A **finished series**: a series whose sessions are all in the past. + 2. A **legacy series**: a series with at least one upcoming session whose members have `ical_recurrence_id` set to NULL. + +## 2. Part A: who sees the item + +### Case A1: coachee + +Sign in as the coachee. Open ⋮ on a series row and on a one-off row. + +**Pass:** neither menu has a resend item. + +### Case A2: coach + +Sign in as the coach. Open ⋮ on an upcoming series session, an upcoming one-off, a past one-off, and a session of the finished series. + +**Pass:** "Resend series invite" is enabled on the upcoming series session and "Resend invite" is enabled on the upcoming one-off. The past one-off and the finished-series session both show the item disabled, with "No upcoming sessions" under it. + +## 3. Part B: the dialog + +### Case B1: cancel sends nothing + +Open "Resend series invite" and press Cancel. + +**Pass:** no `resend_invite` request appears in the network log and no email arrives. + +### Case B2: ✉×2 series resend + +Open "Resend series invite" again and double-click Resend invite. + +**Pass:** +- Exactly one `POST /coaching_session_series/:id/resend_invite`, answered 204. +- While the request is in flight, the action shows "Sending…" and Cancel is disabled. +- The toast reads "Invite sent to you and James". +- Both inboxes get the "recurring sessions scheduled" email with an `invite.ics` attachment titled "James / Jim". +- After accepting in Google Calendar, there is one series. Occurrence 3 is at its moved time and occurrence 4 is gone. + +### Case B3: ✉×2 resend after accepting + +Once B2 has been accepted, resend the series again. + +**Pass:** each calendar updates the existing series. No second copy appears in either calendar. + +### Case B4: ✉×4 one-off resend + +Create a one-off session for tomorrow (✉×2), then use "Resend invite" on it (✉×2). + +**Pass:** one `POST /coaching_sessions/:id/resend_invite`, answered 204, and a success toast. Each calendar shows one event for it, not two. + +## 4. Part C: refusals + +### Case C1: ✉×2 legacy series + +Use "Resend series invite" on a session of the legacy series. + +**Pass:** the response is 409, and the toast reads "This series can't be resent. You can resend this session instead." with a "Resend this session" action. Pressing the action sends `POST /coaching_sessions/:id/resend_invite`, which is answered 204, and a standalone invite for that session arrives. + +### Case C2: API refusals + +In the coach's page, `fetch` `POST /coaching_session_series//resend_invite`. In the coachee's page, `fetch` `POST /coaching_session_series//resend_invite`. + +**Pass:** 409, then 403. No email. + +### Case C3: provider failure + +Unset the recurring "scheduled" template id in the backend `.env` and restart the backend. Then resend the active series. + +**Pass:** the response is 502 and the toast reads "Couldn't resend the invite". No email arrives. Restore the template id afterwards. + +## 5. Cleanup + +Delete the test series, sessions and fixtures through SQL. Don't use the API delete routes, which send cancellation emails. From bdba09a541966291cab586411cd1defff18c9e42 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 10:59:53 -0500 Subject: [PATCH 2/8] feat(sessions): data layer for resending calendar invites --- __tests__/lib/api/resend-invite.test.ts | 99 +++++++++++++++++++ .../coaching-session-display-title.test.ts | 35 ++++++- .../session-buckets/bucket-accordion.tsx | 1 + .../session-buckets/this-week-accordion.tsx | 1 + .../session-buckets/today-section.tsx | 1 + src/lib/api/coaching-session-series.ts | 9 ++ src/lib/api/coaching-sessions.ts | 8 ++ src/types/coaching-session.ts | 29 +++++- src/types/resend-invite.ts | 50 ++++++++++ 9 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 __tests__/lib/api/resend-invite.test.ts create mode 100644 src/types/resend-invite.ts diff --git a/__tests__/lib/api/resend-invite.test.ts b/__tests__/lib/api/resend-invite.test.ts new file mode 100644 index 000000000..3e76336ed --- /dev/null +++ b/__tests__/lib/api/resend-invite.test.ts @@ -0,0 +1,99 @@ +// Resending calendar invites: exact paths, no body, and typed failures. + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Result } from "neverthrow"; +import { CoachingSessionSeriesApi } from "@/lib/api/coaching-session-series"; +import { CoachingSessionApi } from "@/lib/api/coaching-sessions"; +import { sessionGuard } from "@/lib/auth/session-guard"; +import { Some } from "@/types/option"; +import { ResendInviteRefusal, type ResendInviteFailure } from "@/types/resend-invite"; + +vi.mock("@/lib/auth/session-guard", () => ({ + sessionGuard: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }, +})); + +vi.mock("@/site.config", () => ({ + siteConfig: { env: { backendServiceURL: "http://localhost:3000" } }, +})); + +const BASE = "http://localhost:3000"; + +const httpError = (status: number, data: unknown = {}) => + Object.assign(new Error(`Request failed with status code ${status}`), { + isAxiosError: true, + response: { status, statusText: "", data, headers: {}, config: {} }, + }); + +const conflict = (reason: string) => ({ + status_code: 409, + error: "conflict", + message: "refused", + details: { reason }, +}); + +const failureOf = async ( + call: Promise> +): Promise => { + const result = await call; + if (result.isOk()) throw new Error("expected a failure"); + return result.error; +}; + +describe("resendInvite", () => { + beforeEach(() => vi.clearAllMocks()); + + it("POSTs to the series' resend_invite route with no body", async () => { + vi.mocked(sessionGuard.post).mockResolvedValue({ status: 204, data: "" } as never); + const result = await CoachingSessionSeriesApi.resendInvite("ser-1"); + expect(sessionGuard.post).toHaveBeenCalledWith( + `${BASE}/coaching_session_series/ser-1/resend_invite` + ); + expect(result.isOk()).toBe(true); + }); + + it("POSTs to the session's resend_invite route with no body", async () => { + vi.mocked(sessionGuard.post).mockResolvedValue({ status: 204, data: "" } as never); + const result = await CoachingSessionApi.resendInvite("s-1"); + expect(sessionGuard.post).toHaveBeenCalledWith( + `${BASE}/coaching_sessions/s-1/resend_invite` + ); + expect(result.isOk()).toBe(true); + }); + + it.each([ + ["legacy_series", ResendInviteRefusal.LegacySeries], + ["no_upcoming_sessions", ResendInviteRefusal.NoUpcomingSessions], + ["series_member", ResendInviteRefusal.SeriesMember], + ])("maps 409 %s to refused", async (wire, reason) => { + vi.mocked(sessionGuard.post).mockRejectedValue(httpError(409, conflict(wire))); + expect(await failureOf(CoachingSessionSeriesApi.resendInvite("ser-1"))).toEqual({ + kind: "refused", + reason, + }); + }); + + it("maps a 409 with no details to unexpected", async () => { + vi.mocked(sessionGuard.post).mockRejectedValue( + httpError(409, { status_code: 409, error: "conflict", message: "refused" }) + ); + expect(await failureOf(CoachingSessionApi.resendInvite("s-1"))).toEqual({ + kind: "unexpected", + status: Some(409), + }); + }); + + it("maps 502 to not_delivered", async () => { + vi.mocked(sessionGuard.post).mockRejectedValue(httpError(502)); + expect(await failureOf(CoachingSessionApi.resendInvite("s-1"))).toEqual({ + kind: "not_delivered", + }); + }); + + it("maps 403 to unexpected", async () => { + vi.mocked(sessionGuard.post).mockRejectedValue(httpError(403)); + expect(await failureOf(CoachingSessionSeriesApi.resendInvite("ser-1"))).toEqual({ + kind: "unexpected", + status: Some(403), + }); + }); +}); diff --git a/__tests__/types/coaching-session-display-title.test.ts b/__tests__/types/coaching-session-display-title.test.ts index 0f7408814..47179bdc1 100644 --- a/__tests__/types/coaching-session-display-title.test.ts +++ b/__tests__/types/coaching-session-display-title.test.ts @@ -2,9 +2,10 @@ // read transform (string | null on the list/enriched reads, absent on the single // read) and its exclusion from the write serialize. The frozen // coaching-session-title.test.ts pins the client-side fallback rule; this file -// pins only the display_title wire handling. +// pins the display_title and series wire handling. import { describe, it, expect } from "vitest"; +import { DateTime } from "ts-luxon"; import { Some } from "@/types/option"; import { transformCoachingSession, @@ -73,3 +74,35 @@ describe("defaultCoachingSession — display_title", () => { expect(defaultCoachingSession().display_title).toBeUndefined(); }); }); + +describe("transformCoachingSession — series", () => { + it("wraps a present series as Some with a UTC last_session_date", () => { + const s = transformCoachingSession({ + ...rawWire, + series: { id: "ser-1", last_session_date: "2026-08-28T15:00:00" }, + }); + expect(s.series?.some).toBe(true); + if (!s.series?.some) return; + expect(s.series.val.id).toBe("ser-1"); + expect(s.series.val.last_session_date.zoneName).toBe("UTC"); + expect(s.series.val.last_session_date.toMillis()).toBe( + DateTime.fromISO("2026-08-28T15:00:00Z").toMillis() + ); + }); + + it("maps an absent series to None", () => { + expect(transformCoachingSession(rawWire).series?.none).toBe(true); + }); + + it("maps a null series to None", () => { + expect(transformCoachingSession({ ...rawWire, series: null }).series?.none).toBe(true); + }); + + it("never includes series on the wire", () => { + const fe = transformCoachingSession({ + ...rawWire, + series: { id: "ser-1", last_session_date: "2026-08-28T15:00:00" }, + }); + expect("series" in serializeCoachingSession(fe)).toBe(false); + }); +}); diff --git a/src/components/ui/dashboard/session-buckets/bucket-accordion.tsx b/src/components/ui/dashboard/session-buckets/bucket-accordion.tsx index e5846bee3..c3d6be2f8 100644 --- a/src/components/ui/dashboard/session-buckets/bucket-accordion.tsx +++ b/src/components/ui/dashboard/session-buckets/bucket-accordion.tsx @@ -55,6 +55,7 @@ export interface BucketAccordionProps { const SESSION_INCLUDES: CoachingSessionInclude[] = [ CoachingSessionInclude.Relationship, CoachingSessionInclude.Goal, + CoachingSessionInclude.Series, ]; // The overlap bucket fetches its full calendar window even when its diff --git a/src/components/ui/dashboard/session-buckets/this-week-accordion.tsx b/src/components/ui/dashboard/session-buckets/this-week-accordion.tsx index 65398212a..c4d2d360c 100644 --- a/src/components/ui/dashboard/session-buckets/this-week-accordion.tsx +++ b/src/components/ui/dashboard/session-buckets/this-week-accordion.tsx @@ -43,6 +43,7 @@ export interface ThisWeekAccordionProps { const SESSION_INCLUDES: CoachingSessionInclude[] = [ CoachingSessionInclude.Relationship, CoachingSessionInclude.Goal, + CoachingSessionInclude.Series, ]; export function ThisWeekAccordion({ diff --git a/src/components/ui/dashboard/session-buckets/today-section.tsx b/src/components/ui/dashboard/session-buckets/today-section.tsx index 30797f372..41d26b6be 100644 --- a/src/components/ui/dashboard/session-buckets/today-section.tsx +++ b/src/components/ui/dashboard/session-buckets/today-section.tsx @@ -39,6 +39,7 @@ export interface TodaySectionProps { const SESSION_INCLUDES: CoachingSessionInclude[] = [ CoachingSessionInclude.Relationship, CoachingSessionInclude.Goal, + CoachingSessionInclude.Series, ]; export function TodaySection({ diff --git a/src/lib/api/coaching-session-series.ts b/src/lib/api/coaching-session-series.ts index 3df22e9a4..08a5d3341 100644 --- a/src/lib/api/coaching-session-series.ts +++ b/src/lib/api/coaching-session-series.ts @@ -13,6 +13,9 @@ import { parseCoachingSessionSeriesWithSessions, } from "@/types/coaching-session-series"; import { EntityApi } from "./entity-api"; +import type { Result } from "neverthrow"; +import { sessionGuard } from "@/lib/auth/session-guard"; +import { attemptResend, type ResendInviteFailure } from "@/types/resend-invite"; const COACHING_SESSION_SERIES_BASEURL: string = `${siteConfig.env.backendServiceURL}/coaching_session_series`; @@ -92,6 +95,12 @@ export const CoachingSessionSeriesApi = { ); return defaultCoachingSessionSeriesWithSessions(); }, + + /** Re-sends the series' calendar invite to the coachee. */ + resendInvite: (seriesId: Id): Promise> => + attemptResend(() => + sessionGuard.post(`${COACHING_SESSION_SERIES_BASEURL}/${seriesId}/resend_invite`) + ), }; /** diff --git a/src/lib/api/coaching-sessions.ts b/src/lib/api/coaching-sessions.ts index 932b9f6d6..ff7ee7f91 100644 --- a/src/lib/api/coaching-sessions.ts +++ b/src/lib/api/coaching-sessions.ts @@ -17,6 +17,8 @@ import { USERS_BASEURL } from "./users"; import { sessionGuard } from "@/lib/auth/session-guard"; import { Option } from "@/types/option"; import { DateTime } from "ts-luxon"; +import type { Result } from "neverthrow"; +import { attemptResend, type ResendInviteFailure } from "@/types/resend-invite"; // Re-export for convenience export { CoachingSessionInclude }; @@ -140,6 +142,12 @@ export const CoachingSessionApi = { return transformCoachingSession(res.data.data); }, + /** Re-sends a one-off session's calendar invite to the coachee. */ + resendInvite: (sessionId: Id): Promise> => + attemptResend(() => + sessionGuard.post(`${COACHING_SESSIONS_BASEURL}/${sessionId}/resend_invite`) + ), + /** * Deletes an coaching session. * diff --git a/src/types/coaching-session.ts b/src/types/coaching-session.ts index 456a094a7..164d95387 100644 --- a/src/types/coaching-session.ts +++ b/src/types/coaching-session.ts @@ -38,6 +38,13 @@ export enum CoachingSessionInclude { Organization = "organization", Goal = "goal", Agreements = "agreements", + Series = "series", +} + +/** The part of a session's series the list view needs. */ +export interface CoachingSessionSeriesSummary { + id: Id; + last_session_date: DateTime; // parsed as UTC, like session.date } /** @@ -71,6 +78,9 @@ export interface EnrichedCoachingSession extends CoachingSession { /** Agreement (included when include=agreements) */ agreement?: Agreement; + + /** None when the session is not in a series; undefined when not requested. */ + series?: Option; } export function isCoachingSession(value: unknown): value is CoachingSession { @@ -156,7 +166,7 @@ export type CoachingSessionWire = Omit< * `title` is the only field needing normalization; the spread is deliberate so * the EnrichedCoachingSession include-fields (coach/coachee/goals/…) pass * through untouched when this is reused for the enriched list endpoint. */ -export function transformCoachingSession(raw: any): CoachingSession { +export function transformCoachingSession(raw: any): EnrichedCoachingSession { if (raw == null) return defaultCoachingSession(); return { ...raw, @@ -172,15 +182,26 @@ export function transformCoachingSession(raw: any): CoachingSession { typeof raw.coaching_session_series_id === "string" ? Some(raw.coaching_session_series_id) : None, + series: transformSeriesSummary(raw.series), }; } +function transformSeriesSummary(raw: any): Option { + if (typeof raw?.id !== "string" || typeof raw?.last_session_date !== "string") { + return None; + } + return Some({ + id: raw.id, + last_session_date: DateTime.fromISO(raw.last_session_date, { zone: "utc" }), + }); +} + /** Converts a CoachingSession to wire format, unwrapping the Option title. */ export function serializeCoachingSession( - session: CoachingSession + session: EnrichedCoachingSession ): CoachingSessionWire { - // display_title is server-computed/read-only — never send it back. - const { display_title: _displayTitle, ...rest } = session; + // display_title and series are server-computed/read-only — never send them back. + const { display_title: _displayTitle, series: _series, ...rest } = session; return { ...rest, title: session.title.some ? session.title.val : null, diff --git a/src/types/resend-invite.ts b/src/types/resend-invite.ts new file mode 100644 index 000000000..88f2c3ca7 --- /dev/null +++ b/src/types/resend-invite.ts @@ -0,0 +1,50 @@ +import axios from "axios"; +import { err, ok, type Result } from "neverthrow"; +import { None, Some, type Option } from "@/types/option"; + +/** Why the backend refused a resend; values match the 409 body's `details.reason`. */ +export enum ResendInviteRefusal { + LegacySeries = "legacy_series", + NoUpcomingSessions = "no_upcoming_sessions", + SeriesMember = "series_member", +} + +export type ResendInviteFailure = + | { kind: "refused"; reason: ResendInviteRefusal } + | { kind: "not_delivered" } // 502 + | { kind: "network" } // no response + | { kind: "unexpected"; status: Option }; + +const REFUSALS: ReadonlySet = new Set(Object.values(ResendInviteRefusal)); + +function refusalOf(data: unknown): Option { + const reason = (data as { details?: { reason?: unknown } } | undefined)?.details?.reason; + return typeof reason === "string" && REFUSALS.has(reason) + ? Some(reason as ResendInviteRefusal) + : None; +} + +export function toResendInviteFailure(error: unknown): ResendInviteFailure { + if (!axios.isAxiosError(error)) return { kind: "unexpected", status: None }; + if (!error.response) return { kind: "network" }; + const { status, data } = error.response; + if (status === 409) { + const reason = refusalOf(data); + return reason.some + ? { kind: "refused", reason: reason.val } + : { kind: "unexpected", status: Some(409) }; + } + if (status === 502) return { kind: "not_delivered" }; + return { kind: "unexpected", status: Some(status) }; +} + +export async function attemptResend( + call: () => Promise +): Promise> { + try { + await call(); + return ok(undefined); + } catch (error) { + return err(toResendInviteFailure(error)); + } +} From 296cfb0894f3cd7c31597065ab37f63eb16b569c Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 11:14:49 -0500 Subject: [PATCH 3/8] feat(dashboard): resend a calendar invite from the session menu --- .../coaching-sessions-card-resend.test.tsx | 138 ++++++++++++++++++ .../dashboard/coaching-sessions-row.test.tsx | 103 ++++++++++++- .../dashboard/resend-invite-dialog.test.tsx | 20 +++ __tests__/e2e/resend-calendar-invite.spec.ts | 115 +++++++++++++++ .../ui/dashboard/coaching-sessions-card.tsx | 122 +++++++++++++++- .../ui/dashboard/coaching-sessions-row.tsx | 57 ++++++++ .../ui/dashboard/resend-invite-dialog.tsx | 64 ++++++++ .../session-buckets/bucket-accordion.tsx | 6 + .../dashboard/session-buckets/bucket-list.tsx | 3 + .../session-buckets/buckets-container.tsx | 8 + .../session-buckets/this-week-accordion.tsx | 3 + .../session-buckets/today-section.tsx | 3 + 12 files changed, 633 insertions(+), 9 deletions(-) create mode 100644 __tests__/components/ui/dashboard/coaching-sessions-card-resend.test.tsx create mode 100644 __tests__/components/ui/dashboard/resend-invite-dialog.test.tsx create mode 100644 __tests__/e2e/resend-calendar-invite.spec.ts create mode 100644 src/components/ui/dashboard/resend-invite-dialog.tsx diff --git a/__tests__/components/ui/dashboard/coaching-sessions-card-resend.test.tsx b/__tests__/components/ui/dashboard/coaching-sessions-card-resend.test.tsx new file mode 100644 index 000000000..3326b9e8e --- /dev/null +++ b/__tests__/components/ui/dashboard/coaching-sessions-card-resend.test.tsx @@ -0,0 +1,138 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import { Toaster } from "sonner"; +import { server } from "@/test-utils/msw-server"; +import { CoachingSessionsCard } from "@/components/ui/dashboard/coaching-sessions-card"; +import { Some } from "@/types/option"; +import { createMockEnrichedSession } from "../../../test-utils"; + +vi.mock("@/site.config", () => ({ + siteConfig: { + env: { + backendServiceURL: "http://localhost:4000", + backendApiVersion: "1.0.0-test", + }, + }, +})); + +vi.mock("@/lib/providers/auth-store-provider", () => ({ + useAuthStore: (selector: (state: unknown) => unknown) => + selector({ userSession: { id: "coach-1", timezone: "UTC" }, isACoach: true }), +})); + +vi.mock("@/lib/providers/coaching-sessions-card-filter-store-provider", () => ({ + useCoachingSessionsCardFilterStore: (selector: (state: unknown) => unknown) => + selector({ relationshipFilter: undefined, setRelationshipFilter: vi.fn() }), +})); + +vi.mock("@/lib/hooks/use-current-organization", () => ({ + useCurrentOrganization: () => ({ currentOrganizationId: "org-1" }), +})); + +vi.mock("@/lib/api/coaching-relationships", () => ({ + useCoachingRelationshipList: () => ({ + relationships: [], + isLoading: false, + isError: undefined, + refresh: vi.fn(), + }), +})); + +// One button that hands a fixed series session to the card's resend callback. +vi.mock("@/components/ui/dashboard/session-buckets/buckets-container", () => ({ + BucketsContainer: ({ + onRequestResendInvite, + }: { + onRequestResendInvite: (s: unknown) => void; + }) => ( + + ), +})); + +const BASE = "http://localhost:4000"; +const SESSION = createMockEnrichedSession({ + id: "session-7", + coaching_session_series_id: Some("series-9"), +}); + +let posts: string[]; + +function respondToSeries(response: () => Response) { + server.use( + http.post(`${BASE}/coaching_session_series/:id/resend_invite`, ({ request }) => { + posts.push(new URL(request.url).pathname); + return response(); + }), + http.post(`${BASE}/coaching_sessions/:id/resend_invite`, ({ request }) => { + posts.push(new URL(request.url).pathname); + return new HttpResponse(null, { status: 204 }); + }) + ); +} + +async function confirmResend() { + const user = userEvent.setup(); + render( + <> + + + + ); + await user.click(screen.getByRole("button", { name: "trigger resend" })); + await screen.findByRole("alertdialog", { name: "Resend series invite?" }); + await user.click(screen.getByRole("button", { name: "Resend invite" })); + return user; +} + +beforeEach(() => { + posts = []; +}); + +describe("CoachingSessionsCard — resend invite", () => { + it("POSTs once to the series route and announces success", async () => { + respondToSeries(() => new HttpResponse(null, { status: 204 })); + await confirmResend(); + + expect( + await screen.findByText("Invite sent to you and Alex Chen") + ).toBeInTheDocument(); + expect(posts).toEqual(["/coaching_session_series/series-9/resend_invite"]); + await waitFor(() => + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument() + ); + }); + + it("shows a retry message on a 502", async () => { + respondToSeries(() => HttpResponse.json({}, { status: 502 })); + await confirmResend(); + + expect( + await screen.findByText("Couldn't resend the invite") + ).toBeInTheDocument(); + }); + + it("offers to resend just this session for a legacy series", async () => { + respondToSeries(() => + HttpResponse.json( + { status_code: 409, details: { reason: "legacy_series" } }, + { status: 409 } + ) + ); + const user = await confirmResend(); + + await user.click( + await screen.findByRole("button", { name: "Resend this session" }) + ); + + await waitFor(() => + expect(posts).toEqual([ + "/coaching_session_series/series-9/resend_invite", + "/coaching_sessions/session-7/resend_invite", + ]) + ); + }); +}); diff --git a/__tests__/components/ui/dashboard/coaching-sessions-row.test.tsx b/__tests__/components/ui/dashboard/coaching-sessions-row.test.tsx index ec18a6cb0..60d46b65f 100644 --- a/__tests__/components/ui/dashboard/coaching-sessions-row.test.tsx +++ b/__tests__/components/ui/dashboard/coaching-sessions-row.test.tsx @@ -1,8 +1,10 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { DateTime, Settings } from "ts-luxon"; import { SessionRow } from "@/components/ui/dashboard/coaching-sessions-row"; -import { Some } from "@/types/option"; +import { Some, type Option } from "@/types/option"; +import type { CoachingSessionSeriesSummary } from "@/types/coaching-session"; import { createMockEnrichedSession } from "../../../test-utils"; // `copyCoachingSessionLinkWithToast` writes to the clipboard and fires a @@ -21,6 +23,8 @@ function renderRow(opts: { isPast?: boolean; /** When set, the session belongs to a series with this id. */ seriesId?: string; + series?: Option; + onRequestResendInvite?: ReturnType; onReschedule?: ReturnType; onRequestDelete?: ReturnType; onSeriesAction?: ReturnType; @@ -30,11 +34,11 @@ function renderRow(opts: { const onRequestDelete = opts.onRequestDelete ?? vi.fn(); const onSeriesAction = opts.onSeriesAction ?? vi.fn(); const onSelect = opts.onSelect ?? vi.fn(); - const session = createMockEnrichedSession( - opts.seriesId - ? { coaching_session_series_id: Some(opts.seriesId) } - : undefined - ); + const onRequestResendInvite = opts.onRequestResendInvite ?? vi.fn(); + const session = createMockEnrichedSession({ + ...(opts.seriesId && { coaching_session_series_id: Some(opts.seriesId) }), + ...(opts.series && { series: opts.series }), + }); render( ); - return { session, onReschedule, onRequestDelete, onSeriesAction, onSelect }; + return { + session, + onReschedule, + onRequestDelete, + onRequestResendInvite, + onSeriesAction, + onSelect, + }; } async function openKebab() { @@ -225,3 +237,78 @@ async function openKebabAfterRender() { }); return openKebab(); } + +describe("SessionRow — resend invite", () => { + const originalNow = Settings.now; + const NOW = DateTime.fromISO("2026-10-20T12:00:00Z", { zone: "utc" }); + const seriesEnding = (iso: string) => + Some({ + id: "series-1", + last_session_date: DateTime.fromISO(iso, { zone: "utc" }), + }); + + beforeEach(() => { + vi.clearAllMocks(); + Settings.now = () => NOW.toMillis(); + }); + + afterEach(() => { + Settings.now = originalNow; + }); + + const item = () => screen.queryByTestId("session-row-resend-invite"); + + it("is hidden from a coachee", async () => { + renderRow({ viewerId: COACHEE_ID }); + await openKebab(); + expect(item()).not.toBeInTheDocument(); + }); + + it("is enabled as Resend invite for a coach on an upcoming one-off", async () => { + renderRow({ viewerId: COACH_ID, isPast: false }); + await openKebab(); + expect(item()).not.toHaveAttribute("aria-disabled"); + expect(item()).toHaveTextContent(/^Resend invite$/); + }); + + it("is disabled with a hint for a coach on a past one-off", async () => { + renderRow({ viewerId: COACH_ID, isPast: true }); + await openKebab(); + expect(item()).toHaveAttribute("aria-disabled", "true"); + expect(item()).toHaveAttribute("data-disabled"); + expect(screen.getByText("No upcoming sessions")).toBeVisible(); + }); + + it("is enabled as Resend series invite when the series has a session ahead", async () => { + renderRow({ + viewerId: COACH_ID, + isPast: true, + seriesId: "series-1", + series: seriesEnding("2026-12-01T15:00:00Z"), + }); + await openKebab(); + expect(item()).not.toHaveAttribute("aria-disabled"); + expect(item()).toHaveTextContent(/^Resend series invite$/); + }); + + it("is disabled with a hint when the series' last session has passed", async () => { + renderRow({ + viewerId: COACH_ID, + seriesId: "series-1", + series: seriesEnding("2026-10-01T15:00:00Z"), + }); + await openKebab(); + expect(item()).toHaveAttribute("aria-disabled", "true"); + expect(item()).toHaveTextContent("Resend series invite"); + expect(screen.getByText("No upcoming sessions")).toBeVisible(); + }); + + it("hands the session to onRequestResendInvite when clicked", async () => { + const onRequestResendInvite = vi.fn(); + const { session } = renderRow({ viewerId: COACH_ID, onRequestResendInvite }); + const user = await openKebab(); + await user.click(screen.getByTestId("session-row-resend-invite")); + expect(onRequestResendInvite).toHaveBeenCalledTimes(1); + expect(onRequestResendInvite).toHaveBeenCalledWith(session); + }); +}); diff --git a/__tests__/components/ui/dashboard/resend-invite-dialog.test.tsx b/__tests__/components/ui/dashboard/resend-invite-dialog.test.tsx new file mode 100644 index 000000000..9f870533c --- /dev/null +++ b/__tests__/components/ui/dashboard/resend-invite-dialog.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { ResendInviteDialog } from "@/components/ui/dashboard/resend-invite-dialog"; + +describe("ResendInviteDialog", () => { + it("disables both buttons and reads Sending… while sending", () => { + render( + + ); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled(); + }); +}); diff --git a/__tests__/e2e/resend-calendar-invite.spec.ts b/__tests__/e2e/resend-calendar-invite.spec.ts new file mode 100644 index 000000000..a74cca460 --- /dev/null +++ b/__tests__/e2e/resend-calendar-invite.spec.ts @@ -0,0 +1,115 @@ +import { test, expect, type Page, type Route } from '@playwright/test' +import { + setupAuthentication, + mockCommonApiRoutes, + MOCK_USER_ID, + SINGLE_RELATIONSHIP, +} from './helpers' + +// Resending a calendar invite from the dashboard session menu, against a mocked backend. + +test.use({ timezoneId: 'America/Chicago', locale: 'en-US' }) + +const NOW = '2026-10-21T17:00:00Z' +const SERIES_ID = 'series-1' +const RELATIONSHIP = { ...SINGLE_RELATIONSHIP[0], coach_id: MOCK_USER_ID } + +const person = (id: string, first: string, last: string) => ({ + id, + email: `${first.toLowerCase()}@example.com`, + first_name: first, + last_name: last, + display_name: `${first} ${last}`, + timezone: 'America/Chicago', + roles: [], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +}) + +const session = (id: string, date: string, series: { id: string; last_session_date: string } | null) => ({ + id, + coaching_relationship_id: RELATIONSHIP.id, + date, + duration_minutes: 60, + title: null, + coaching_session_series_id: series?.id ?? null, + series, + created_at: '2026-10-01T12:00:00Z', + updated_at: '2026-10-01T12:00:00Z', + relationship: { + id: RELATIONSHIP.id, + coach_id: MOCK_USER_ID, + coachee_id: RELATIONSHIP.coachee_id, + organization_id: 'org-1', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + coach: person(MOCK_USER_ID, 'Test', 'User'), + coachee: person(RELATIONSHIP.coachee_id, 'Jane', 'Smith'), +}) + +const SERIES_SESSION = session('series-session', '2026-10-21T21:00:00', { + id: SERIES_ID, + last_session_date: '2026-12-30T21:00:00', +}) +const PAST_ONE_OFF = session('past-one-off', '2026-10-21T14:00:00', null) + +const json = (data: unknown) => ({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data }), +}) + +async function mockDashboard(page: Page): Promise { + const resendPosts: string[] = [] + await mockCommonApiRoutes(page, { relationships: [RELATIONSHIP] }) + await page.route( + (url) => url.pathname === `/users/${MOCK_USER_ID}/coaching_sessions`, + (route) => route.fulfill(json([SERIES_SESSION, PAST_ONE_OFF])) + ) + await page.route('**/coaching_sessions/counts**', (route) => route.fulfill(json({ counts: [] }))) + await page.route('**/resend_invite', (route: Route) => { + resendPosts.push(`${route.request().method()} ${new URL(route.request().url()).pathname}`) + return route.fulfill({ status: 204 }) + }) + return resendPosts +} + +async function openRowMenu(page: Page, sessionId: string) { + const row = page.getByRole('region', { name: 'Today' }).getByTestId(`session-row-${sessionId}`) + await row.hover() + await row.getByRole('button', { name: 'Session actions' }).click() +} + +test.describe('Resend calendar invite', () => { + test.beforeEach(async ({ page, context }) => { + await page.clock.setFixedTime(NOW) + await setupAuthentication(page, context) + }) + + test('a double-clicked confirm sends one series resend', async ({ page }) => { + const resendPosts = await mockDashboard(page) + await page.goto('/dashboard') + + await openRowMenu(page, SERIES_SESSION.id) + await page.getByRole('menuitem', { name: 'Resend series invite' }).click() + + const dialog = page.getByRole('alertdialog', { name: 'Resend series invite?' }) + await dialog.getByRole('button', { name: 'Resend invite' }).dblclick() + + await expect(page.getByText('Invite sent to you and Jane Smith')).toBeVisible() + expect(resendPosts).toEqual([`POST /coaching_session_series/${SERIES_ID}/resend_invite`]) + }) + + test('the item is disabled on a past one-off', async ({ page }) => { + await mockDashboard(page) + await page.goto('/dashboard') + + await page.getByRole('tab', { name: 'Previous' }).click() + await openRowMenu(page, PAST_ONE_OFF.id) + + const item = page.getByTestId('session-row-resend-invite') + await expect(item).toHaveAttribute('aria-disabled', 'true') + await expect(item).toContainText('No upcoming sessions') + }) +}) diff --git a/src/components/ui/dashboard/coaching-sessions-card.tsx b/src/components/ui/dashboard/coaching-sessions-card.tsx index 5b6a05baa..16aa29aba 100644 --- a/src/components/ui/dashboard/coaching-sessions-card.tsx +++ b/src/components/ui/dashboard/coaching-sessions-card.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DateTime } from "ts-luxon"; import { toast as sonnerToast } from "sonner"; +import type { Result } from "neverthrow"; import { useSWRConfig } from "swr"; import { Card, CardContent } from "@/components/ui/card"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -12,12 +13,17 @@ import { } from "@/components/ui/dashboard/coaching-sessions-card-header"; import { BucketsContainer } from "@/components/ui/dashboard/session-buckets/buckets-container"; import { DeleteSessionDialog } from "@/components/ui/dashboard/delete-session-dialog"; +import { ResendInviteDialog } from "@/components/ui/dashboard/resend-invite-dialog"; import { SeriesActionDialogs } from "@/components/ui/dashboard/series-action-dialogs"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; import { useCoachingSessionsCardFilterStore } from "@/lib/providers/coaching-sessions-card-filter-store-provider"; import { useCurrentOrganization } from "@/lib/hooks/use-current-organization"; import { useCoachingRelationshipList } from "@/lib/api/coaching-relationships"; -import { useCoachingSessionMutation } from "@/lib/api/coaching-sessions"; +import { + CoachingSessionApi, + useCoachingSessionMutation, +} from "@/lib/api/coaching-sessions"; +import { CoachingSessionSeriesApi } from "@/lib/api/coaching-session-series"; import { USERS_BASEURL } from "@/lib/api/users"; import type { Id } from "@/types/general"; import { getBrowserTimezone } from "@/lib/timezone-utils"; @@ -26,6 +32,10 @@ import { type CoachingSession, type EnrichedCoachingSession, } from "@/types/coaching-session"; +import { + ResendInviteRefusal, + type ResendInviteFailure, +} from "@/types/resend-invite"; import { isUserCoachInRelationship, isUserCoacheeInRelationship, @@ -54,6 +64,67 @@ type DeleteDialogState = | { kind: "pending"; session: EnrichedCoachingSession } | { kind: "deleting"; session: EnrichedCoachingSession }; +type ResendDialogState = + | { kind: "closed" } + | { kind: "pending"; session: EnrichedCoachingSession } + | { kind: "sending"; session: EnrichedCoachingSession }; + +const RESEND_FAILED = "Couldn't resend the invite"; +const RESEND_RETRY = { description: "Please try again in a moment." }; + +function resendSessionInvite( + session: EnrichedCoachingSession, + participantName: string +) { + void CoachingSessionApi.resendInvite(session.id).then((result) => + announceResendResult(result, { session, participantName, isSeries: false }) + ); +} + +function announceResendResult( + result: Result, + ctx: { + session: EnrichedCoachingSession; + participantName: string; + isSeries: boolean; + } +): void { + if (result.isOk()) { + sonnerToast.success(`Invite sent to you and ${ctx.participantName}`); + return; + } + const failure = result.error; + if (failure.kind !== "refused") { + sonnerToast.error(RESEND_FAILED, RESEND_RETRY); + return; + } + switch (failure.reason) { + case ResendInviteRefusal.LegacySeries: + sonnerToast.error("This series can't be resent.", { + description: "You can resend this session instead.", + action: { + label: "Resend this session", + onClick: () => resendSessionInvite(ctx.session, ctx.participantName), + }, + }); + return; + case ResendInviteRefusal.NoUpcomingSessions: + sonnerToast.error( + ctx.isSeries + ? "This series has no upcoming sessions." + : "This session has already happened." + ); + return; + case ResendInviteRefusal.SeriesMember: + sonnerToast.error(RESEND_FAILED, RESEND_RETRY); + return; + default: { + const _exhaustive: never = failure.reason; + throw new Error(`Unhandled resend refusal: ${_exhaustive}`); + } + } +} + export interface CoachingSessionsCardProps { /** Opens the create/edit dialog with the given session pre-filled. */ onReschedule: (session: CoachingSession | EnrichedCoachingSession) => void; @@ -187,6 +258,43 @@ export function CoachingSessionsCard({ kind: "closed", }); + // ── Resend invite flow ─────────────────────────────────────────────── + const [resendState, setResendState] = useState({ + kind: "closed", + }); + + const resendParticipantName = useMemo(() => { + if (resendState.kind === "closed" || !userSession) return ""; + const info = getSessionParticipantInfo(resendState.session, userSession.id); + return info?.participantName ?? "Unknown"; + }, [resendState, userSession]); + + const handleRequestResendInvite = useCallback( + (session: EnrichedCoachingSession) => + setResendState({ kind: "pending", session }), + [] + ); + + const handleCancelResend = useCallback(() => { + setResendState({ kind: "closed" }); + }, []); + + const handleConfirmResend = useCallback(async () => { + if (resendState.kind !== "pending") return; + const { session } = resendState; + setResendState({ kind: "sending", session }); + const seriesId = session.coaching_session_series_id; + const result = seriesId.some + ? await CoachingSessionSeriesApi.resendInvite(seriesId.val) + : await CoachingSessionApi.resendInvite(session.id); + announceResendResult(result, { + session, + participantName: resendParticipantName, + isSeries: seriesId.some, + }); + setResendState({ kind: "closed" }); + }, [resendState, resendParticipantName]); + // ── Series actions ─────────────────────────────────────────────────── // A session row's kebab surfaces view / edit / delete for the series it // belongs to. The row only knows the `coaching_session_series_id`, so the @@ -283,6 +391,7 @@ export function CoachingSessionsCard({ mountNow={mountNow} onReschedule={onReschedule} onRequestDelete={handleRequestDelete} + onRequestResendInvite={handleRequestResendInvite} onSeriesAction={handleSeriesAction} /> )} @@ -301,6 +410,17 @@ export function CoachingSessionsCard({ onCancel={handleCancelDelete} onConfirm={handleConfirmDelete} /> + ` and the delete mutation. Not invoked on * rows where the viewer isn't a coach — the kebab item is hidden. */ onRequestDelete: (session: EnrichedCoachingSession) => void; + /** Hands the session up to the card, which owns the resend dialog. Coach-only. */ + onRequestResendInvite: (session: EnrichedCoachingSession) => void; /** Hands a series action up to the card so it can fetch the series and * drive the view/edit/delete dialogs. Only invoked for sessions that * belong to a series; edit/delete items are coach-only. */ @@ -62,6 +65,7 @@ export function SessionRow({ onSelect, onReschedule, onRequestDelete, + onRequestResendInvite, onSeriesAction, }: SessionRowProps) { const participant = useMemo( @@ -88,6 +92,7 @@ export function SessionRow({ const seriesId = session.coaching_session_series_id; const canViewSeries = seriesId.some; const canManageSeries = seriesId.some && participant?.isCoach === true; + const canResendInvite = participant?.isCoach === true; const participantName = participant?.participantName ?? "Unknown"; const participantInitials = participant @@ -230,6 +235,13 @@ export function SessionRow({ Share link + {canResendInvite && ( + onRequestResendInvite(session)} + /> + )} {(canDelete || canManageSeries) && } {canDelete && ( ); } + +// An unknown series (not requested, or absent) stays enabled; the backend decides. +function hasUpcomingSession( + session: EnrichedCoachingSession, + isPast: boolean +): boolean { + if (!session.coaching_session_series_id.some) return !isPast; + const series = session.series; + if (series === undefined || series.none) return true; + return series.val.last_session_date > DateTime.now(); +} + +function ResendInviteMenuItem({ + session, + isPast, + onSelect, +}: { + session: EnrichedCoachingSession; + isPast: boolean; + onSelect: () => void; +}) { + const label = session.coaching_session_series_id.some + ? "Resend series invite" + : "Resend invite"; + const enabled = hasUpcomingSession(session, isPast); + + return ( + + +
+ {label} + {!enabled && ( + + No upcoming sessions + + )} +
+
+ ); +} diff --git a/src/components/ui/dashboard/resend-invite-dialog.tsx b/src/components/ui/dashboard/resend-invite-dialog.tsx new file mode 100644 index 000000000..337b12e9d --- /dev/null +++ b/src/components/ui/dashboard/resend-invite-dialog.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +export interface ResendInviteDialogProps { + open: boolean; + isSeries: boolean; + participantName: string; + isSending: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +export function ResendInviteDialog({ + open, + isSeries, + participantName, + isSending, + onCancel, + onConfirm, +}: ResendInviteDialogProps) { + return ( + { + if (!next && !isSending) onCancel(); + }} + > + + + + {isSeries ? "Resend series invite?" : "Resend invite?"} + + + {isSeries + ? `You and ${participantName} will each get a new calendar invite for every session in this series. Accepting it updates your calendars to match.` + : `You and ${participantName} will each get a new calendar invite for this session.`} + + + + Cancel + { + e.preventDefault(); + onConfirm(); + }} + disabled={isSending} + > + {isSending ? "Sending…" : "Resend invite"} + + + + + ); +} diff --git a/src/components/ui/dashboard/session-buckets/bucket-accordion.tsx b/src/components/ui/dashboard/session-buckets/bucket-accordion.tsx index c3d6be2f8..7460668e2 100644 --- a/src/components/ui/dashboard/session-buckets/bucket-accordion.tsx +++ b/src/components/ui/dashboard/session-buckets/bucket-accordion.tsx @@ -49,6 +49,7 @@ export interface BucketAccordionProps { onSelect: (session: EnrichedCoachingSession) => void; onReschedule: (session: EnrichedCoachingSession) => void; onRequestDelete: (session: EnrichedCoachingSession) => void; + onRequestResendInvite: (session: EnrichedCoachingSession) => void; onSeriesAction: (action: "view" | "edit" | "delete", seriesId: Id) => void; } @@ -88,6 +89,7 @@ export function BucketAccordion({ onSelect, onReschedule, onRequestDelete, + onRequestResendInvite, onSeriesAction, }: BucketAccordionProps) { const [hasEverExpanded, setHasEverExpanded] = useState(isExpanded); @@ -175,6 +177,7 @@ export function BucketAccordion({ onSelect={onSelect} onReschedule={onReschedule} onRequestDelete={onRequestDelete} + onRequestResendInvite={onRequestResendInvite} onSeriesAction={onSeriesAction} /> @@ -192,6 +195,7 @@ interface BucketBodyProps { onSelect: (session: EnrichedCoachingSession) => void; onReschedule: (session: EnrichedCoachingSession) => void; onRequestDelete: (session: EnrichedCoachingSession) => void; + onRequestResendInvite: (session: EnrichedCoachingSession) => void; onSeriesAction: (action: "view" | "edit" | "delete", seriesId: Id) => void; } @@ -205,6 +209,7 @@ function BucketBody({ onSelect, onReschedule, onRequestDelete, + onRequestResendInvite, onSeriesAction, }: BucketBodyProps) { if (isLoading && sessions.length === 0) { @@ -238,6 +243,7 @@ function BucketBody({ onSelect={() => onSelect(session)} onReschedule={onReschedule} onRequestDelete={onRequestDelete} + onRequestResendInvite={onRequestResendInvite} onSeriesAction={onSeriesAction} /> ))} diff --git a/src/components/ui/dashboard/session-buckets/bucket-list.tsx b/src/components/ui/dashboard/session-buckets/bucket-list.tsx index e517388c8..349e7c101 100644 --- a/src/components/ui/dashboard/session-buckets/bucket-list.tsx +++ b/src/components/ui/dashboard/session-buckets/bucket-list.tsx @@ -31,6 +31,7 @@ export interface BucketListProps { onSelect: (session: EnrichedCoachingSession) => void; onReschedule: (session: EnrichedCoachingSession) => void; onRequestDelete: (session: EnrichedCoachingSession) => void; + onRequestResendInvite: (session: EnrichedCoachingSession) => void; onSeriesAction: (action: "view" | "edit" | "delete", seriesId: Id) => void; /** Bucket keys that just appeared in the list — animated in on render. */ recentlyAddedKeys: Set; @@ -64,6 +65,7 @@ export function BucketList({ onSelect, onReschedule, onRequestDelete, + onRequestResendInvite, onSeriesAction, recentlyAddedKeys, showMoreLabel, @@ -171,6 +173,7 @@ export function BucketList({ onSelect={onSelect} onReschedule={onReschedule} onRequestDelete={onRequestDelete} + onRequestResendInvite={onRequestResendInvite} onSeriesAction={onSeriesAction} animateIn={recentlyAddedKeys.has(bucket.key)} /> diff --git a/src/components/ui/dashboard/session-buckets/buckets-container.tsx b/src/components/ui/dashboard/session-buckets/buckets-container.tsx index 9186b992e..fd6ab759f 100644 --- a/src/components/ui/dashboard/session-buckets/buckets-container.tsx +++ b/src/components/ui/dashboard/session-buckets/buckets-container.tsx @@ -49,6 +49,7 @@ export interface BucketsContainerProps { mountNow: DateTime; onReschedule: (session: EnrichedCoachingSession) => void; onRequestDelete: (session: EnrichedCoachingSession) => void; + onRequestResendInvite: (session: EnrichedCoachingSession) => void; onSeriesAction: (action: "view" | "edit" | "delete", seriesId: Id) => void; } @@ -81,6 +82,7 @@ export function BucketsContainer({ mountNow, onReschedule, onRequestDelete, + onRequestResendInvite, onSeriesAction, }: BucketsContainerProps) { const [monthsForward, setMonthsForward] = useState( @@ -385,6 +387,7 @@ export function BucketsContainer({ onSelect={setSelectedSession} onReschedule={onReschedule} onRequestDelete={onRequestDelete} + onRequestResendInvite={onRequestResendInvite} onSeriesAction={onSeriesAction} /> void; onReschedule: (session: EnrichedCoachingSession) => void; onRequestDelete: (session: EnrichedCoachingSession) => void; + onRequestResendInvite: (session: EnrichedCoachingSession) => void; onSeriesAction: (action: "view" | "edit" | "delete", seriesId: Id) => void; } @@ -59,6 +60,7 @@ export function ThisWeekAccordion({ onSelect, onReschedule, onRequestDelete, + onRequestResendInvite, onSeriesAction, }: ThisWeekAccordionProps) { const isPastView = view === CoachingSessionBucketView.Previous; @@ -146,6 +148,7 @@ export function ThisWeekAccordion({ onSelect={() => onSelect(session)} onReschedule={onReschedule} onRequestDelete={onRequestDelete} + onRequestResendInvite={onRequestResendInvite} onSeriesAction={onSeriesAction} /> ))} diff --git a/src/components/ui/dashboard/session-buckets/today-section.tsx b/src/components/ui/dashboard/session-buckets/today-section.tsx index 41d26b6be..487fb3c12 100644 --- a/src/components/ui/dashboard/session-buckets/today-section.tsx +++ b/src/components/ui/dashboard/session-buckets/today-section.tsx @@ -33,6 +33,7 @@ export interface TodaySectionProps { onSelect: (session: EnrichedCoachingSession) => void; onReschedule: (session: EnrichedCoachingSession) => void; onRequestDelete: (session: EnrichedCoachingSession) => void; + onRequestResendInvite: (session: EnrichedCoachingSession) => void; onSeriesAction: (action: "view" | "edit" | "delete", seriesId: Id) => void; } @@ -55,6 +56,7 @@ export function TodaySection({ onSelect, onReschedule, onRequestDelete, + onRequestResendInvite, onSeriesAction, }: TodaySectionProps) { const isPastView = view === CoachingSessionBucketView.Previous; @@ -115,6 +117,7 @@ export function TodaySection({ onSelect={() => onSelect(session)} onReschedule={onReschedule} onRequestDelete={onRequestDelete} + onRequestResendInvite={onRequestResendInvite} onSeriesAction={onSeriesAction} /> ))} From 9d950c2412d04c1591a8413449830fca1b1d7697 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 11:27:21 -0500 Subject: [PATCH 4/8] docs: resend invite plan reflects the build; fixtures need no email --- .../resend-calendar-invite.md | 6 ++--- .../resend_calendar_invite_manual_testing.md | 25 ++++++++++--------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/implementation-plans/resend-calendar-invite.md b/docs/implementation-plans/resend-calendar-invite.md index 78189253f..53475e1a4 100644 --- a/docs/implementation-plans/resend-calendar-invite.md +++ b/docs/implementation-plans/resend-calendar-invite.md @@ -22,10 +22,10 @@ Branch: `resend-calendar-invite`. Built with the overseer/handoff workflow, one ## Data - `EnrichedCoachingSession.series?: Option`, where the summary is `{ id, last_session_date: DateTime }`. The dashboard adds `series` to its `include`. -- `CoachingSessionSeriesApi.resendInvite(id)` and `CoachingSessionApi.resendInvite(id)` return `Result`. The error is a `ResendInviteFailure` enum: `LegacySeries`, `NoUpcomingSessions`, `SeriesMember`, `Failed`. +- `CoachingSessionSeriesApi.resendInvite(id)` and `CoachingSessionApi.resendInvite(id)` return `Result`. The failure is a union: `refused` (with a `ResendInviteRefusal` enum: `LegacySeries`, `NoUpcomingSessions`, `SeriesMember`), `not_delivered` (502), `network`, or `unexpected`. ## Phases -- [ ] F1: types, transform, the two API calls, dashboard include (Vitest + MSW) -- [ ] F2: row gating, dialog, card state, toasts, and an isolated Playwright spec using `page.route` +- [x] F1 (`bdba09a5`): types, transform, the two API calls, dashboard include (Vitest + MSW) +- [x] F2 (`296cfb08`): row gating, dialog, card state, toasts, and an isolated Playwright spec using `page.route` - [ ] V: live run of the manual test plan through Playwright MCP against the backend branch diff --git a/docs/test-plans/resend_calendar_invite_manual_testing.md b/docs/test-plans/resend_calendar_invite_manual_testing.md index 5fa9e577f..47dbe829c 100644 --- a/docs/test-plans/resend_calendar_invite_manual_testing.md +++ b/docs/test-plans/resend_calendar_invite_manual_testing.md @@ -5,17 +5,18 @@ Checks that a coach can resend the calendar invite for a series or a one-off ses Backend counterpart: `refactor-platform-rs/docs/implementation-plans/resend-calendar-invite.md`. > [!IMPORTANT] -> This plan sends **real email**. The only recipients allowed are `jim@refactorgroup.com` (coach) and `james.hodapp@gmail.com` (coachee), in relationship `7adf81aa-23f4-4221-9f82-afb4a5f7512e`. Before any step marked ✉, confirm that the relationship's coach and coachee are still exactly those two addresses. Each ✉ shows how many emails the step sends. The whole plan sends about 16. +> This plan sends **real email**. The only recipients allowed are `jim@refactorgroup.com` (coach) and `james.hodapp@gmail.com` (coachee), in relationship `7adf81aa-23f4-4221-9f82-afb4a5f7512e`. Before any step marked ✉, confirm that the relationship's coach and coachee are still exactly those two addresses. Each ✉ shows how many emails the step sends. The whole plan sends about 8. ## 1. Prerequisites -1. Backend on `resend-calendar-invite` on `:4000`, migrations applied. `.env` must have the Resend API key and the session/recurring "scheduled", "rescheduled" and "cancelled" template ids. +1. Backend on `resend-calendar-invite` on `:4000`, migrations applied, with the session/recurring "scheduled", "rescheduled" and "cancelled" template ids set. Keep `RESEND_API_KEY` **unset** while building the fixtures in step 4, so they send no email. Set it and restart the backend before Part B. 2. Frontend on `resend-calendar-invite`, `npm run dev` on `:3000`. 3. Logins: the coach is `PW_ORG_ADMIN_LOGIN_*` and the coachee is `PW_USER_LOGIN_*`, both from `.env.local`. -4. Fixtures, created in the coach's browser: - 1. ✉×2 A weekly series of 6 sessions in the relationship above, starting next week. Don't accept it in either calendar. - 2. ✉×2 Reschedule occurrence 3 of that series by one day. - 3. ✉×2 Delete occurrence 4 of that series. +4. Fixtures, created in the coach's browser with email off: + 1. A weekly series of 6 sessions in the relationship above, starting next week. Neither calendar has it, which is the "lost invite" case. + 2. Reschedule occurrence 3 of that series by one day. + 3. Delete occurrence 4 of that series. + 4. A one-off session for tomorrow. 5. Fixtures created through SQL (no email): 1. A **finished series**: a series whose sessions are all in the past. 2. A **legacy series**: a series with at least one upcoming session whose members have `ical_recurrence_id` set to NULL. @@ -49,7 +50,7 @@ Open "Resend series invite" again and double-click Resend invite. **Pass:** - Exactly one `POST /coaching_session_series/:id/resend_invite`, answered 204. - While the request is in flight, the action shows "Sending…" and Cancel is disabled. -- The toast reads "Invite sent to you and James". +- The toast reads "Invite sent to you and ". - Both inboxes get the "recurring sessions scheduled" email with an `invite.ics` attachment titled "James / Jim". - After accepting in Google Calendar, there is one series. Occurrence 3 is at its moved time and occurrence 4 is gone. @@ -59,9 +60,9 @@ Once B2 has been accepted, resend the series again. **Pass:** each calendar updates the existing series. No second copy appears in either calendar. -### Case B4: ✉×4 one-off resend +### Case B4: ✉×2 one-off resend -Create a one-off session for tomorrow (✉×2), then use "Resend invite" on it (✉×2). +Use "Resend invite" on the one-off fixture. **Pass:** one `POST /coaching_sessions/:id/resend_invite`, answered 204, and a success toast. Each calendar shows one event for it, not two. @@ -79,11 +80,11 @@ In the coach's page, `fetch` `POST /coaching_session_series//resend **Pass:** 409, then 403. No email. -### Case C3: provider failure +### Case C3: email unavailable -Unset the recurring "scheduled" template id in the backend `.env` and restart the backend. Then resend the active series. +Run this before Part B, while `RESEND_API_KEY` is still unset. Resend the active series. -**Pass:** the response is 502 and the toast reads "Couldn't resend the invite". No email arrives. Restore the template id afterwards. +**Pass:** the response is a 5xx (500 for missing config; a provider refusal would be 502). The toast reads "Couldn't resend the invite". No email arrives, and the coach can try again. ## 5. Cleanup From b2b80e09dce6c36f0f767512cebb97e0c7705478 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 12:22:13 -0500 Subject: [PATCH 5/8] docs: resend invite live run done; test plan guards against reminder email --- docs/implementation-plans/resend-calendar-invite.md | 2 +- docs/test-plans/resend_calendar_invite_manual_testing.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/implementation-plans/resend-calendar-invite.md b/docs/implementation-plans/resend-calendar-invite.md index 53475e1a4..214d74078 100644 --- a/docs/implementation-plans/resend-calendar-invite.md +++ b/docs/implementation-plans/resend-calendar-invite.md @@ -28,4 +28,4 @@ Branch: `resend-calendar-invite`. Built with the overseer/handoff workflow, one - [x] F1 (`bdba09a5`): types, transform, the two API calls, dashboard include (Vitest + MSW) - [x] F2 (`296cfb08`): row gating, dialog, card state, toasts, and an isolated Playwright spec using `page.route` -- [ ] V: live run of the manual test plan through Playwright MCP against the backend branch +- [x] V: live run of the manual test plan through Playwright MCP against the backend branch, with Google Calendar checked by hand. This found the override SEQUENCE bug, fixed in backend B5. diff --git a/docs/test-plans/resend_calendar_invite_manual_testing.md b/docs/test-plans/resend_calendar_invite_manual_testing.md index 47dbe829c..8aa8ecba8 100644 --- a/docs/test-plans/resend_calendar_invite_manual_testing.md +++ b/docs/test-plans/resend_calendar_invite_manual_testing.md @@ -5,6 +5,8 @@ Checks that a coach can resend the calendar invite for a series or a one-off ses Backend counterpart: `refactor-platform-rs/docs/implementation-plans/resend-calendar-invite.md`. > [!IMPORTANT] +> Start the backend with `SESSION_REMINDER_EMAIL_TEMPLATE_ID=` (blank). Otherwise the reminder job mails every participant with a session in the next day, including real coachees in other relationships. +> > This plan sends **real email**. The only recipients allowed are `jim@refactorgroup.com` (coach) and `james.hodapp@gmail.com` (coachee), in relationship `7adf81aa-23f4-4221-9f82-afb4a5f7512e`. Before any step marked ✉, confirm that the relationship's coach and coachee are still exactly those two addresses. Each ✉ shows how many emails the step sends. The whole plan sends about 8. ## 1. Prerequisites @@ -17,7 +19,8 @@ Backend counterpart: `refactor-platform-rs/docs/implementation-plans/resend-cale 2. Reschedule occurrence 3 of that series by one day. 3. Delete occurrence 4 of that series. 4. A one-off session for tomorrow. -5. Fixtures created through SQL (no email): +5. If creating a one-off session through the app fails locally (it needs the collab document server), insert the one-off fixtures through SQL as well. +6. Fixtures created through SQL (no email): 1. A **finished series**: a series whose sessions are all in the past. 2. A **legacy series**: a series with at least one upcoming session whose members have `ical_recurrence_id` set to NULL. From 896b2fc24a827334af68c7a551ab652ea075a1f2 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 12:27:36 -0500 Subject: [PATCH 6/8] docs: drop the resend invite implementation plan from the branch --- .../resend-calendar-invite.md | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 docs/implementation-plans/resend-calendar-invite.md diff --git a/docs/implementation-plans/resend-calendar-invite.md b/docs/implementation-plans/resend-calendar-invite.md deleted file mode 100644 index 214d74078..000000000 --- a/docs/implementation-plans/resend-calendar-invite.md +++ /dev/null @@ -1,31 +0,0 @@ -# Resend calendar invite (frontend) - -Lets a coach resend the calendar invite for a series or a one-off session from the session row's ⋮ menu. The backend contract and its decisions live in `refactor-platform-rs/docs/implementation-plans/resend-calendar-invite.md`. The manual test plan is `docs/test-plans/resend_calendar_invite_manual_testing.md`. - -Branch: `resend-calendar-invite`. Built with the overseer/handoff workflow, one phase per commit. - -## UI - -- `coaching-sessions-row.tsx`: the item sits in the Share link group. - - It's only for the coach; a coachee doesn't see it. - - On a series session it reads "Resend series invite", otherwise "Resend invite". Icon: `MailPlus`. - - It is enabled when the session's series still has a session ahead (`series.last_session_date` in the future). A one-off is enabled when the session itself is upcoming. - - Otherwise it's disabled and shows the muted hint "No upcoming sessions". The hint is inline because Radix tooltips don't fire on disabled items. -- `coaching-sessions-card.tsx` keeps the resend state as a `closed | pending | sending` union, the same as the delete flow, and renders `ResendInviteDialog`. -- `resend-invite-dialog.tsx` is an AlertDialog. The title is "Resend series invite?" or "Resend invite?". The body says both people get a new invite. While sending, the action shows "Sending…" and Cancel is disabled. -- Toasts (sonner): - - Success: "Invite sent to you and {coachee}". - - 502: "Couldn't resend the invite". - - 409 `legacy_series`: "This series can't be resent. You can resend this session instead.", with a "Resend this session" action. - - 409 `no_upcoming_sessions`: "This series has no upcoming sessions." - -## Data - -- `EnrichedCoachingSession.series?: Option`, where the summary is `{ id, last_session_date: DateTime }`. The dashboard adds `series` to its `include`. -- `CoachingSessionSeriesApi.resendInvite(id)` and `CoachingSessionApi.resendInvite(id)` return `Result`. The failure is a union: `refused` (with a `ResendInviteRefusal` enum: `LegacySeries`, `NoUpcomingSessions`, `SeriesMember`), `not_delivered` (502), `network`, or `unexpected`. - -## Phases - -- [x] F1 (`bdba09a5`): types, transform, the two API calls, dashboard include (Vitest + MSW) -- [x] F2 (`296cfb08`): row gating, dialog, card state, toasts, and an isolated Playwright spec using `page.route` -- [x] V: live run of the manual test plan through Playwright MCP against the backend branch, with Google Calendar checked by hand. This found the override SEQUENCE bug, fixed in backend B5. From 4d3c9ab76ac140d513648f061ec0d174eec3942c Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 13:10:49 -0500 Subject: [PATCH 7/8] refactor(dashboard): tighten the resend invite code and tests --- .../coaching-sessions-card-resend.test.tsx | 66 +++------- .../dashboard/coaching-sessions-row.test.tsx | 59 +++------ __tests__/e2e/resend-calendar-invite.spec.ts | 83 ++++-------- __tests__/lib/api/resend-invite.test.ts | 95 ++++---------- .../coaching-session-display-title.test.ts | 40 +++--- .../ui/dashboard/coaching-sessions-card.tsx | 124 +----------------- .../ui/dashboard/use-resend-invite.ts | 115 ++++++++++++++++ 7 files changed, 221 insertions(+), 361 deletions(-) create mode 100644 src/components/ui/dashboard/use-resend-invite.ts diff --git a/__tests__/components/ui/dashboard/coaching-sessions-card-resend.test.tsx b/__tests__/components/ui/dashboard/coaching-sessions-card-resend.test.tsx index 3326b9e8e..46e1c96b5 100644 --- a/__tests__/components/ui/dashboard/coaching-sessions-card-resend.test.tsx +++ b/__tests__/components/ui/dashboard/coaching-sessions-card-resend.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { http, HttpResponse } from "msw"; import { Toaster } from "sonner"; import { server } from "@/test-utils/msw-server"; @@ -59,22 +59,18 @@ const SESSION = createMockEnrichedSession({ coaching_session_series_id: Some("series-9"), }); -let posts: string[]; - -function respondToSeries(response: () => Response) { +// Confirms the resend dialog with the series route answering `seriesResponse`; +// returns every resend path POSTed. +async function confirmResend(seriesResponse: Response) { + const posts: string[] = []; + const record = (response: Response) => ({ request }: { request: Request }) => { + posts.push(new URL(request.url).pathname); + return response; + }; server.use( - http.post(`${BASE}/coaching_session_series/:id/resend_invite`, ({ request }) => { - posts.push(new URL(request.url).pathname); - return response(); - }), - http.post(`${BASE}/coaching_sessions/:id/resend_invite`, ({ request }) => { - posts.push(new URL(request.url).pathname); - return new HttpResponse(null, { status: 204 }); - }) + http.post(`${BASE}/coaching_session_series/:id/resend_invite`, record(seriesResponse)), + http.post(`${BASE}/coaching_sessions/:id/resend_invite`, record(new HttpResponse(null, { status: 204 }))) ); -} - -async function confirmResend() { const user = userEvent.setup(); render( <> @@ -85,49 +81,27 @@ async function confirmResend() { await user.click(screen.getByRole("button", { name: "trigger resend" })); await screen.findByRole("alertdialog", { name: "Resend series invite?" }); await user.click(screen.getByRole("button", { name: "Resend invite" })); - return user; + return { user, posts }; } -beforeEach(() => { - posts = []; -}); - describe("CoachingSessionsCard — resend invite", () => { it("POSTs once to the series route and announces success", async () => { - respondToSeries(() => new HttpResponse(null, { status: 204 })); - await confirmResend(); - - expect( - await screen.findByText("Invite sent to you and Alex Chen") - ).toBeInTheDocument(); + const { posts } = await confirmResend(new HttpResponse(null, { status: 204 })); + expect(await screen.findByText("Invite sent to you and Alex Chen")).toBeInTheDocument(); expect(posts).toEqual(["/coaching_session_series/series-9/resend_invite"]); - await waitFor(() => - expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument() - ); + await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument()); }); it("shows a retry message on a 502", async () => { - respondToSeries(() => HttpResponse.json({}, { status: 502 })); - await confirmResend(); - - expect( - await screen.findByText("Couldn't resend the invite") - ).toBeInTheDocument(); + await confirmResend(HttpResponse.json({}, { status: 502 })); + expect(await screen.findByText("Couldn't resend the invite")).toBeInTheDocument(); }); it("offers to resend just this session for a legacy series", async () => { - respondToSeries(() => - HttpResponse.json( - { status_code: 409, details: { reason: "legacy_series" } }, - { status: 409 } - ) + const { user, posts } = await confirmResend( + HttpResponse.json({ details: { reason: "legacy_series" } }, { status: 409 }) ); - const user = await confirmResend(); - - await user.click( - await screen.findByRole("button", { name: "Resend this session" }) - ); - + await user.click(await screen.findByRole("button", { name: "Resend this session" })); await waitFor(() => expect(posts).toEqual([ "/coaching_session_series/series-9/resend_invite", diff --git a/__tests__/components/ui/dashboard/coaching-sessions-row.test.tsx b/__tests__/components/ui/dashboard/coaching-sessions-row.test.tsx index 60d46b65f..e1dfbd5e0 100644 --- a/__tests__/components/ui/dashboard/coaching-sessions-row.test.tsx +++ b/__tests__/components/ui/dashboard/coaching-sessions-row.test.tsx @@ -240,16 +240,12 @@ async function openKebabAfterRender() { describe("SessionRow — resend invite", () => { const originalNow = Settings.now; - const NOW = DateTime.fromISO("2026-10-20T12:00:00Z", { zone: "utc" }); const seriesEnding = (iso: string) => - Some({ - id: "series-1", - last_session_date: DateTime.fromISO(iso, { zone: "utc" }), - }); + Some({ id: "series-1", last_session_date: DateTime.fromISO(iso, { zone: "utc" }) }); beforeEach(() => { vi.clearAllMocks(); - Settings.now = () => NOW.toMillis(); + Settings.now = () => Date.parse("2026-10-20T12:00:00Z"); }); afterEach(() => { @@ -264,43 +260,22 @@ describe("SessionRow — resend invite", () => { expect(item()).not.toBeInTheDocument(); }); - it("is enabled as Resend invite for a coach on an upcoming one-off", async () => { - renderRow({ viewerId: COACH_ID, isPast: false }); - await openKebab(); - expect(item()).not.toHaveAttribute("aria-disabled"); - expect(item()).toHaveTextContent(/^Resend invite$/); - }); - - it("is disabled with a hint for a coach on a past one-off", async () => { - renderRow({ viewerId: COACH_ID, isPast: true }); - await openKebab(); - expect(item()).toHaveAttribute("aria-disabled", "true"); - expect(item()).toHaveAttribute("data-disabled"); - expect(screen.getByText("No upcoming sessions")).toBeVisible(); - }); - - it("is enabled as Resend series invite when the series has a session ahead", async () => { - renderRow({ - viewerId: COACH_ID, - isPast: true, - seriesId: "series-1", - series: seriesEnding("2026-12-01T15:00:00Z"), - }); - await openKebab(); - expect(item()).not.toHaveAttribute("aria-disabled"); - expect(item()).toHaveTextContent(/^Resend series invite$/); - }); - - it("is disabled with a hint when the series' last session has passed", async () => { - renderRow({ - viewerId: COACH_ID, - seriesId: "series-1", - series: seriesEnding("2026-10-01T15:00:00Z"), - }); + it.each([ + ["an upcoming one-off", { isPast: false }, "Resend invite", true], + ["a past one-off", { isPast: true }, "Resend invite", false], + ["a series with a session ahead", { isPast: true, seriesId: "series-1", series: seriesEnding("2026-12-01T15:00:00Z") }, "Resend series invite", true], + ["a finished series", { isPast: false, seriesId: "series-1", series: seriesEnding("2026-10-01T15:00:00Z") }, "Resend series invite", false], + ])("for a coach on %s", async (_, opts, label, enabled) => { + renderRow({ viewerId: COACH_ID, ...opts }); await openKebab(); - expect(item()).toHaveAttribute("aria-disabled", "true"); - expect(item()).toHaveTextContent("Resend series invite"); - expect(screen.getByText("No upcoming sessions")).toBeVisible(); + if (enabled) { + expect(item()).not.toHaveAttribute("aria-disabled"); + expect(item()).toHaveTextContent(new RegExp(`^${label}$`)); + } else { + expect(item()).toHaveAttribute("aria-disabled", "true"); + expect(item()).toHaveTextContent(`${label}No upcoming sessions`); + expect(screen.getByText("No upcoming sessions")).toBeVisible(); + } }); it("hands the session to onRequestResendInvite when clicked", async () => { diff --git a/__tests__/e2e/resend-calendar-invite.spec.ts b/__tests__/e2e/resend-calendar-invite.spec.ts index a74cca460..9d68dc48f 100644 --- a/__tests__/e2e/resend-calendar-invite.spec.ts +++ b/__tests__/e2e/resend-calendar-invite.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page, type Route } from '@playwright/test' +import { test, expect, type Page } from '@playwright/test' import { setupAuthentication, mockCommonApiRoutes, @@ -6,25 +6,10 @@ import { SINGLE_RELATIONSHIP, } from './helpers' -// Resending a calendar invite from the dashboard session menu, against a mocked backend. - test.use({ timezoneId: 'America/Chicago', locale: 'en-US' }) -const NOW = '2026-10-21T17:00:00Z' -const SERIES_ID = 'series-1' const RELATIONSHIP = { ...SINGLE_RELATIONSHIP[0], coach_id: MOCK_USER_ID } - -const person = (id: string, first: string, last: string) => ({ - id, - email: `${first.toLowerCase()}@example.com`, - first_name: first, - last_name: last, - display_name: `${first} ${last}`, - timezone: 'America/Chicago', - roles: [], - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z', -}) +const COACHEE = { id: RELATIONSHIP.coachee_id, first_name: 'Jane', last_name: 'Smith', display_name: 'Jane Smith' } const session = (id: string, date: string, series: { id: string; last_session_date: string } | null) => ({ id, @@ -36,44 +21,19 @@ const session = (id: string, date: string, series: { id: string; last_session_da series, created_at: '2026-10-01T12:00:00Z', updated_at: '2026-10-01T12:00:00Z', - relationship: { - id: RELATIONSHIP.id, - coach_id: MOCK_USER_ID, - coachee_id: RELATIONSHIP.coachee_id, - organization_id: 'org-1', - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z', - }, - coach: person(MOCK_USER_ID, 'Test', 'User'), - coachee: person(RELATIONSHIP.coachee_id, 'Jane', 'Smith'), + relationship: RELATIONSHIP, + coachee: COACHEE, }) const SERIES_SESSION = session('series-session', '2026-10-21T21:00:00', { - id: SERIES_ID, + id: 'series-1', last_session_date: '2026-12-30T21:00:00', }) const PAST_ONE_OFF = session('past-one-off', '2026-10-21T14:00:00', null) -const json = (data: unknown) => ({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ data }), -}) +const json = (data: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify({ data }) }) -async function mockDashboard(page: Page): Promise { - const resendPosts: string[] = [] - await mockCommonApiRoutes(page, { relationships: [RELATIONSHIP] }) - await page.route( - (url) => url.pathname === `/users/${MOCK_USER_ID}/coaching_sessions`, - (route) => route.fulfill(json([SERIES_SESSION, PAST_ONE_OFF])) - ) - await page.route('**/coaching_sessions/counts**', (route) => route.fulfill(json({ counts: [] }))) - await page.route('**/resend_invite', (route: Route) => { - resendPosts.push(`${route.request().method()} ${new URL(route.request().url()).pathname}`) - return route.fulfill({ status: 204 }) - }) - return resendPosts -} +let resendPosts: string[] async function openRowMenu(page: Page, sessionId: string) { const row = page.getByRole('region', { name: 'Today' }).getByTestId(`session-row-${sessionId}`) @@ -83,28 +43,35 @@ async function openRowMenu(page: Page, sessionId: string) { test.describe('Resend calendar invite', () => { test.beforeEach(async ({ page, context }) => { - await page.clock.setFixedTime(NOW) + resendPosts = [] + await page.clock.setFixedTime('2026-10-21T17:00:00Z') await setupAuthentication(page, context) + await mockCommonApiRoutes(page, { relationships: [RELATIONSHIP] }) + await page.route( + (url) => url.pathname === `/users/${MOCK_USER_ID}/coaching_sessions`, + (route) => route.fulfill(json([SERIES_SESSION, PAST_ONE_OFF])) + ) + await page.route('**/coaching_sessions/counts**', (route) => route.fulfill(json({ counts: [] }))) + await page.route('**/resend_invite', (route) => { + resendPosts.push(`${route.request().method()} ${new URL(route.request().url()).pathname}`) + return route.fulfill({ status: 204 }) + }) + await page.goto('/dashboard') }) test('a double-clicked confirm sends one series resend', async ({ page }) => { - const resendPosts = await mockDashboard(page) - await page.goto('/dashboard') - await openRowMenu(page, SERIES_SESSION.id) await page.getByRole('menuitem', { name: 'Resend series invite' }).click() - - const dialog = page.getByRole('alertdialog', { name: 'Resend series invite?' }) - await dialog.getByRole('button', { name: 'Resend invite' }).dblclick() + await page + .getByRole('alertdialog', { name: 'Resend series invite?' }) + .getByRole('button', { name: 'Resend invite' }) + .dblclick() await expect(page.getByText('Invite sent to you and Jane Smith')).toBeVisible() - expect(resendPosts).toEqual([`POST /coaching_session_series/${SERIES_ID}/resend_invite`]) + expect(resendPosts).toEqual(['POST /coaching_session_series/series-1/resend_invite']) }) test('the item is disabled on a past one-off', async ({ page }) => { - await mockDashboard(page) - await page.goto('/dashboard') - await page.getByRole('tab', { name: 'Previous' }).click() await openRowMenu(page, PAST_ONE_OFF.id) diff --git a/__tests__/lib/api/resend-invite.test.ts b/__tests__/lib/api/resend-invite.test.ts index 3e76336ed..fa5ad89f6 100644 --- a/__tests__/lib/api/resend-invite.test.ts +++ b/__tests__/lib/api/resend-invite.test.ts @@ -1,12 +1,9 @@ -// Resending calendar invites: exact paths, no body, and typed failures. - import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Result } from "neverthrow"; import { CoachingSessionSeriesApi } from "@/lib/api/coaching-session-series"; import { CoachingSessionApi } from "@/lib/api/coaching-sessions"; import { sessionGuard } from "@/lib/auth/session-guard"; import { Some } from "@/types/option"; -import { ResendInviteRefusal, type ResendInviteFailure } from "@/types/resend-invite"; +import { ResendInviteRefusal } from "@/types/resend-invite"; vi.mock("@/lib/auth/session-guard", () => ({ sessionGuard: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }, @@ -16,84 +13,36 @@ vi.mock("@/site.config", () => ({ siteConfig: { env: { backendServiceURL: "http://localhost:3000" } }, })); -const BASE = "http://localhost:3000"; - -const httpError = (status: number, data: unknown = {}) => - Object.assign(new Error(`Request failed with status code ${status}`), { - isAxiosError: true, - response: { status, statusText: "", data, headers: {}, config: {} }, - }); - -const conflict = (reason: string) => ({ - status_code: 409, - error: "conflict", - message: "refused", - details: { reason }, -}); +const axiosError = (response?: { status: number; data: unknown }) => + Object.assign(new Error("Request failed"), { isAxiosError: true, response }); -const failureOf = async ( - call: Promise> -): Promise => { - const result = await call; - if (result.isOk()) throw new Error("expected a failure"); - return result.error; -}; +const refusal = (reason: string) => + axiosError({ status: 409, data: { status_code: 409, details: { reason } } }); describe("resendInvite", () => { beforeEach(() => vi.clearAllMocks()); - it("POSTs to the series' resend_invite route with no body", async () => { - vi.mocked(sessionGuard.post).mockResolvedValue({ status: 204, data: "" } as never); - const result = await CoachingSessionSeriesApi.resendInvite("ser-1"); - expect(sessionGuard.post).toHaveBeenCalledWith( - `${BASE}/coaching_session_series/ser-1/resend_invite` - ); - expect(result.isOk()).toBe(true); - }); - - it("POSTs to the session's resend_invite route with no body", async () => { + it.each([ + ["series", () => CoachingSessionSeriesApi.resendInvite("ser-1"), "/coaching_session_series/ser-1/resend_invite"], + ["session", () => CoachingSessionApi.resendInvite("s-1"), "/coaching_sessions/s-1/resend_invite"], + ])("POSTs a %s resend to its route with no body", async (_, call, path) => { vi.mocked(sessionGuard.post).mockResolvedValue({ status: 204, data: "" } as never); - const result = await CoachingSessionApi.resendInvite("s-1"); - expect(sessionGuard.post).toHaveBeenCalledWith( - `${BASE}/coaching_sessions/s-1/resend_invite` - ); + const result = await call(); + expect(sessionGuard.post).toHaveBeenCalledWith(`http://localhost:3000${path}`); expect(result.isOk()).toBe(true); }); it.each([ - ["legacy_series", ResendInviteRefusal.LegacySeries], - ["no_upcoming_sessions", ResendInviteRefusal.NoUpcomingSessions], - ["series_member", ResendInviteRefusal.SeriesMember], - ])("maps 409 %s to refused", async (wire, reason) => { - vi.mocked(sessionGuard.post).mockRejectedValue(httpError(409, conflict(wire))); - expect(await failureOf(CoachingSessionSeriesApi.resendInvite("ser-1"))).toEqual({ - kind: "refused", - reason, - }); - }); - - it("maps a 409 with no details to unexpected", async () => { - vi.mocked(sessionGuard.post).mockRejectedValue( - httpError(409, { status_code: 409, error: "conflict", message: "refused" }) - ); - expect(await failureOf(CoachingSessionApi.resendInvite("s-1"))).toEqual({ - kind: "unexpected", - status: Some(409), - }); - }); - - it("maps 502 to not_delivered", async () => { - vi.mocked(sessionGuard.post).mockRejectedValue(httpError(502)); - expect(await failureOf(CoachingSessionApi.resendInvite("s-1"))).toEqual({ - kind: "not_delivered", - }); - }); - - it("maps 403 to unexpected", async () => { - vi.mocked(sessionGuard.post).mockRejectedValue(httpError(403)); - expect(await failureOf(CoachingSessionSeriesApi.resendInvite("ser-1"))).toEqual({ - kind: "unexpected", - status: Some(403), - }); + ["409 legacy_series", refusal("legacy_series"), { kind: "refused", reason: ResendInviteRefusal.LegacySeries }], + ["409 no_upcoming_sessions", refusal("no_upcoming_sessions"), { kind: "refused", reason: ResendInviteRefusal.NoUpcomingSessions }], + ["409 series_member", refusal("series_member"), { kind: "refused", reason: ResendInviteRefusal.SeriesMember }], + ["409 without a reason", axiosError({ status: 409, data: { status_code: 409 } }), { kind: "unexpected", status: Some(409) }], + ["502", axiosError({ status: 502, data: {} }), { kind: "not_delivered" }], + ["403", axiosError({ status: 403, data: {} }), { kind: "unexpected", status: Some(403) }], + ["no response", axiosError(), { kind: "network" }], + ])("maps %s", async (_, error, expected) => { + vi.mocked(sessionGuard.post).mockRejectedValue(error); + const result = await CoachingSessionApi.resendInvite("s-1"); + expect(result._unsafeUnwrapErr()).toEqual(expected); }); }); diff --git a/__tests__/types/coaching-session-display-title.test.ts b/__tests__/types/coaching-session-display-title.test.ts index 47179bdc1..0a0058fd8 100644 --- a/__tests__/types/coaching-session-display-title.test.ts +++ b/__tests__/types/coaching-session-display-title.test.ts @@ -5,7 +5,6 @@ // pins the display_title and series wire handling. import { describe, it, expect } from "vitest"; -import { DateTime } from "ts-luxon"; import { Some } from "@/types/option"; import { transformCoachingSession, @@ -76,33 +75,28 @@ describe("defaultCoachingSession — display_title", () => { }); describe("transformCoachingSession — series", () => { - it("wraps a present series as Some with a UTC last_session_date", () => { - const s = transformCoachingSession({ - ...rawWire, - series: { id: "ser-1", last_session_date: "2026-08-28T15:00:00" }, - }); - expect(s.series?.some).toBe(true); - if (!s.series?.some) return; - expect(s.series.val.id).toBe("ser-1"); - expect(s.series.val.last_session_date.zoneName).toBe("UTC"); - expect(s.series.val.last_session_date.toMillis()).toBe( - DateTime.fromISO("2026-08-28T15:00:00Z").toMillis() - ); - }); + const withSeries = { + ...rawWire, + series: { id: "ser-1", last_session_date: "2026-08-28T15:00:00" }, + }; - it("maps an absent series to None", () => { - expect(transformCoachingSession(rawWire).series?.none).toBe(true); + it("wraps a present series as Some with a UTC last_session_date", () => { + const series = transformCoachingSession(withSeries).series; + if (!series?.some) throw new Error("expected Some"); + expect(series.val.id).toBe("ser-1"); + expect(series.val.last_session_date.zoneName).toBe("UTC"); + expect(series.val.last_session_date.toISO()).toBe("2026-08-28T15:00:00.000Z"); }); - it("maps a null series to None", () => { - expect(transformCoachingSession({ ...rawWire, series: null }).series?.none).toBe(true); + it.each([ + ["absent", rawWire], + ["null", { ...rawWire, series: null }], + ])("maps an %s series to None", (_, raw) => { + expect(transformCoachingSession(raw).series?.none).toBe(true); }); it("never includes series on the wire", () => { - const fe = transformCoachingSession({ - ...rawWire, - series: { id: "ser-1", last_session_date: "2026-08-28T15:00:00" }, - }); - expect("series" in serializeCoachingSession(fe)).toBe(false); + const wire = serializeCoachingSession(transformCoachingSession(withSeries)); + expect("series" in wire).toBe(false); }); }); diff --git a/src/components/ui/dashboard/coaching-sessions-card.tsx b/src/components/ui/dashboard/coaching-sessions-card.tsx index 16aa29aba..374a48506 100644 --- a/src/components/ui/dashboard/coaching-sessions-card.tsx +++ b/src/components/ui/dashboard/coaching-sessions-card.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DateTime } from "ts-luxon"; import { toast as sonnerToast } from "sonner"; -import type { Result } from "neverthrow"; import { useSWRConfig } from "swr"; import { Card, CardContent } from "@/components/ui/card"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -14,16 +13,13 @@ import { import { BucketsContainer } from "@/components/ui/dashboard/session-buckets/buckets-container"; import { DeleteSessionDialog } from "@/components/ui/dashboard/delete-session-dialog"; import { ResendInviteDialog } from "@/components/ui/dashboard/resend-invite-dialog"; +import { useResendInvite } from "@/components/ui/dashboard/use-resend-invite"; import { SeriesActionDialogs } from "@/components/ui/dashboard/series-action-dialogs"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; import { useCoachingSessionsCardFilterStore } from "@/lib/providers/coaching-sessions-card-filter-store-provider"; import { useCurrentOrganization } from "@/lib/hooks/use-current-organization"; import { useCoachingRelationshipList } from "@/lib/api/coaching-relationships"; -import { - CoachingSessionApi, - useCoachingSessionMutation, -} from "@/lib/api/coaching-sessions"; -import { CoachingSessionSeriesApi } from "@/lib/api/coaching-session-series"; +import { useCoachingSessionMutation } from "@/lib/api/coaching-sessions"; import { USERS_BASEURL } from "@/lib/api/users"; import type { Id } from "@/types/general"; import { getBrowserTimezone } from "@/lib/timezone-utils"; @@ -32,10 +28,6 @@ import { type CoachingSession, type EnrichedCoachingSession, } from "@/types/coaching-session"; -import { - ResendInviteRefusal, - type ResendInviteFailure, -} from "@/types/resend-invite"; import { isUserCoachInRelationship, isUserCoacheeInRelationship, @@ -64,67 +56,6 @@ type DeleteDialogState = | { kind: "pending"; session: EnrichedCoachingSession } | { kind: "deleting"; session: EnrichedCoachingSession }; -type ResendDialogState = - | { kind: "closed" } - | { kind: "pending"; session: EnrichedCoachingSession } - | { kind: "sending"; session: EnrichedCoachingSession }; - -const RESEND_FAILED = "Couldn't resend the invite"; -const RESEND_RETRY = { description: "Please try again in a moment." }; - -function resendSessionInvite( - session: EnrichedCoachingSession, - participantName: string -) { - void CoachingSessionApi.resendInvite(session.id).then((result) => - announceResendResult(result, { session, participantName, isSeries: false }) - ); -} - -function announceResendResult( - result: Result, - ctx: { - session: EnrichedCoachingSession; - participantName: string; - isSeries: boolean; - } -): void { - if (result.isOk()) { - sonnerToast.success(`Invite sent to you and ${ctx.participantName}`); - return; - } - const failure = result.error; - if (failure.kind !== "refused") { - sonnerToast.error(RESEND_FAILED, RESEND_RETRY); - return; - } - switch (failure.reason) { - case ResendInviteRefusal.LegacySeries: - sonnerToast.error("This series can't be resent.", { - description: "You can resend this session instead.", - action: { - label: "Resend this session", - onClick: () => resendSessionInvite(ctx.session, ctx.participantName), - }, - }); - return; - case ResendInviteRefusal.NoUpcomingSessions: - sonnerToast.error( - ctx.isSeries - ? "This series has no upcoming sessions." - : "This session has already happened." - ); - return; - case ResendInviteRefusal.SeriesMember: - sonnerToast.error(RESEND_FAILED, RESEND_RETRY); - return; - default: { - const _exhaustive: never = failure.reason; - throw new Error(`Unhandled resend refusal: ${_exhaustive}`); - } - } -} - export interface CoachingSessionsCardProps { /** Opens the create/edit dialog with the given session pre-filled. */ onReschedule: (session: CoachingSession | EnrichedCoachingSession) => void; @@ -258,42 +189,7 @@ export function CoachingSessionsCard({ kind: "closed", }); - // ── Resend invite flow ─────────────────────────────────────────────── - const [resendState, setResendState] = useState({ - kind: "closed", - }); - - const resendParticipantName = useMemo(() => { - if (resendState.kind === "closed" || !userSession) return ""; - const info = getSessionParticipantInfo(resendState.session, userSession.id); - return info?.participantName ?? "Unknown"; - }, [resendState, userSession]); - - const handleRequestResendInvite = useCallback( - (session: EnrichedCoachingSession) => - setResendState({ kind: "pending", session }), - [] - ); - - const handleCancelResend = useCallback(() => { - setResendState({ kind: "closed" }); - }, []); - - const handleConfirmResend = useCallback(async () => { - if (resendState.kind !== "pending") return; - const { session } = resendState; - setResendState({ kind: "sending", session }); - const seriesId = session.coaching_session_series_id; - const result = seriesId.some - ? await CoachingSessionSeriesApi.resendInvite(seriesId.val) - : await CoachingSessionApi.resendInvite(session.id); - announceResendResult(result, { - session, - participantName: resendParticipantName, - isSeries: seriesId.some, - }); - setResendState({ kind: "closed" }); - }, [resendState, resendParticipantName]); + const resend = useResendInvite(); // ── Series actions ─────────────────────────────────────────────────── // A session row's kebab surfaces view / edit / delete for the series it @@ -391,7 +287,7 @@ export function CoachingSessionsCard({ mountNow={mountNow} onReschedule={onReschedule} onRequestDelete={handleRequestDelete} - onRequestResendInvite={handleRequestResendInvite} + onRequestResendInvite={resend.requestResend} onSeriesAction={handleSeriesAction} /> )} @@ -410,17 +306,7 @@ export function CoachingSessionsCard({ onCancel={handleCancelDelete} onConfirm={handleConfirmDelete} /> - + + announceResendResult(result, { session, participantName, isSeries: false }) + ); +} + +function announceResendResult( + result: Result, + ctx: ResendContext +): void { + if (result.isOk()) { + sonnerToast.success(`Invite sent to you and ${ctx.participantName}`); + return; + } + const failure = result.error; + const cause = failure.kind === "refused" ? failure.reason : failure.kind; + switch (cause) { + case ResendInviteRefusal.LegacySeries: + sonnerToast.error("This series can't be resent.", { + description: "You can resend this session instead.", + action: { + label: "Resend this session", + onClick: () => resendSessionInvite(ctx.session, ctx.participantName), + }, + }); + return; + case ResendInviteRefusal.NoUpcomingSessions: + sonnerToast.error( + ctx.isSeries + ? "This series has no upcoming sessions." + : "This session has already happened." + ); + return; + case ResendInviteRefusal.SeriesMember: + case "not_delivered": + case "network": + case "unexpected": + sonnerToast.error("Couldn't resend the invite", { + description: "Please try again in a moment.", + }); + return; + default: { + const _exhaustive: never = cause; + throw new Error(`Unhandled resend failure: ${_exhaustive}`); + } + } +} + +/** Drives the resend-invite confirmation: open, send once, announce, close. */ +export function useResendInvite() { + const viewerId = useAuthStore((s) => s.userSession?.id); + const [state, setState] = useState({ kind: "closed" }); + + const participantName = useMemo(() => { + if (state.kind === "closed" || !viewerId) return ""; + return getSessionParticipantInfo(state.session, viewerId)?.participantName ?? "Unknown"; + }, [state, viewerId]); + + const requestResend = useCallback( + (session: EnrichedCoachingSession) => setState({ kind: "pending", session }), + [] + ); + + const onCancel = useCallback(() => setState({ kind: "closed" }), []); + + const onConfirm = useCallback(async () => { + if (state.kind !== "pending") return; + const { session } = state; + setState({ kind: "sending", session }); + const seriesId = session.coaching_session_series_id; + const result = seriesId.some + ? await CoachingSessionSeriesApi.resendInvite(seriesId.val) + : await CoachingSessionApi.resendInvite(session.id); + announceResendResult(result, { session, participantName, isSeries: seriesId.some }); + setState({ kind: "closed" }); + }, [state, participantName]); + + const dialogProps: ResendInviteDialogProps = { + open: state.kind !== "closed", + isSeries: state.kind !== "closed" && state.session.coaching_session_series_id.some, + participantName, + isSending: state.kind === "sending", + onCancel, + onConfirm, + }; + + return { requestResend, dialogProps }; +} From 34f3a9c460d87d3b006f18f127ba0c00575633fe Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Tue, 20 Oct 2026 13:40:10 -0500 Subject: [PATCH 8/8] docs(sessions): say series is None whenever a read lacks it --- src/types/coaching-session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/coaching-session.ts b/src/types/coaching-session.ts index 164d95387..cc2604484 100644 --- a/src/types/coaching-session.ts +++ b/src/types/coaching-session.ts @@ -79,7 +79,7 @@ export interface EnrichedCoachingSession extends CoachingSession { /** Agreement (included when include=agreements) */ agreement?: Agreement; - /** None when the session is not in a series; undefined when not requested. */ + /** None when absent from the read (not requested, or not in a series); callers can't tell which. */ series?: Option; }