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.
287 lines
7.4 KiB
TypeScript
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;
|
|
}
|
|
}
|