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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions api/src/api/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>(
'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<any>(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<any>('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
Expand All @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions api/src/test/member-invite-conflict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**: #1985 follow-up (Members App calendar lists events with no Activity Type): `GET /me/schedule` now LEFT JOINs `activity_types`, so a standalone calendar event is no longer dropped from the member calendar.

- **Done**: #2337 (Nutrition Library and Base Nutrition Library: a click anywhere on a food row toggles it via `DataTable`'s `clickRowToExpand`; the image still opens its modal without toggling and buttons, menus and links keep their behaviour.)
Expand Down
Loading