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..46e1c96b5 --- /dev/null +++ b/__tests__/components/ui/dashboard/coaching-sessions-card-resend.test.tsx @@ -0,0 +1,112 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } 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"), +}); + +// 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`, record(seriesResponse)), + http.post(`${BASE}/coaching_sessions/:id/resend_invite`, record(new HttpResponse(null, { status: 204 }))) + ); + 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, posts }; +} + +describe("CoachingSessionsCard — resend invite", () => { + it("POSTs once to the series route and announces success", async () => { + 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()); + }); + + it("shows a retry message on a 502", async () => { + 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 () => { + const { user, posts } = await confirmResend( + HttpResponse.json({ details: { reason: "legacy_series" } }, { status: 409 }) + ); + 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..e1dfbd5e0 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,53 @@ async function openKebabAfterRender() { }); return openKebab(); } + +describe("SessionRow — resend invite", () => { + const originalNow = Settings.now; + const seriesEnding = (iso: string) => + Some({ id: "series-1", last_session_date: DateTime.fromISO(iso, { zone: "utc" }) }); + + beforeEach(() => { + vi.clearAllMocks(); + Settings.now = () => Date.parse("2026-10-20T12:00:00Z"); + }); + + 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.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(); + 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 () => { + 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..9d68dc48f --- /dev/null +++ b/__tests__/e2e/resend-calendar-invite.spec.ts @@ -0,0 +1,82 @@ +import { test, expect, type Page } from '@playwright/test' +import { + setupAuthentication, + mockCommonApiRoutes, + MOCK_USER_ID, + SINGLE_RELATIONSHIP, +} from './helpers' + +test.use({ timezoneId: 'America/Chicago', locale: 'en-US' }) + +const RELATIONSHIP = { ...SINGLE_RELATIONSHIP[0], coach_id: MOCK_USER_ID } +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, + 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: RELATIONSHIP, + coachee: COACHEE, +}) + +const SERIES_SESSION = session('series-session', '2026-10-21T21:00:00', { + 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 }) }) + +let resendPosts: string[] + +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 }) => { + 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 }) => { + await openRowMenu(page, SERIES_SESSION.id) + await page.getByRole('menuitem', { name: 'Resend series invite' }).click() + 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-1/resend_invite']) + }) + + test('the item is disabled on a past one-off', async ({ page }) => { + 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/__tests__/lib/api/resend-invite.test.ts b/__tests__/lib/api/resend-invite.test.ts new file mode 100644 index 000000000..fa5ad89f6 --- /dev/null +++ b/__tests__/lib/api/resend-invite.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +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 } 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 axiosError = (response?: { status: number; data: unknown }) => + Object.assign(new Error("Request failed"), { isAxiosError: true, response }); + +const refusal = (reason: string) => + axiosError({ status: 409, data: { status_code: 409, details: { reason } } }); + +describe("resendInvite", () => { + beforeEach(() => vi.clearAllMocks()); + + 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 call(); + expect(sessionGuard.post).toHaveBeenCalledWith(`http://localhost:3000${path}`); + expect(result.isOk()).toBe(true); + }); + + it.each([ + ["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 0f7408814..0a0058fd8 100644 --- a/__tests__/types/coaching-session-display-title.test.ts +++ b/__tests__/types/coaching-session-display-title.test.ts @@ -2,7 +2,7 @@ // 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 { Some } from "@/types/option"; @@ -73,3 +73,30 @@ describe("defaultCoachingSession — display_title", () => { expect(defaultCoachingSession().display_title).toBeUndefined(); }); }); + +describe("transformCoachingSession — series", () => { + const withSeries = { + ...rawWire, + series: { id: "ser-1", last_session_date: "2026-08-28T15:00:00" }, + }; + + 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.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 wire = serializeCoachingSession(transformCoachingSession(withSeries)); + expect("series" in wire).toBe(false); + }); +}); 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..8aa8ecba8 --- /dev/null +++ b/docs/test-plans/resend_calendar_invite_manual_testing.md @@ -0,0 +1,94 @@ +# 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] +> 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 + +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 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. 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. + +## 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 ". +- 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: ✉×2 one-off resend + +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. + +## 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: email unavailable + +Run this before Part B, while `RESEND_API_KEY` is still unset. Resend the active series. + +**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 + +Delete the test series, sessions and fixtures through SQL. Don't use the API delete routes, which send cancellation emails. diff --git a/src/components/ui/dashboard/coaching-sessions-card.tsx b/src/components/ui/dashboard/coaching-sessions-card.tsx index 5b6a05baa..374a48506 100644 --- a/src/components/ui/dashboard/coaching-sessions-card.tsx +++ b/src/components/ui/dashboard/coaching-sessions-card.tsx @@ -12,6 +12,8 @@ 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 { 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"; @@ -187,6 +189,8 @@ export function CoachingSessionsCard({ kind: "closed", }); + const resend = useResendInvite(); + // ── 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 +287,7 @@ export function CoachingSessionsCard({ mountNow={mountNow} onReschedule={onReschedule} onRequestDelete={handleRequestDelete} + onRequestResendInvite={resend.requestResend} onSeriesAction={handleSeriesAction} /> )} @@ -301,6 +306,7 @@ 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 e5846bee3..7460668e2 100644 --- a/src/components/ui/dashboard/session-buckets/bucket-accordion.tsx +++ b/src/components/ui/dashboard/session-buckets/bucket-accordion.tsx @@ -49,12 +49,14 @@ 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; } const SESSION_INCLUDES: CoachingSessionInclude[] = [ CoachingSessionInclude.Relationship, CoachingSessionInclude.Goal, + CoachingSessionInclude.Series, ]; // The overlap bucket fetches its full calendar window even when its @@ -87,6 +89,7 @@ export function BucketAccordion({ onSelect, onReschedule, onRequestDelete, + onRequestResendInvite, onSeriesAction, }: BucketAccordionProps) { const [hasEverExpanded, setHasEverExpanded] = useState(isExpanded); @@ -174,6 +177,7 @@ export function BucketAccordion({ onSelect={onSelect} onReschedule={onReschedule} onRequestDelete={onRequestDelete} + onRequestResendInvite={onRequestResendInvite} onSeriesAction={onSeriesAction} /> @@ -191,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; } @@ -204,6 +209,7 @@ function BucketBody({ onSelect, onReschedule, onRequestDelete, + onRequestResendInvite, onSeriesAction, }: BucketBodyProps) { if (isLoading && sessions.length === 0) { @@ -237,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; } const SESSION_INCLUDES: CoachingSessionInclude[] = [ CoachingSessionInclude.Relationship, CoachingSessionInclude.Goal, + CoachingSessionInclude.Series, ]; export function ThisWeekAccordion({ @@ -58,6 +60,7 @@ export function ThisWeekAccordion({ onSelect, onReschedule, onRequestDelete, + onRequestResendInvite, onSeriesAction, }: ThisWeekAccordionProps) { const isPastView = view === CoachingSessionBucketView.Previous; @@ -145,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 30797f372..487fb3c12 100644 --- a/src/components/ui/dashboard/session-buckets/today-section.tsx +++ b/src/components/ui/dashboard/session-buckets/today-section.tsx @@ -33,12 +33,14 @@ 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; } const SESSION_INCLUDES: CoachingSessionInclude[] = [ CoachingSessionInclude.Relationship, CoachingSessionInclude.Goal, + CoachingSessionInclude.Series, ]; export function TodaySection({ @@ -54,6 +56,7 @@ export function TodaySection({ onSelect, onReschedule, onRequestDelete, + onRequestResendInvite, onSeriesAction, }: TodaySectionProps) { const isPastView = view === CoachingSessionBucketView.Previous; @@ -114,6 +117,7 @@ export function TodaySection({ onSelect={() => onSelect(session)} onReschedule={onReschedule} onRequestDelete={onRequestDelete} + onRequestResendInvite={onRequestResendInvite} onSeriesAction={onSeriesAction} /> ))} diff --git a/src/components/ui/dashboard/use-resend-invite.ts b/src/components/ui/dashboard/use-resend-invite.ts new file mode 100644 index 000000000..f4da60385 --- /dev/null +++ b/src/components/ui/dashboard/use-resend-invite.ts @@ -0,0 +1,115 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { toast as sonnerToast } from "sonner"; +import type { Result } from "neverthrow"; +import type { ResendInviteDialogProps } from "@/components/ui/dashboard/resend-invite-dialog"; +import { useAuthStore } from "@/lib/providers/auth-store-provider"; +import { CoachingSessionApi } from "@/lib/api/coaching-sessions"; +import { CoachingSessionSeriesApi } from "@/lib/api/coaching-session-series"; +import { getSessionParticipantInfo } from "@/lib/utils/session"; +import type { EnrichedCoachingSession } from "@/types/coaching-session"; +import { + ResendInviteRefusal, + type ResendInviteFailure, +} from "@/types/resend-invite"; + +type ResendDialogState = + | { kind: "closed" } + | { kind: "pending"; session: EnrichedCoachingSession } + | { kind: "sending"; session: EnrichedCoachingSession }; + +interface ResendContext { + session: EnrichedCoachingSession; + participantName: string; + isSeries: boolean; +} + +function resendSessionInvite(session: EnrichedCoachingSession, participantName: string) { + void CoachingSessionApi.resendInvite(session.id).then((result) => + 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 }; +} 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..cc2604484 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 absent from the read (not requested, or not in a series); callers can't tell which. */ + 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)); + } +}