From 2fe08f2f0e29c7ed04222532dcb3f150104a2736 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 20:44:39 +0000 Subject: [PATCH] Clerk status: report a pending Clerk invitation the row does not store (#2183 follow-up) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UJjrZJZUPYn8UwEZvT6npZ --- api/src/api/members.ts | 33 ++++++++++++++++----- api/src/test/member-invite-conflict.test.ts | 29 ++++++++++++++++++ docs/changelog.md | 1 + 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/api/src/api/members.ts b/api/src/api/members.ts index 66c67855..df60341d 100644 --- a/api/src/api/members.ts +++ b/api/src/api/members.ts @@ -299,21 +299,40 @@ membersRouter.get('/:id/clerk-status', async (req, res, next) => { ); if (!rows[0]) return res.status(404).json({ error: 'Member not found' }); - const { clerk_user_id, invitation_id, invited_at, enrolled_at } = rows[0]; + let { invited_at } = rows[0]; + const { clerk_user_id, invitation_id, enrolled_at } = rows[0]; // #2183: an unlinked member whose email already has a Clerk account (the invite is // refused with "account already exists") read "None". Report the fact, read-only; // linking stays /me/link's job. A lookup failure reports nothing. + // #2183 follow-up: Clerk may also hold a pending invitation this row never stored + // (the invite was refused with "invitation or account already exists"). Report it + // independently of the account, so Clerk Invitation never reads "None" beside it. let accountExists = false; - if (!clerk_user_id && !invitation_id) { + let pendingInvitation = !!invitation_id; + if (!clerk_user_id) { try { const { rows: er } = await db.query( 'SELECT email FROM members WHERE id = ? AND gym_id = ?', [req.params.id, gymId]); const email = er[0]?.email; if (email) { - const found = resolveInviteConflict(email, [], listItems(await clerkClient.users.getUserList({ emailAddress: [email] }))); - accountExists = found.kind === 'account_exists'; + const users = listItems(await clerkClient.users.getUserList({ emailAddress: [email] })); + accountExists = resolveInviteConflict(email, [], users).kind === 'account_exists'; + if (!pendingInvitation) { + const found = resolveInviteConflict(email, await listPendingInvitations(email), []); + if (found.kind === 'pending_invitation') { + pendingInvitation = true; + await db.query( + `UPDATE members SET invitation_id = ?, invited_at = COALESCE(invited_at, UTC_TIMESTAMP()), + modified_at = UTC_TIMESTAMP(), modified_by = ? + WHERE id = ? AND gym_id = ? AND clerk_user_id IS NULL AND invitation_id IS NULL`, + [found.invitationId, 'System', req.params.id, gymId], + ); + const { rows: ir } = await db.query('SELECT invited_at FROM members WHERE id = ?', [req.params.id]); + invited_at = ir[0]?.invited_at ?? invited_at; + } + } } - } catch { /* leave false */ } + } catch { /* leave as stored */ } } // #1234: the dates are history, reported as stored and independent of the status. // #1295: `enrolled` is the stored link itself (what #1234 defines it as), reported in @@ -331,13 +350,13 @@ membersRouter.get('/:id/clerk-status', async (req, res, next) => { has_payment_token: !!tokenRows[0], invited_at: invited_at ?? null, enrolled_at: enrolled_at ?? null, - has_pending_invitation: !!invitation_id, + has_pending_invitation: pendingInvitation, enrolled: !!clerk_user_id, account_exists: accountExists, }; if (!clerk_user_id) { - return res.json({ status: invitation_id ? 'invited' : 'not_enrolled', userId: null, ...dates }); + return res.json({ status: pendingInvitation ? 'invited' : 'not_enrolled', userId: null, ...dates }); } try { diff --git a/api/src/test/member-invite-conflict.test.ts b/api/src/test/member-invite-conflict.test.ts index 607880eb..ddbe637c 100644 --- a/api/src/test/member-invite-conflict.test.ts +++ b/api/src/test/member-invite-conflict.test.ts @@ -102,3 +102,32 @@ describe('POST /members/:id/invite conflict reconciliation', () => { expect((await stored(m.id)).invitation_id).toBeNull(); }); }); + +describe('GET /members/:id/clerk-status with an invitation Clerk holds but the row does not', () => { + const status = (id: number) => + request.get(`/members/${id}/clerk-status`).set('Authorization', TEST_AUTH_HEADER).set('x-gym-id', gymId); + + it('reports the pending invitation (even beside an existing account) and stores it', async () => { + const m = await member(); + clerk.getInvitationList.mockReset(); + clerk.getInvitationList.mockResolvedValue({ data: [{ id: 'inv_status', emailAddress: m.email, status: 'pending' }] }); + clerk.getUserList.mockResolvedValue({ data: [{ id: 'user_x', emailAddresses: [{ emailAddress: m.email }] }] }); + const res = await status(m.id); + expect(res.status).toBe(200); + expect(res.body.has_pending_invitation).toBe(true); + expect(res.body.status).toBe('invited'); + expect(res.body.account_exists).toBe(true); + expect(res.body.invited_at).not.toBeNull(); + expect((await stored(m.id)).invitation_id).toBe('inv_status'); + }); + + it('keeps "none" when Clerk holds no invitation', async () => { + const m = await member(); + clerk.getInvitationList.mockReset(); + clerk.getInvitationList.mockResolvedValue({ data: [] }); + clerk.getUserList.mockResolvedValue({ data: [] }); + const res = await status(m.id); + expect(res.body.has_pending_invitation).toBe(false); + expect(res.body.status).toBe('not_enrolled'); + }); +}); diff --git a/docs/changelog.md b/docs/changelog.md index 0b92b254..85e5bf08 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,7 @@ What the product does today and what is still open is `docs/roadmap.md`; this fi ## November 2026 +- **Done**: #2183 follow-up (`GET /members/:id/clerk-status` also looks up the pending Clerk invitation when the row stores none, independently of an existing account, reports `has_pending_invitation`/`invited` and stores the invitation id as `System`, so Clerk Invitation no longer reads None while Clerk holds one; no migration) - **Done**: #2125 / #2126 M1 (migration 366: independent Product Sets assembled from immutable snapshots. Adds the junctions `product_set_plan_snapshots`, `product_set_{session,oneoff,periodical}_snapshots` and `billing_event_oneoff_snapshots` (UNIQUE pair, CASCADE to the owner, RESTRICT to the snapshot) and empties dev `billing_events`, refused in production without `ALLOW_CLEAR_BILLING_HISTORY=1`. Additive: nothing reads or writes the junctions yet; M2-M5 follow. Owner decisions: one active and one pending set per owner, `previous_product_set_id` kept for history, no `root_product_set_id`, version numbers or `is_inherited`) - **Done**: #2331 (Professional Services, Base and Gym-based: Name and Description show the default language only, with a globe icon per field opening the shared `FieldTranslationsModal` — editable with Save/Cancel in the editor, read-only in the expanded row; the modal lists the supported languages from `PS_LOCALES`, persistence is the unchanged `translations` payload, no migration) - **Done**: #2330 (a click anywhere on a Base Exercises row toggles it, via `DataTable`'s opt-in `clickRowToExpand` that ignores buttons, links, inputs and dialogs; the Gym Exercises row already did. An exercise thumbnail click never toggles the row, even when it has no full-size image to open. Closes #2330)