Files
crowd-funder-for-time-pwa/src/services/notifications/NotificationDebugService.ts
Jose Olarte III 82380b3d35 fix(notifications): decouple JWT bypass from backend URL override
Add an explicit notificationDebug.bypassAuth setting (default off) so
custom backend URLs and test mode no longer skip authentication. Expose
the toggle in the Notification Debug Panel for local ngrok workflows;
hosted test servers receive JWT-authenticated requests by default.
2026-07-07 19:00:42 +08:00

359 lines
10 KiB
TypeScript

/**
* DEV-only notification testing utilities.
*
* IMPORTANT:
* This service intentionally routes through the same production notification
* orchestration paths used by refresh flows, wakeup pushes, and replacement.
* Avoid adding duplicate scheduling logic here.
*/
import { Capacitor } from "@capacitor/core";
import type { PushNotificationSchema } from "@capacitor/push-notifications";
import { logger } from "@/utils/logger";
import { getOrCreateDeviceId } from "./deviceId";
import {
clearNotificationDebugLogs,
logNotification,
} from "./NotificationDebugEvents";
import { logNotificationClearing } from "./notificationLog";
import {
getBackendBaseUrl,
getBypassAuth,
getNotificationApiBaseUrl,
getTestMode,
setBackendBaseUrl,
setBypassAuth,
setTestMode,
} from "./NotificationDebugConfig";
import {
getLastKnownFcmToken,
reregisterFcmTokenNow,
} from "./firebaseMessagingClient";
import {
getNotificationApiHeaders,
httpAuthErrorMessage,
} from "./notificationApiAuth";
import {
applyNotificationRefreshPayload,
handleCapacitorPushNotificationReceived,
refreshNotificationsWithDiagnostics,
type NotificationRefreshPayload,
} from "./NativeNotificationService";
import { truncateFcmTokenForLog } from "./notificationLog";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin";
type PendingNotificationInfo = {
identifier: string;
nextTriggerDate?: number | null;
triggerType?: string | null;
wallClockMillis?: number | null;
wallClockSource?: string | null;
};
export type PendingNotificationsResult = {
pending: PendingNotificationInfo[];
/** Native layer does not implement inspection on this platform (e.g. Android). */
inspectorUnavailableMessage?: string;
};
export type SendRealWakeupPingResult =
| { ok: true; responseBody?: unknown }
| {
ok: false;
errorMessage: string;
status?: number;
responseBody?: unknown;
};
function wakeupPingResponseDetail(body: unknown): Record<string, unknown> {
if (typeof body !== "object" || body === null) {
return {};
}
const record = body as Record<string, unknown>;
const detail: Record<string, unknown> = {};
for (const key of [
"success",
"message",
"reason",
"error",
"tokenSuffix",
"deviceId",
] as const) {
if (record[key] !== undefined) {
detail[key] = record[key];
}
}
return detail;
}
function wakeupPingFailureMessage(status: number, body: unknown): string {
if (typeof body === "object" && body !== null) {
const record = body as Record<string, unknown>;
for (const key of ["message", "reason", "error"] as const) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
}
if (status === 401 || status === 403) {
return httpAuthErrorMessage(status);
}
return `HTTP ${status}`;
}
function isUnimplementedError(e: unknown): boolean {
return (
typeof e === "object" &&
e !== null &&
"code" in e &&
(e as { code?: string }).code === "UNIMPLEMENTED"
);
}
const LOG = "[NotificationDebugService]";
export const NotificationDebugService = {
clearDebugLogs(): void {
clearNotificationDebugLogs();
},
getActiveBackendUrl(): string {
return getNotificationApiBaseUrl();
},
getBackendUrlOverride(): string | null {
return getBackendBaseUrl();
},
saveBackendBaseUrl(url: string): void {
setBackendBaseUrl(url);
logNotification(
url.trim()
? `Backend URL saved (${getNotificationApiBaseUrl()})`
: "Backend URL cleared (using default)",
);
},
setTestModeEnabled(enabled: boolean): void {
setTestMode(enabled);
logNotification(`Test mode ${enabled ? "enabled" : "disabled"}`);
},
isTestModeEnabled(): boolean {
return getTestMode();
},
setBypassAuthEnabled(enabled: boolean): void {
setBypassAuth(enabled);
logNotification(
`Auth bypass ${enabled ? "enabled" : "disabled"} (local dev only)`,
);
},
isBypassAuthEnabled(): boolean {
return getBypassAuth();
},
getFcmToken(): string | null {
return getLastKnownFcmToken();
},
async registerTokenNow(): Promise<void> {
logNotification("Register token now (debug panel)");
await reregisterFcmTokenNow();
},
async triggerBackendRefresh(): Promise<void> {
await refreshNotificationsWithDiagnostics({ source: "debug panel" });
},
/** Local simulation: same API call as a WAKEUP_PING handler (no push payload). */
async simulateWakeupViaRefresh(): Promise<void> {
logNotification("WAKEUP_PING simulation (local refresh API only)");
await refreshNotificationsWithDiagnostics({
source: "WAKEUP_PING simulation",
});
},
/** Full pipeline: backend `/debug/send-wakeup` → FCM → native WAKEUP_PING handler. */
async sendRealWakeupPing(): Promise<SendRealWakeupPingResult> {
logNotification("Real WAKEUP_PING requested");
const fcmToken = getLastKnownFcmToken()?.trim() ?? "";
if (!fcmToken) {
const errorMessage = "no FCM token (register first)";
logNotification(`Real WAKEUP_PING failed: ${errorMessage}`);
return { ok: false, errorMessage };
}
try {
const auth = await getNotificationApiHeaders();
if (!auth.ok) {
logNotification(`Real WAKEUP_PING failed: ${auth.message}`);
return { ok: false, errorMessage: auth.message };
}
const deviceId = await getOrCreateDeviceId();
const baseUrl = getNotificationApiBaseUrl();
const res = await fetch(`${baseUrl}/debug/send-wakeup`, {
method: "POST",
headers: auth.headers,
body: JSON.stringify({
deviceId,
fcmToken,
platform: Capacitor.getPlatform(),
testMode: getTestMode(),
}),
});
let responseBody: unknown;
try {
responseBody = await res.json();
} catch {
responseBody = undefined;
}
if (!res.ok) {
const errorMessage = wakeupPingFailureMessage(res.status, responseBody);
logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, {
status: res.status,
token: truncateFcmTokenForLog(fcmToken),
...wakeupPingResponseDetail(responseBody),
});
return {
ok: false,
errorMessage,
status: res.status,
responseBody,
};
}
logNotification("Real WAKEUP_PING success", {
token: truncateFcmTokenForLog(fcmToken),
deviceId,
...wakeupPingResponseDetail(responseBody),
});
return { ok: true, responseBody };
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, {
token: truncateFcmTokenForLog(fcmToken),
});
logger.warn(`${LOG} sendRealWakeupPing failed`, err);
return { ok: false, errorMessage };
}
},
generateMockNotifications(
intervalMs: number = 60_000,
): NotificationRefreshPayload {
const now = Date.now();
const future1 = now + intervalMs;
const future2 = now + intervalMs * 2;
return {
shouldNotify: true,
nextNotifications: [{ timestamp: future1 }, { timestamp: future2 }],
};
},
async triggerMockRefresh(intervalMs?: number): Promise<void> {
logNotification("Mock refresh requested");
const payload = this.generateMockNotifications(intervalMs);
const timestamps = payload.nextNotifications?.map((n) => n.timestamp) ?? [];
logNotification(`Mock payload generated (${timestamps.length} timestamps)`);
if (!Capacitor.isNativePlatform()) {
logNotification("Mock refresh skipped: not running on native platform");
return;
}
await applyNotificationRefreshPayload(payload);
logNotification("Mock refresh applied");
},
async simulateWakeupPing(): Promise<void> {
logNotification("Simulating WAKEUP_PING (production push handler)");
if (!Capacitor.isNativePlatform()) {
logNotification("WAKEUP_PING simulation skipped: not native platform");
return;
}
const notification = {
title: "WAKEUP_PING",
body: "",
id: "dev_wakeup_ping",
data: { type: "WAKEUP_PING" },
} as unknown as PushNotificationSchema;
await handleCapacitorPushNotificationReceived(notification);
},
async runFloodTest(intervalMs?: number): Promise<void> {
logNotification("Flood test started (20 sequential refreshes)");
for (let i = 0; i < 20; i++) {
logNotification(`Flood iteration ${i + 1}/20`);
await this.triggerMockRefresh(intervalMs);
}
logNotification("Flood test completed");
},
async clearNotifications(): Promise<void> {
logNotification("Clear notifications (debug panel)");
if (!Capacitor.isNativePlatform()) {
logNotification("Clear skipped: not running on native platform");
return;
}
const plugin = DailyNotification as unknown as {
clearAllNotifications?: () => Promise<void>;
cancelAllNotifications?: () => Promise<void>;
};
if (typeof plugin.clearAllNotifications === "function") {
logNotificationClearing("clearAllNotifications");
await plugin.clearAllNotifications();
} else if (typeof plugin.cancelAllNotifications === "function") {
logNotificationClearing("cancelAllNotifications");
await plugin.cancelAllNotifications();
} else {
logNotification("Clear not available (plugin method missing)");
return;
}
logNotification("Notifications cleared");
},
async getPendingNotifications(): Promise<PendingNotificationsResult> {
logNotification("Fetching pending notifications");
if (!Capacitor.isNativePlatform()) {
logNotification("Pending fetch skipped: not running on native platform");
return { pending: [] };
}
try {
const res = await NotificationInspector.getPendingNotifications();
const items = (res?.pending ?? []) as PendingNotificationInfo[];
logNotification(`Pending fetched (${items.length})`);
return { pending: items };
} catch (e: unknown) {
if (isUnimplementedError(e)) {
return {
pending: [],
inspectorUnavailableMessage:
"Pending notification inspection is currently supported on iOS only.",
};
}
logNotification("Pending fetch failed");
logger.warn(`${LOG} getPendingNotifications failed`, e);
return { pending: [] };
}
},
};