Skip to content

Commit a7fb845

Browse files
Herkloscursoragent
andcommitted
Fix join onboarding bounce after a successful invite accept.
Profile onboarded was written outside the store cache, so the app gate sent users back to the join form with a consumed invite; also revive soft-deleted members on accept. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4863b82 commit a7fb845

4 files changed

Lines changed: 128 additions & 19 deletions

File tree

apps/mobile/src/app/(onboarding)/role.tsx

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { useAppAuth } from '@/lib/supabase-stores';
66
import { useProfileMutations } from '@/lib/hooks/use-profile';
77
import { useCompanyMutations } from '@/lib/hooks/use-companies';
88
import { useInviteMutations } from '@/lib/hooks/use-invites';
9-
import { classifyInviteError, tokenFromInput } from '@chrono/sdk';
9+
import { classifyInviteError, fetchMyCompanies, tokenFromInput } from '@chrono/sdk';
1010
import type { InviteErrorKind } from '@chrono/sdk';
11+
import { globalSupabaseClient } from '@/lib/supabase';
1112
import { useActiveCompany } from '@/lib/active-company-context';
1213
import { AuthCard } from '@/components/common/AuthCard';
1314
import { useT } from '@/lib/i18n';
@@ -49,7 +50,7 @@ export default function RoleSetup() {
4950
const { completeOnboarding } = useProfileMutations();
5051
const { create } = useCompanyMutations();
5152
const { accept } = useInviteMutations();
52-
const { refresh } = useActiveCompany();
53+
const { refresh, setCompanyId } = useActiveCompany();
5354

5455
const [mode, setMode] = useState<Mode>('create');
5556
const [fullName, setFullName] = useState('');
@@ -58,6 +59,12 @@ export default function RoleSetup() {
5859
const [busy, setBusy] = useState(false);
5960
const [error, setError] = useState<string | undefined>();
6061

62+
const finish = async (activeCompanyId?: string) => {
63+
await refresh();
64+
if (activeCompanyId) setCompanyId(activeCompanyId);
65+
router.replace('/(app)/(tabs)/home');
66+
};
67+
6168
const submit = async () => {
6269
if (!user?.id) return;
6370
if (!fullName.trim()) {
@@ -79,20 +86,33 @@ export default function RoleSetup() {
7986
if (mode === 'join') {
8087
// Join only by redeeming an invite token (accept_company_invite validates
8188
// the token server-side). Self-joining an arbitrary company is not allowed.
89+
let companyId: string | undefined;
8290
try {
83-
await accept(token);
91+
companyId = await accept(token);
8492
} catch (e) {
85-
setError(inviteJoinError(classifyInviteError(e), t));
86-
setBusy(false);
87-
return;
93+
// Invite already consumed (e.g. PendingInviteRedeemer or a prior
94+
// accept that bounced on the onboarded gate): if this user is already
95+
// a member, finish onboarding instead of blocking on "used".
96+
if (classifyInviteError(e) === 'used') {
97+
const memberships = await fetchMyCompanies(globalSupabaseClient, user.id);
98+
companyId = memberships[0]?.id;
99+
}
100+
if (!companyId) {
101+
setError(inviteJoinError(classifyInviteError(e), t));
102+
setBusy(false);
103+
return;
104+
}
88105
}
89106
await completeOnboarding(user.id, fullName.trim());
107+
await finish(companyId);
90108
} else {
91109
await completeOnboarding(user.id, fullName.trim());
92-
await create({ content: { name: companyName.trim() }, created_by: user.id });
110+
await create({
111+
content: { name: companyName.trim() },
112+
created_by: user.id,
113+
});
114+
await finish();
93115
}
94-
await refresh();
95-
router.replace('/(app)/(tabs)/home');
96116
} catch (e) {
97117
setError(e instanceof Error ? e.message : t('onboarding.role.errGeneric'));
98118
setBusy(false);

apps/mobile/src/lib/hooks/use-profile.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ import { useMutation } from '@drakkar.software/anchor/hooks';
33
import { linkedQuery } from './linked-query';
44
import { stores, useAppAuth } from '@/lib/supabase-stores';
55
import { globalSupabaseClient } from '@/lib/supabase';
6-
import { completeOnboarding as sdkCompleteOnboarding, fetchProfile, fetchProfileBilling } from '@chrono/sdk';
6+
import { fetchProfile, fetchProfileBilling } from '@chrono/sdk';
77
import type { Profile, ProfileBilling, TablesUpdate } from '@chrono/sdk';
8-
import { useAsyncAction } from './use-async-action';
98

109
/** The signed-in user's profile, or another user's when `userId` is passed. */
1110
export function useProfile(userId?: string) {
@@ -63,14 +62,19 @@ export function useProfileMutations() {
6362
[update],
6463
);
6564

66-
const complete = useAsyncAction((userId: string, fullName: string) =>
67-
sdkCompleteOnboarding(globalSupabaseClient, userId, fullName),
65+
// Go through the profiles store (not a bare SDK update) so linked queries —
66+
// especially the app layout's onboarded gate — see the new value immediately.
67+
// A cache miss here used to bounce successful joins straight back to onboarding.
68+
const completeOnboarding = useCallback(
69+
(userId: string, fullName: string) =>
70+
update(userId, { full_name: fullName, onboarded: true }),
71+
[update],
6872
);
6973

7074
return {
7175
updateProfile,
72-
completeOnboarding: complete.mutateAsync,
73-
isPending: isLoading || complete.isPending,
74-
error: error ?? complete.error,
76+
completeOnboarding,
77+
isPending: isLoading,
78+
error,
7579
};
7680
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
-- Invite accept: revive soft-deleted memberships; seat check ignores an
2+
-- already-active seat holder (ON CONFLICT re-insert / idempotent re-accept).
3+
4+
create or replace function public.internal_insert_member_from_invite(
5+
p_company_id uuid,
6+
p_user_id uuid,
7+
p_role public.app_role
8+
)
9+
returns void
10+
language plpgsql
11+
security definer
12+
set search_path = ''
13+
as $$
14+
begin
15+
perform set_config('chrono.accepting_invite', 'on', true);
16+
begin
17+
insert into public.company_members (company_id, user_id, role)
18+
values (p_company_id, p_user_id, p_role)
19+
on conflict (company_id, user_id) do update
20+
set
21+
-- Soft-deleted members must become active again on re-invite.
22+
deleted = false,
23+
-- Keep an active member's role; apply invite role only when undeleting.
24+
role = case
25+
when company_members.deleted then excluded.role
26+
else company_members.role
27+
end,
28+
updated_at = now();
29+
exception
30+
when others then
31+
perform set_config('chrono.accepting_invite', 'off', true);
32+
raise;
33+
end;
34+
perform set_config('chrono.accepting_invite', 'off', true);
35+
end;
36+
$$;
37+
38+
revoke all on function public.internal_insert_member_from_invite(uuid, uuid, public.app_role) from public;
39+
revoke all on function public.internal_insert_member_from_invite(uuid, uuid, public.app_role) from anon, authenticated;
40+
41+
create or replace function public.enforce_seat_limit()
42+
returns trigger
43+
language plpgsql
44+
security definer
45+
set search_path = ''
46+
as $$
47+
declare
48+
v_limit integer;
49+
v_count integer;
50+
begin
51+
if new.deleted then
52+
return new;
53+
end if;
54+
55+
-- Already holding an active seat (idempotent accept / ON CONFLICT): not a new seat.
56+
if exists (
57+
select 1
58+
from public.company_members cm
59+
where cm.company_id = new.company_id
60+
and cm.user_id = new.user_id
61+
and cm.deleted = false
62+
) then
63+
return new;
64+
end if;
65+
66+
select seat_limit into v_limit
67+
from public.company_subscriptions
68+
where company_id = new.company_id;
69+
70+
if v_limit is null then
71+
return new;
72+
end if;
73+
74+
select count(*) into v_count
75+
from public.company_members
76+
where company_id = new.company_id and deleted = false;
77+
78+
if v_count >= v_limit then
79+
raise exception 'Company has reached its seat limit (%) for the current plan', v_limit;
80+
end if;
81+
82+
return new;
83+
end;
84+
$$;

backend/supabase/tests/database/040-company_invites_edge.test.sql

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ select ok(
5757
'Invite still marked accepted on conflict'
5858
);
5959

60-
-- 50 soft-deleted member redeeming again — unique conflict keeps deleted row
60+
-- 50 soft-deleted member redeeming again — undeletes and applies invite role
6161
select tests.clear_auth();
6262
update public.company_members
6363
set deleted = true
@@ -78,11 +78,12 @@ select lives_ok(
7878
'Soft-deleted member redeem does not error'
7979
);
8080
select tests.clear_auth();
81-
select ok(
81+
select is(
8282
(select deleted from public.company_members
8383
where company_id = (select company_id from edge_ctx)
8484
and user_id = 'dddddddd-dddd-dddd-dddd-ddddddddddd2'),
85-
'Soft-deleted member stays deleted on conflict (documented)'
85+
false,
86+
'Soft-deleted member is revived on accept'
8687
);
8788

8889
-- 51 seat limit: accept fails and invite remains unaccepted

0 commit comments

Comments
 (0)