Files
crowd-funder-for-time-pwa/src/services/notifications/NotificationService.ts
Jose Olarte III 0d7586865c feat(notifications): allow auth bypass for local debug and ngrok testing
Add shouldBypassNotificationAuth() when test mode or a backend URL override
is set so register/refresh can proceed without DID/Bearer headers. Production
paths still require auth when bypass is off; log bypass vs authenticated
request modes for easier WAKEUP_PING and panel smoke testing.
2026-05-20 19:34:46 +08:00

287 lines
7.4 KiB
TypeScript

/**
* Unified Notification Service
*
* Provides a platform-agnostic interface for managing notifications across
* web and native platforms. Automatically delegates to the appropriate
* implementation based on the runtime platform:
*
* - Native platforms (iOS/Android): Uses DailyNotificationPlugin
* - Web/PWA: Uses Web Push with service workers and VAPID
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2026-01-21
*/
import { Capacitor } from "@capacitor/core";
import { logger } from "@/utils/logger";
import { getOrCreateDeviceId } from "./deviceId";
import {
getNotificationApiBaseUrl,
getTestMode,
} from "./NotificationDebugConfig";
import {
getNotificationApiHeaders,
httpAuthErrorMessage,
logNotificationAuthFailure,
} from "./notificationApiAuth";
import {
logTokenRegistrationFailure,
logTokenRegistrationStarted,
logTokenRegistrationSuccess,
} from "./notificationLog";
import { NativeNotificationService } from "./NativeNotificationService";
import { WebPushNotificationService } from "./WebPushNotificationService";
/**
* Registers an FCM device token with the app backend (native Capacitor token or web getToken).
*/
export async function registerToken(fcmToken: string): Promise<void> {
logTokenRegistrationStarted(fcmToken);
const deviceId = await getOrCreateDeviceId();
const baseUrl = getNotificationApiBaseUrl();
try {
const auth = await getNotificationApiHeaders("register");
if (!auth.ok) {
logNotificationAuthFailure("register", auth.message);
throw new Error(`registerToken auth unavailable: ${auth.message}`);
}
const res = await fetch(`${baseUrl}/notifications/register`, {
method: "POST",
headers: auth.headers,
body: JSON.stringify({
deviceId,
fcmToken,
platform: Capacitor.getPlatform(),
testMode: getTestMode(),
}),
});
if (!res.ok) {
const authDetail =
res.status === 401 || res.status === 403
? httpAuthErrorMessage(res.status)
: `HTTP ${res.status}`;
logger.warn("[NotificationService] registerToken failed", {
status: res.status,
statusText: res.statusText,
authDetail,
});
throw new Error(`registerToken failed: ${authDetail}`);
}
logTokenRegistrationSuccess(fcmToken);
} catch (err) {
logTokenRegistrationFailure(fcmToken, err);
throw err;
}
}
/**
* Options for scheduling a daily notification
*/
export interface DailyNotificationOptions {
/**
* Time to send notification in HH:mm format (24-hour)
* Example: "09:00" for 9 AM, "17:30" for 5:30 PM
*/
time: string;
/**
* Notification title
*/
title: string;
/**
* Notification body/message
*/
body: string;
/**
* Optional notification priority
* @default 'normal'
*/
priority?: "low" | "normal" | "high";
/**
* Optional rollover interval in minutes (e.g. 10 for testing). When set, next occurrence
* is scheduled this many minutes after the current trigger instead of 24 hours.
* Plugin must support and persist this for it to take effect after reboot.
*/
rolloverIntervalMinutes?: number;
}
/**
* Current notification status
*/
export interface NotificationStatus {
/**
* Whether notifications are currently enabled
*/
enabled: boolean;
/**
* Scheduled time in HH:mm format, if any
*/
scheduledTime?: string;
/**
* Current notification message, if any
*/
message?: string;
/**
* Platform-specific notification type identifier
*/
notificationType?: string;
}
/**
* Permission status result
*/
export interface PermissionStatus {
/**
* Whether notification permissions are granted
*/
granted: boolean;
/**
* Additional platform-specific permission details
*/
details?: {
notifications?: boolean;
exactAlarm?: boolean;
backgroundRefresh?: boolean;
};
}
/**
* Unified notification service interface
* All platform-specific implementations must conform to this interface
*/
export interface NotificationServiceInterface {
/**
* Check if notifications are supported on current platform
* @returns true if notifications are supported
*/
isSupported(): boolean;
/**
* Request notification permissions from the user
* Shows system permission dialog on first call
* @returns Promise that resolves to true if permissions granted
*/
requestPermissions(): Promise<boolean>;
/**
* Check current notification permission status
* @returns Promise with permission status
*/
checkPermissions(): Promise<PermissionStatus>;
/**
* Schedule a daily notification/reminder
* @param options Notification configuration
* @returns Promise that resolves to true if scheduling succeeded
*/
scheduleDailyNotification(
options: DailyNotificationOptions,
): Promise<boolean>;
/**
* Cancel all daily notifications
* @returns Promise that resolves when cancellation is complete
*/
cancelDailyNotification(): Promise<void>;
/**
* Get current notification status
* @returns Promise with current notification state
*/
getStatus(): Promise<NotificationStatus>;
/**
* Get the platform name for this service
* @returns Platform identifier (e.g., 'native', 'web')
*/
getPlatformName(): string;
}
/**
* Notification Service Factory
*
* Singleton factory that creates and manages the appropriate notification
* service implementation based on the current platform.
*
* @example
* ```typescript
* const notificationService = NotificationService.getInstance();
* const granted = await notificationService.requestPermissions();
* if (granted) {
* await notificationService.scheduleDailyNotification({
* time: '09:00',
* title: 'Daily Check-In',
* body: 'Time to check your TimeSafari activity'
* });
* }
* ```
*/
export class NotificationService {
private static instance: NotificationServiceInterface | null = null;
private static instanceCreated = false;
/**
* Get the singleton notification service instance
* Creates the appropriate platform-specific implementation on first call
*
* @returns NotificationServiceInterface implementation for current platform
*/
static getInstance(): NotificationServiceInterface {
if (!this.instance) {
const platform = Capacitor.getPlatform();
if (!this.instanceCreated) {
// Log initialization for debugging (only happens once per app lifecycle)
// eslint-disable-next-line no-console
console.log(
`[NotificationService] Creating ${this.isNative() ? "native" : "web"} notification service for platform: ${platform}`,
);
this.instanceCreated = true;
}
if (this.isNative()) {
// iOS/Android: Use native plugin
this.instance = new NativeNotificationService();
} else {
// Web/PWA: Use web push
this.instance = new WebPushNotificationService();
}
}
return this.instance;
}
/**
* Check if running on a native platform (iOS/Android)
* @returns true if running on iOS or Android
*/
static isNative(): boolean {
return Capacitor.isNativePlatform();
}
/**
* Get the current platform name
* @returns Platform identifier: 'ios', 'android', 'web', 'electron'
*/
static getPlatform(): string {
return Capacitor.getPlatform();
}
/**
* Reset the singleton instance (for testing purposes)
* @internal
*/
static reset(): void {
this.instance = null;
this.instanceCreated = false;
}
}