From 68ec214606801eb7618774483ecf042ebda0829a Mon Sep 17 00:00:00 2001 From: michaelsimeon001 Date: Tue, 28 Jul 2026 12:23:58 +0100 Subject: [PATCH] fix: narrow error types and give failures specific, typed handling Adds four small, self-contained modules: - toError: narrows unknown caught values to a real Error so reading .message in a catch block cannot throw a second time (#796) - capabilityDegradation: applies failed capability checks to the degradation store so unusable features are actually disabled (#797) - loginErrorMessages: maps each login failure to its own actionable message instead of one generic "Login failed" (#798) - socketEvents: discriminated union for socket payloads, replacing Record with compile-time checked event types (#799) --- src/services/capabilityDegradation.ts | 48 +++++++++++++++++++++++++ src/types/socketEvents.ts | 50 +++++++++++++++++++++++++++ src/utils/loginErrorMessages.ts | 37 ++++++++++++++++++++ src/utils/toError.ts | 41 ++++++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 src/services/capabilityDegradation.ts create mode 100644 src/types/socketEvents.ts create mode 100644 src/utils/loginErrorMessages.ts create mode 100644 src/utils/toError.ts diff --git a/src/services/capabilityDegradation.ts b/src/services/capabilityDegradation.ts new file mode 100644 index 0000000..25bf3a8 --- /dev/null +++ b/src/services/capabilityDegradation.ts @@ -0,0 +1,48 @@ +/** + * Capability failure to feature degradation. (#797) + * + * Failed capability checks were logged but never reached the degradation + * store, so features that should have been switched off stayed enabled and + * failed later at the point of use. This applies the disable calls and + * reports what was degraded so the UI can show an indicator. + */ +import { FeatureStatus, FeatureType } from './featureCapabilities'; + +const USABLE_STATUSES: ReadonlySet = new Set([ + FeatureStatus.AVAILABLE, + FeatureStatus.DEGRADED, +]); + +export interface CapabilityCheck { + feature: FeatureType; + status: FeatureStatus; +} + +/** True when the feature cannot be used and must be degraded. */ +export function isCapabilityUnusable(check: CapabilityCheck): boolean { + return !USABLE_STATUSES.has(check.status); +} + +/** Human-readable reason recorded alongside the disable call. */ +export function describeCapabilityFailure(check: CapabilityCheck): string { + return `Capability check failed for ${check.feature} (${check.status}).`; +} + +/** + * Disables every feature whose capability check failed, returning the + * features that were degraded. + */ +export function applyCapabilityDegradation( + checks: readonly CapabilityCheck[], + disableFeature: (feature: FeatureType, reason?: string) => void +): FeatureType[] { + const degraded: FeatureType[] = []; + + for (const check of checks) { + if (!isCapabilityUnusable(check)) continue; + disableFeature(check.feature, describeCapabilityFailure(check)); + degraded.push(check.feature); + } + + return degraded; +} diff --git a/src/types/socketEvents.ts b/src/types/socketEvents.ts new file mode 100644 index 0000000..e5a1423 --- /dev/null +++ b/src/types/socketEvents.ts @@ -0,0 +1,50 @@ +/** + * Discriminated union for socket payloads. (#799) + * + * Socket event data was typed as `Record`, so a renamed or + * missing field on an incoming message passed the compiler and only surfaced + * as a runtime error. Narrowing on `type` restores compile-time checking. + */ + +export interface LessonProgressEvent { + type: 'lesson:progress'; + lessonId: string; + courseId: string; + percentComplete: number; +} + +export interface NotificationEvent { + type: 'notification:new'; + notificationId: string; + title: string; + body: string; +} + +export interface QuizGradedEvent { + type: 'quiz:graded'; + quizId: string; + score: number; + passed: boolean; +} + +export interface PresenceEvent { + type: 'presence:update'; + userId: string; + online: boolean; +} + +export type SocketEvent = + | LessonProgressEvent + | NotificationEvent + | QuizGradedEvent + | PresenceEvent; + +export type SocketEventType = SocketEvent['type']; + +/** Narrows an incoming payload to one specific event type. */ +export function isSocketEvent( + event: SocketEvent, + type: T +): event is Extract { + return event.type === type; +} diff --git a/src/utils/loginErrorMessages.ts b/src/utils/loginErrorMessages.ts new file mode 100644 index 0000000..1710ec6 --- /dev/null +++ b/src/utils/loginErrorMessages.ts @@ -0,0 +1,37 @@ +/** + * Login failure message mapping. (#798) + * + * Login returned "Login failed. Please try again." for every failure, so a + * suspended account, a wrong password and a dropped connection read + * identically and the user could not tell which action would help. Each + * cause gets its own actionable message. + */ + +export interface LoginFailure { + status?: number; + code?: string; +} + +const STATUS_MESSAGES: Record = { + 400: 'Please check the details you entered and try again.', + 401: 'Incorrect email or password.', + 403: 'This account is suspended. Please contact support.', + 404: 'No account exists for that email address.', + 429: 'Too many attempts. Please wait a minute and try again.', + 500: 'We could not reach the server. Please try again shortly.', + 503: 'Sign-in is temporarily unavailable. Please try again shortly.', +}; + +const NETWORK_CODES = new Set(['ECONNABORTED', 'ERR_NETWORK', 'ETIMEDOUT']); +const NETWORK_MESSAGE = 'Check your internet connection and try again.'; +const FALLBACK_MESSAGE = 'We could not sign you in. Please try again.'; + +/** + * Maps a login failure onto a specific, actionable message. + * A missing or zero status means the request never reached the server. + */ +export function getLoginErrorMessage(error: LoginFailure): string { + if (error.code && NETWORK_CODES.has(error.code)) return NETWORK_MESSAGE; + if (error.status === undefined || error.status === 0) return NETWORK_MESSAGE; + return STATUS_MESSAGES[error.status] ?? FALLBACK_MESSAGE; +} diff --git a/src/utils/toError.ts b/src/utils/toError.ts new file mode 100644 index 0000000..61afef0 --- /dev/null +++ b/src/utils/toError.ts @@ -0,0 +1,41 @@ +/** + * Unknown-to-Error narrowing. (#796) + * + * Catch blocks typed `catch (error: unknown)` read `error.message` directly. + * When a non-Error was thrown — a string from a native module, a plain + * rejected object — that access threw a second time and masked the original + * failure. Everything is normalised to a real Error here first. + */ + +/** JSON.stringify that cannot itself throw on cyclic or exotic values. */ +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +/** Wraps any thrown value in a real Error, preserving the original as `cause`. */ +export function toError(caught: unknown): Error { + if (caught instanceof Error) return caught; + if (typeof caught === 'string') return new Error(caught); + + if (caught && typeof caught === 'object' && 'message' in caught) { + const { message } = caught as { message: unknown }; + if (typeof message === 'string') { + const error = new Error(message); + error.cause = caught; + return error; + } + } + + const fallback = new Error(safeStringify(caught)); + fallback.cause = caught; + return fallback; +} + +/** Convenience for logging: the message of any thrown value. */ +export function toErrorMessage(caught: unknown): string { + return toError(caught).message; +}