refactor(i18n): share error message maps between web and native

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-05 04:25:28 -04:00
1 parent d874ae9198
commit 4adaa4a73b
6 files changed
+115 -125

No files matched your search

+1 -61
View File
@@ -1,39 +1,6 @@
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { ORPCError } from "@orpc/client";
import type { AppErrorCode } from "@sofa/api/errors";
import { i18n } from "@sofa/i18n";
export function getAppErrorCode(error: unknown): AppErrorCode | null {
if (
error instanceof ORPCError &&
error.data &&
typeof error.data === "object" &&
"code" in error.data &&
typeof error.data.code === "string"
) {
return error.data.code as AppErrorCode;
}
return null;
}
const APP_ERROR_MESSAGES: Record<AppErrorCode, MessageDescriptor> = {
TITLE_NOT_FOUND: msg`Title not found`,
PERSON_NOT_FOUND: msg`Person not found`,
INTEGRATION_NOT_FOUND: msg`Integration not found`,
BACKUP_NOT_FOUND: msg`Backup not found`,
BACKUP_DELETE_FAILED: msg`Failed to delete backup`,
BACKUP_RESTORE_FAILED: msg`Backup restoration failed`,
JOB_NOT_FOUND: msg`Job not found`,
TMDB_NOT_CONFIGURED: msg`TMDB API key is not configured`,
IMPORT_INVALID_FILE: msg`Invalid import file`,
IMPORT_PAYLOAD_TOO_LARGE: msg`Import payload is too large`,
IMPORT_ALREADY_RUNNING: msg`An import is already in progress`,
IMPORT_CANNOT_CANCEL: msg`This import cannot be cancelled`,
REGISTRATION_CLOSED: msg`Registration is currently closed`,
EXPORT_FAILED: msg`Failed to export data`,
};
export * from "@sofa/i18n/errors";
/** True when an oRPC call failed because the server no longer accepts the session. */
export function isUnauthorizedError(error: unknown): boolean {
@@ -44,30 +11,3 @@ export function isUnauthorizedError(error: unknown): boolean {
export function isClientError(error: unknown): boolean {
return error instanceof ORPCError && error.status >= 400 && error.status < 500;
}
/** Localized message for an oRPC error carrying an app error code, else `fallback`. */
export function getErrorMessage(error: unknown, fallback?: string): string {
const code = getAppErrorCode(error);
if (code && code in APP_ERROR_MESSAGES) return i18n._(APP_ERROR_MESSAGES[code]);
return fallback ?? i18n._(msg`Something went wrong`);
}
const AUTH_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
INVALID_EMAIL_OR_PASSWORD: msg`Invalid email or password`,
USER_ALREADY_EXISTS: msg`An account with this email already exists`,
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: msg`An account with this email already exists`,
PASSWORD_TOO_SHORT: msg`Password is too short`,
PASSWORD_TOO_LONG: msg`Password is too long`,
INVALID_EMAIL: msg`Enter a valid email address`,
REGISTRATION_CLOSED: msg`Registration is currently closed`,
};
/** Localized message for a Better Auth client error (never its raw `message`). */
export function getAuthErrorMessage(
error: { code?: string; status?: number } | null | undefined,
fallback: string,
): string {
if (error?.status === 429) return i18n._(msg`Too many attempts. Wait a moment and try again.`);
const descriptor = error?.code ? AUTH_ERROR_MESSAGES[error.code] : undefined;
return descriptor ? i18n._(descriptor) : fallback;
}
+1 -63
View File
@@ -1,63 +1 @@
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { ORPCError } from "@orpc/client";
import type { AppErrorCode } from "@sofa/api/errors";
import { i18n } from "@sofa/i18n";
export function getAppErrorCode(error: unknown): AppErrorCode | null {
if (
error instanceof ORPCError &&
error.data &&
typeof error.data === "object" &&
"code" in error.data &&
typeof error.data.code === "string"
) {
return error.data.code as AppErrorCode;
}
return null;
}
const APP_ERROR_MESSAGES: Record<AppErrorCode, MessageDescriptor> = {
TITLE_NOT_FOUND: msg`Title not found`,
PERSON_NOT_FOUND: msg`Person not found`,
INTEGRATION_NOT_FOUND: msg`Integration not found`,
BACKUP_NOT_FOUND: msg`Backup not found`,
BACKUP_DELETE_FAILED: msg`Failed to delete backup`,
BACKUP_RESTORE_FAILED: msg`Backup restoration failed`,
JOB_NOT_FOUND: msg`Job not found`,
TMDB_NOT_CONFIGURED: msg`TMDB API key is not configured`,
IMPORT_INVALID_FILE: msg`Invalid import file`,
IMPORT_PAYLOAD_TOO_LARGE: msg`Import payload is too large`,
IMPORT_ALREADY_RUNNING: msg`An import is already in progress`,
IMPORT_CANNOT_CANCEL: msg`This import cannot be cancelled`,
REGISTRATION_CLOSED: msg`Registration is currently closed`,
EXPORT_FAILED: msg`Failed to export data`,
};
/** Localized message for an oRPC error carrying an app error code, else `fallback`. */
export function getErrorMessage(error: unknown, fallback?: string): string {
const code = getAppErrorCode(error);
if (code && code in APP_ERROR_MESSAGES) return i18n._(APP_ERROR_MESSAGES[code]);
return fallback ?? i18n._(msg`Something went wrong`);
}
const AUTH_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
INVALID_EMAIL_OR_PASSWORD: msg`Invalid email or password`,
USER_ALREADY_EXISTS: msg`An account with this email already exists`,
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: msg`An account with this email already exists`,
PASSWORD_TOO_SHORT: msg`Password is too short`,
PASSWORD_TOO_LONG: msg`Password is too long`,
INVALID_EMAIL: msg`Enter a valid email address`,
REGISTRATION_CLOSED: msg`Registration is currently closed`,
};
/** Localized message for a Better Auth client error (never its raw `message`). */
export function getAuthErrorMessage(
error: { code?: string; status?: number } | null | undefined,
fallback: string,
): string {
if (error?.status === 429) return i18n._(msg`Too many attempts. Wait a moment and try again.`);
const descriptor = error?.code ? AUTH_ERROR_MESSAGES[error.code] : undefined;
return descriptor ? i18n._(descriptor) : fallback;
}
export * from "@sofa/i18n/errors";
+1
View File
@@ -288,6 +288,7 @@
"dependencies": {
"@lingui/core": "catalog:",
"@lingui/react": "catalog:",
"@sofa/api": "workspace:*",
},
"devDependencies": {
"@lingui/cli": "catalog:",
+4 -1
View File
@@ -8,6 +8,8 @@
"./locales": "./src/locales.ts",
"./format": "./src/format.ts",
"./date-buckets": "./src/date-buckets.ts",
"./errors": "./src/errors.ts",
"./integration-status": "./src/integration-status.ts",
"./test-utils": "./src/test-utils.tsx"
},
"scripts": {
@@ -19,7 +21,8 @@
},
"dependencies": {
"@lingui/core": "catalog:",
"@lingui/react": "catalog:"
"@lingui/react": "catalog:",
"@sofa/api": "workspace:*"
},
"devDependencies": {
"@lingui/cli": "catalog:",
+57
View File
@@ -0,0 +1,57 @@
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import type { AppErrorCode } from "@sofa/api/errors";
import { i18n } from "./index";
export function getAppErrorCode(error: unknown): AppErrorCode | null {
if (typeof error !== "object" || error === null || !("data" in error)) return null;
const { data } = error;
if (typeof data !== "object" || data === null || !("code" in data)) return null;
return typeof data.code === "string" ? (data.code as AppErrorCode) : null;
}
const APP_ERROR_MESSAGES: Record<AppErrorCode, MessageDescriptor> = {
TITLE_NOT_FOUND: msg`Title not found`,
PERSON_NOT_FOUND: msg`Person not found`,
INTEGRATION_NOT_FOUND: msg`Integration not found`,
BACKUP_NOT_FOUND: msg`Backup not found`,
BACKUP_DELETE_FAILED: msg`Failed to delete backup`,
BACKUP_RESTORE_FAILED: msg`Backup restoration failed`,
JOB_NOT_FOUND: msg`Job not found`,
TMDB_NOT_CONFIGURED: msg`TMDB API key is not configured`,
IMPORT_INVALID_FILE: msg`Invalid import file`,
IMPORT_PAYLOAD_TOO_LARGE: msg`Import payload is too large`,
IMPORT_ALREADY_RUNNING: msg`An import is already in progress`,
IMPORT_CANNOT_CANCEL: msg`This import cannot be cancelled`,
REGISTRATION_CLOSED: msg`Registration is currently closed`,
EXPORT_FAILED: msg`Failed to export data`,
};
/** Localized message for an oRPC error carrying an app error code, else `fallback`. */
export function getErrorMessage(error: unknown, fallback?: string): string {
const code = getAppErrorCode(error);
if (code && code in APP_ERROR_MESSAGES) return i18n._(APP_ERROR_MESSAGES[code]);
return fallback ?? i18n._(msg`Something went wrong`);
}
const AUTH_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
INVALID_EMAIL_OR_PASSWORD: msg`Invalid email or password`,
USER_ALREADY_EXISTS: msg`An account with this email already exists`,
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: msg`An account with this email already exists`,
PASSWORD_TOO_SHORT: msg`Password is too short`,
PASSWORD_TOO_LONG: msg`Password is too long`,
INVALID_EMAIL: msg`Enter a valid email address`,
REGISTRATION_CLOSED: msg`Registration is currently closed`,
};
/** Localized message for a Better Auth client error (never its raw `message`). */
export function getAuthErrorMessage(
error: { code?: string; status?: number } | null | undefined,
fallback: string,
): string {
if (error?.status === 429) return i18n._(msg`Too many attempts. Wait a moment and try again.`);
const descriptor = error?.code ? AUTH_ERROR_MESSAGES[error.code] : undefined;
return descriptor ? i18n._(descriptor) : fallback;
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, test, vi } from "vitest";
vi.mock("../src/index", () => ({
i18n: {
locale: "en",
_: (descriptor: { id?: string; message?: string } | string) => {
if (typeof descriptor === "string") return descriptor;
return descriptor.message ?? descriptor.id ?? "";
},
},
}));
vi.mock("@lingui/core/macro", () => ({
msg: (strings: TemplateStringsArray, ...values: unknown[]) => ({
id: String.raw(strings, ...values),
message: String.raw(strings, ...values),
}),
}));
import { getAppErrorCode, getAuthErrorMessage, getErrorMessage } from "../src/errors";
describe("getAppErrorCode", () => {
test("recognizes a plain object carrying an app error code", () => {
expect(getAppErrorCode({ data: { code: "JOB_NOT_FOUND" } })).toBe("JOB_NOT_FOUND");
});
test.each([["oops"], [null], [{}], [{ data: { code: 5 } }]])("returns null for %j", (input) => {
expect(getAppErrorCode(input)).toBeNull();
});
});
describe("getErrorMessage", () => {
test("returns the English message for a mapped code", () => {
expect(getErrorMessage({ data: { code: "JOB_NOT_FOUND" } })).toBe("Job not found");
});
test("falls back to the given fallback for an unknown code", () => {
expect(getErrorMessage({ data: { code: "NOPE" } }, "Fallback")).toBe("Fallback");
});
});
describe("getAuthErrorMessage", () => {
test("maps a 429 status to the rate-limit message", () => {
expect(getAuthErrorMessage({ status: 429 }, "F")).toMatch(/^Too many attempts/);
});
test("maps a known auth code and falls back otherwise", () => {
expect(getAuthErrorMessage({ code: "INVALID_EMAIL" }, "F")).toBe("Enter a valid email address");
expect(getAuthErrorMessage({ code: "UNKNOWN" }, "F")).toBe("F");
});
});