Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/services/capabilityDegradation.ts
Original file line number Diff line number Diff line change
@@ -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<FeatureStatus> = 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;
}
50 changes: 50 additions & 0 deletions src/types/socketEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Discriminated union for socket payloads. (#799)
*
* Socket event data was typed as `Record<string, any>`, 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<T extends SocketEventType>(
event: SocketEvent,
type: T
): event is Extract<SocketEvent, { type: T }> {
return event.type === type;
}
37 changes: 37 additions & 0 deletions src/utils/loginErrorMessages.ts
Original file line number Diff line number Diff line change
@@ -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<number, string> = {
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;
}
41 changes: 41 additions & 0 deletions src/utils/toError.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading