Skip to content

Commit 3422fab

Browse files
Herklosclaude
andcommitted
Raise error slugs from the database and translate them client-side.
Postgres functions no longer raise user-facing sentences: every `raise exception` now carries a stable slug, with values that used to be interpolated into the message appended as `:value` segments (`capacity-exceeded:7`). The app is translated, so English raised from the DB could never be localized. 20260813000000_error_message_slugs.sql redefines the 20 functions that raised prose. Each body is its previous definition with only the raised literals changed, so behaviour is untouched and `create or replace` keeps the existing grants. Client side: `parseDbError` splits slug and params, `dbErrorMessage` resolves the `db.*` catalog key (en + fr) and ErrorState / InlineError / the rem screen / onboarding go through it. An unknown slug falls back to the generic copy, so a raw slug is never shown. The pending-invite redeemer classifies the seat-limit failure through `classifyInviteError` instead of matching English. Also fixes the join bounce race: the app gate reads the profile from the shared linked-query cache, which only moves on its own round trip, so navigating right after `completeOnboarding` let the gate see `onboarded: false` and redirect back to onboarding with the invite already consumed. Onboarding now refreshes that query before it navigates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a7fb845 commit 3422fab

20 files changed

Lines changed: 2219 additions & 36 deletions

apps/mobile/src/app/(app)/reports/rem.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import {
1515
useRemMutations,
1616
} from '@/lib/hooks/use-rem';
1717
import { RemBreakdown } from '@/components/reports/RemBreakdown';
18-
import { InlineError } from '@/components/common/ErrorState';
18+
import { InlineError, describeError } from '@/components/common/ErrorState';
19+
import { dbErrorMessage } from '@/lib/db-error';
1920
import { ScreenLoader } from '@/components/common/ScreenLoader';
2021

2122
export default function RemMonthScreen() {
@@ -54,7 +55,7 @@ export default function RemMonthScreen() {
5455
await refetchMonth();
5556
await refetchLines();
5657
} catch (e) {
57-
setError(e instanceof Error ? e.message : t('rem.computeFail'));
58+
setError(dbErrorMessage(e, t) ?? describeError(e, { fallback: t('rem.computeFail') }));
5859
} finally {
5960
setBusy(false);
6061
}
@@ -68,7 +69,7 @@ export default function RemMonthScreen() {
6869
await lock(companyId, month);
6970
await refetchMonth();
7071
} catch (e) {
71-
setError(e instanceof Error ? e.message : t('rem.lockFail'));
72+
setError(dbErrorMessage(e, t) ?? describeError(e, { fallback: t('rem.lockFail') }));
7273
} finally {
7374
setBusy(false);
7475
}

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ import { useRouter } from 'expo-router';
33
import { Button, Segmented, TextField, Txt } from '@chrono/ui';
44

55
import { useAppAuth } from '@/lib/supabase-stores';
6-
import { useProfileMutations } from '@/lib/hooks/use-profile';
6+
import { useProfile, useProfileMutations } from '@/lib/hooks/use-profile';
77
import { useCompanyMutations } from '@/lib/hooks/use-companies';
88
import { useInviteMutations } from '@/lib/hooks/use-invites';
99
import { classifyInviteError, fetchMyCompanies, tokenFromInput } from '@chrono/sdk';
1010
import type { InviteErrorKind } from '@chrono/sdk';
1111
import { globalSupabaseClient } from '@/lib/supabase';
1212
import { useActiveCompany } from '@/lib/active-company-context';
1313
import { AuthCard } from '@/components/common/AuthCard';
14+
import { describeError } from '@/components/common/ErrorState';
15+
import { dbErrorMessage } from '@/lib/db-error';
1416
import { useT } from '@/lib/i18n';
1517

1618
function inviteJoinError(kind: InviteErrorKind | null, t: ReturnType<typeof useT>): string {
@@ -48,6 +50,7 @@ export default function RoleSetup() {
4850
const router = useRouter();
4951
const { user } = useAppAuth();
5052
const { completeOnboarding } = useProfileMutations();
53+
const { refetch: refetchProfile } = useProfile();
5154
const { create } = useCompanyMutations();
5255
const { accept } = useInviteMutations();
5356
const { refresh, setCompanyId } = useActiveCompany();
@@ -60,6 +63,14 @@ export default function RoleSetup() {
6063
const [error, setError] = useState<string | undefined>();
6164

6265
const finish = async (activeCompanyId?: string) => {
66+
// The app gate (app/(app)/_layout) reads the profile from the shared
67+
// linked-query cache when it mounts, and that cache only moves on its own
68+
// round trip — a store write is not enough. Navigating straight after
69+
// `completeOnboarding` made the gate read the pre-write `onboarded: false`
70+
// and redirect back here with the invite already consumed. Refresh the
71+
// query (which writes the `profile:<id>` cache entry the gate will read)
72+
// before leaving the screen.
73+
await refetchProfile();
6374
await refresh();
6475
if (activeCompanyId) setCompanyId(activeCompanyId);
6576
router.replace('/(app)/(tabs)/home');
@@ -114,7 +125,7 @@ export default function RoleSetup() {
114125
await finish();
115126
}
116127
} catch (e) {
117-
setError(e instanceof Error ? e.message : t('onboarding.role.errGeneric'));
128+
setError(dbErrorMessage(e, t) ?? describeError(e, { fallback: t('onboarding.role.errGeneric') }));
118129
setBusy(false);
119130
}
120131
};

apps/mobile/src/app/_layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { I18nProvider } from '@/lib/i18n';
1313
import { useAppAuth } from '@/lib/supabase-stores';
1414
import { usePushRegistration } from '@/lib/hooks/use-push';
1515
import { useInviteMutations } from '@/lib/hooks/use-invites';
16+
import { classifyInviteError } from '@chrono/sdk';
1617
import { clearPendingInvite, getPendingInvite } from '@/lib/pending-invite';
1718
import { companyAppUserId } from '@/lib/revenuecat-constants';
1819
import { configureRevenueCat, subscribeCustomerInfo } from '@/lib/revenuecat';
@@ -121,8 +122,7 @@ function PendingInviteRedeemer() {
121122
// on next launch instead of discarding an otherwise-valid invite.
122123
// Any other failure (used/expired/invalid) is not recoverable, so
123124
// clear it — a bad token must not retry every launch.
124-
const message = e instanceof Error ? e.message : '';
125-
if (!message.includes('seat limit')) {
125+
if (classifyInviteError(e) !== 'seat_limit') {
126126
await clearPendingInvite();
127127
}
128128
}

apps/mobile/src/components/common/ErrorState.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { StyleSheet, View } from 'react-native';
22
import { Button, EmptyState, Txt, spacing } from '@chrono/ui';
33

44
import { useT } from '@/lib/i18n';
5+
import { dbErrorMessage } from '@/lib/db-error';
56

67
function extractMessage(error: unknown): string {
78
if (!error) return '';
@@ -83,7 +84,7 @@ export function ErrorState({
8384
<EmptyState
8485
icon="alert-circle-outline"
8586
title={title ?? t('compb.error.title')}
86-
subtitle={message ?? describeError(error, describe)}
87+
subtitle={message ?? dbErrorMessage(error, t) ?? describeError(error, describe)}
8788
action={onRetry ? <Button title={t('common.retry')} variant="secondary" onPress={onRetry} /> : undefined}
8889
tone="danger"
8990
/>
@@ -104,14 +105,15 @@ export interface InlineErrorProps {
104105
* validation string, or an `error` to have it described.
105106
*/
106107
export function InlineError({ error, message, describe, center = false }: InlineErrorProps) {
108+
const t = useT();
107109
// A non-empty `message` wins; otherwise describe an `error` if one was given.
108110
// An absent/empty `message` with no `error` renders nothing — it must NOT fall
109111
// through to describeError(undefined), which would show a generic fallback on
110112
// a pristine form that simply passed `message={maybeUndefinedString}`.
111113
const text = message != null && message !== ''
112114
? message
113115
: error != null
114-
? describeError(error, describe)
116+
? dbErrorMessage(error, t) ?? describeError(error, describe)
115117
: '';
116118
if (!text) return null;
117119
return (

apps/mobile/src/lib/db-error.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { parseDbError } from '@chrono/sdk';
2+
import type { TFn } from '@/lib/i18n';
3+
4+
/**
5+
* Translate an error raised by a DB function. Those raise stable slugs, never
6+
* copy (see the error-slug migration), so the sentence lives in the `db.*`
7+
* catalog and any values the DB appended arrive as `{0}`, `{1}`, …
8+
*
9+
* Returns null when the error is not a known slug — a PostgREST/network
10+
* failure, or a slug this app version has no copy for — so callers fall back to
11+
* their own generic message rather than showing a raw slug.
12+
*/
13+
export function dbErrorMessage(error: unknown, t: TFn): string | null {
14+
const parsed = parseDbError(error);
15+
if (!parsed) return null;
16+
const key = `db.${parsed.slug}`;
17+
const params = Object.fromEntries(parsed.params.map((value, i) => [String(i), value]));
18+
const message = t(key, params);
19+
return message === key ? null : message;
20+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { CatalogSlice } from '../types';
2+
3+
/**
4+
* Messages for the slugs raised by DB functions (`raise exception 'slug'`, see
5+
* the error-slug migration). A slug the catalog does not know falls back to the
6+
* generic error copy, so a new DB error never shows its raw slug to a user.
7+
* Values the DB appends to a slug arrive as `{0}`, `{1}`, … in order.
8+
*/
9+
export const dbErrorsCatalog: CatalogSlice = {
10+
en: {
11+
// Roles and members
12+
'db.role-admin-grant-forbidden': 'Only an admin can grant or change the admin role.',
13+
'db.role-change-forbidden': 'Only an admin can change your role.',
14+
'db.seat-limit-reached':
15+
'This company has reached its plan seat limit ({0}). Ask an admin to upgrade.',
16+
17+
// Invites
18+
'db.invite-identity-immutable': "An invite's email, role and company cannot be changed.",
19+
'db.invite-role-forbidden': 'Only an admin can invite a manager or an admin.',
20+
21+
// Time entries
22+
'db.time-entry-invoiced-locked': 'This time entry is already invoiced and can no longer be edited.',
23+
'db.capacity-exceeded': 'Monthly capacity exceeded (22 × {0}h).',
24+
'db.vacation-allowance-exceeded': 'Vacation allowance exceeded ({0} days per year).',
25+
'db.time-net-negative-delete': 'Deleting this entry would leave negative net time for this project month.',
26+
'db.time-net-negative-remove': 'Removing this entry would leave negative net time for this project month.',
27+
'db.time-net-negative-correction': 'This correction would leave negative net time for this project month.',
28+
29+
// Invoices
30+
'db.invoice-company-mismatch': 'This invoice does not belong to its project company.',
31+
'db.invoice-foreign-user': 'You cannot write an invoice for another user.',
32+
'db.invoice-invalid-project': 'That project is not valid for this invoice.',
33+
'db.invoice-freelancer-status-forbidden': 'Freelancers can only draft or submit an invoice.',
34+
'db.invoice-settlement-status-reserved': 'Settlement statuses are set by the monthly settlement, not by hand.',
35+
'db.invoice-settled-immutable': 'A settled invoice can only be cancelled, not reverted.',
36+
37+
// Projects, revenue and costs
38+
'db.project-not-found': 'Project not found.',
39+
'db.company-not-found': 'Company not found.',
40+
'db.revenue-source-not-found': 'Revenue source not found.',
41+
'db.revenue-recognize-forbidden': 'Only a manager can recognize revenue.',
42+
'db.revenue-correct-forbidden': 'Only a manager can correct revenue.',
43+
'db.revenue-paid-forbidden': 'Only a manager can mark revenue as paid.',
44+
'db.revenue-entries-not-found': 'No matching revenue entries.',
45+
'db.revenue-entries-multi-company': 'Revenue entries must all belong to the same company.',
46+
'db.revenue-net-negative-delete': 'Deleting this entry would leave negative net revenue for this project month.',
47+
'db.revenue-net-negative-remove': 'Removing this entry would leave negative net revenue for this project month.',
48+
'db.revenue-net-negative-correction': 'This correction would leave negative net revenue for this project month.',
49+
'db.project-costs-not-found': 'No matching project costs.',
50+
'db.project-costs-multi-company': 'Project costs must all belong to the same company.',
51+
'db.project-costs-paid-forbidden': 'Only a manager can mark project costs as paid.',
52+
'db.project-cost-reimbursable-not-payable':
53+
'A reimbursable cost is settled by reimbursing it, not by marking it paid.',
54+
'db.referral-total-exceeded': 'Referral shares cannot exceed 100% for a project (this would reach {0}%).',
55+
'db.settle-forbidden': 'Only a manager can settle a project month.',
56+
57+
// Remuneration
58+
'db.rem-compute-forbidden': 'Only a manager can compute the remuneration month.',
59+
'db.rem-lock-forbidden': 'Only a manager can lock the remuneration month.',
60+
'db.rem-month-locked': 'This remuneration month is locked.',
61+
'db.rem-month-not-computed': 'This remuneration month has not been computed yet.',
62+
'db.rem-license-recipients-invalid':
63+
'License distribution needs exactly two license recipients ({0} found).',
64+
65+
// Company settings
66+
'db.working-weekdays-empty': 'Pick at least one working day.',
67+
'db.working-weekdays-out-of-range': 'Working days must be between Monday and Sunday.',
68+
'db.working-weekdays-duplicate': 'A working day cannot be listed twice.',
69+
'db.product-pool-project-invalid': 'Pick an active product pool project from this company.',
70+
},
71+
fr: {
72+
// Rôles et membres
73+
'db.role-admin-grant-forbidden': 'Seul un admin peut attribuer ou modifier le rôle admin.',
74+
'db.role-change-forbidden': 'Seul un admin peut modifier votre rôle.',
75+
'db.seat-limit-reached':
76+
'Cette entreprise a atteint la limite de sièges de son forfait ({0}). Demandez à un admin de le faire évoluer.',
77+
78+
// Invitations
79+
'db.invite-identity-immutable':
80+
"L'e-mail, le rôle et l'entreprise d'une invitation ne peuvent pas être modifiés.",
81+
'db.invite-role-forbidden': 'Seul un admin peut inviter un manager ou un admin.',
82+
83+
// Saisies de temps
84+
'db.time-entry-invoiced-locked': 'Cette saisie est déjà facturée et ne peut plus être modifiée.',
85+
'db.capacity-exceeded': 'Capacité mensuelle dépassée (22 × {0} h).',
86+
'db.vacation-allowance-exceeded': 'Solde de congés dépassé ({0} jours par an).',
87+
'db.time-net-negative-delete':
88+
'Supprimer cette saisie rendrait le temps net négatif pour ce mois de projet.',
89+
'db.time-net-negative-remove':
90+
'Retirer cette saisie rendrait le temps net négatif pour ce mois de projet.',
91+
'db.time-net-negative-correction':
92+
'Cette correction rendrait le temps net négatif pour ce mois de projet.',
93+
94+
// Factures
95+
'db.invoice-company-mismatch': "Cette facture n'appartient pas à l'entreprise de son projet.",
96+
'db.invoice-foreign-user': 'Vous ne pouvez pas créer de facture pour un autre utilisateur.',
97+
'db.invoice-invalid-project': "Ce projet n'est pas valide pour cette facture.",
98+
'db.invoice-freelancer-status-forbidden':
99+
'Un freelance peut seulement mettre une facture en brouillon ou la soumettre.',
100+
'db.invoice-settlement-status-reserved':
101+
'Les statuts de règlement sont définis par le règlement mensuel, pas manuellement.',
102+
'db.invoice-settled-immutable': 'Une facture réglée peut seulement être annulée, pas rétablie.',
103+
104+
// Projets, revenus et coûts
105+
'db.project-not-found': 'Projet introuvable.',
106+
'db.company-not-found': 'Entreprise introuvable.',
107+
'db.revenue-source-not-found': 'Source de revenu introuvable.',
108+
'db.revenue-recognize-forbidden': 'Seul un manager peut constater du revenu.',
109+
'db.revenue-correct-forbidden': 'Seul un manager peut corriger un revenu.',
110+
'db.revenue-paid-forbidden': 'Seul un manager peut marquer un revenu comme payé.',
111+
'db.revenue-entries-not-found': 'Aucune écriture de revenu correspondante.',
112+
'db.revenue-entries-multi-company':
113+
'Les écritures de revenu doivent toutes appartenir à la même entreprise.',
114+
'db.revenue-net-negative-delete':
115+
'Supprimer cette écriture rendrait le revenu net négatif pour ce mois de projet.',
116+
'db.revenue-net-negative-remove':
117+
'Retirer cette écriture rendrait le revenu net négatif pour ce mois de projet.',
118+
'db.revenue-net-negative-correction':
119+
'Cette correction rendrait le revenu net négatif pour ce mois de projet.',
120+
'db.project-costs-not-found': 'Aucun coût de projet correspondant.',
121+
'db.project-costs-multi-company':
122+
'Les coûts de projet doivent tous appartenir à la même entreprise.',
123+
'db.project-costs-paid-forbidden':
124+
'Seul un manager peut marquer des coûts de projet comme payés.',
125+
'db.project-cost-reimbursable-not-payable':
126+
'Un coût remboursable se règle par un remboursement, pas en le marquant payé.',
127+
'db.referral-total-exceeded':
128+
"Les parts d'apport d'affaires ne peuvent pas dépasser 100 % sur un projet (ce changement atteindrait {0} %).",
129+
'db.settle-forbidden': 'Seul un manager peut clôturer un mois de projet.',
130+
131+
// Rémunération
132+
'db.rem-compute-forbidden': 'Seul un manager peut calculer le mois de rémunération.',
133+
'db.rem-lock-forbidden': 'Seul un manager peut verrouiller le mois de rémunération.',
134+
'db.rem-month-locked': 'Ce mois de rémunération est verrouillé.',
135+
'db.rem-month-not-computed': "Ce mois de rémunération n'a pas encore été calculé.",
136+
'db.rem-license-recipients-invalid':
137+
'La répartition de licence nécessite exactement deux bénéficiaires ({0} trouvés).',
138+
139+
// Paramètres d'entreprise
140+
'db.working-weekdays-empty': 'Choisissez au moins un jour travaillé.',
141+
'db.working-weekdays-out-of-range': 'Les jours travaillés doivent être compris entre lundi et dimanche.',
142+
'db.working-weekdays-duplicate': 'Un jour travaillé ne peut pas être listé deux fois.',
143+
'db.product-pool-project-invalid': 'Choisissez un projet de pool produit actif de cette entreprise.',
144+
},
145+
};

apps/mobile/src/lib/i18n/catalogs/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { detailsCatalog } from './details';
88
import { componentsACatalog } from './componentsA';
99
import { componentsBCatalog } from './componentsB';
1010
import { remCatalog } from './rem';
11+
import { dbErrorsCatalog } from './dbErrors';
1112

1213
const slices = [
1314
commonCatalog,
@@ -19,6 +20,7 @@ const slices = [
1920
componentsACatalog,
2021
componentsBCatalog,
2122
remCatalog,
23+
dbErrorsCatalog,
2224
];
2325

2426
function merge(locale: Locale): Catalog {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
-- If an invite is already marked accepted but the caller is an active member of
2+
-- that company, treat accept as success. Covers: soft-delete revive races,
3+
-- null accepted_by, and onboarding retries after a prior partial join.
4+
--
5+
-- Errors are raised as stable slugs, never user-facing copy: the client maps
6+
-- them to a translated message (see classifyInviteError in @chrono/sdk).
7+
8+
create or replace function public.accept_company_invite(p_token text)
9+
returns uuid
10+
language plpgsql
11+
security definer
12+
set search_path = ''
13+
as $$
14+
declare
15+
v_invite public.company_invites;
16+
v_uid uuid := (select auth.uid());
17+
v_token text := nullif(btrim(p_token), '');
18+
begin
19+
if v_uid is null then
20+
raise exception 'invite-unsigned';
21+
end if;
22+
23+
if v_token is null then
24+
raise exception 'invite-not-found';
25+
end if;
26+
27+
select * into v_invite
28+
from public.company_invites
29+
where token = v_token
30+
for update;
31+
32+
if v_invite.id is null then
33+
raise exception 'invite-not-found';
34+
end if;
35+
if v_invite.revoked_at is not null then
36+
raise exception 'invite-revoked';
37+
end if;
38+
if v_invite.accepted_at is not null then
39+
if v_invite.accepted_by is not distinct from v_uid
40+
or exists (
41+
select 1
42+
from public.company_members cm
43+
where cm.company_id = v_invite.company_id
44+
and cm.user_id = v_uid
45+
and cm.deleted = false
46+
) then
47+
perform public.internal_insert_member_from_invite(
48+
v_invite.company_id,
49+
v_uid,
50+
v_invite.role
51+
);
52+
-- Ensure accepted_by is stamped when reclaiming a used invite as a member.
53+
if v_invite.accepted_by is null then
54+
update public.company_invites
55+
set accepted_by = v_uid, updated_at = now()
56+
where id = v_invite.id;
57+
end if;
58+
return v_invite.company_id;
59+
end if;
60+
raise exception 'invite-used';
61+
end if;
62+
if v_invite.expires_at < now() then
63+
raise exception 'invite-expired';
64+
end if;
65+
66+
perform public.internal_insert_member_from_invite(
67+
v_invite.company_id,
68+
v_uid,
69+
v_invite.role
70+
);
71+
72+
update public.company_invites
73+
set accepted_at = now(), accepted_by = v_uid, updated_at = now()
74+
where id = v_invite.id;
75+
76+
return v_invite.company_id;
77+
end;
78+
$$;
79+
80+
revoke all on function public.accept_company_invite(text) from public;
81+
revoke all on function public.accept_company_invite(text) from anon;
82+
grant execute on function public.accept_company_invite(text) to authenticated;

0 commit comments

Comments
 (0)