mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
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:
6 files changed
+115
-125
No files matched your search
@@ -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 +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";
|
||||
@@ -288,6 +288,7 @@
|
||||
"dependencies": {
|
||||
"@lingui/core": "catalog:",
|
||||
"@lingui/react": "catalog:",
|
||||
"@sofa/api": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lingui/cli": "catalog:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user