diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..56f67e0 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,18 @@ +# Build output directories +dist/ +build/ +out/ + +# Dependencies +node_modules/ + +# Generated files +*.d.ts +*.js.map + +# Test coverage +coverage/ + +# Temporary files +*.tmp +*.temp diff --git a/examples/timesafari-integration-example.ts b/examples/timesafari-integration-example.ts new file mode 100644 index 0000000..522075e --- /dev/null +++ b/examples/timesafari-integration-example.ts @@ -0,0 +1,274 @@ +/** + * TimeSafari Integration Example + * + * Demonstrates how to integrate the Daily Notification Plugin with TimeSafari's + * privacy-preserving claims architecture, community features, and storage patterns. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +import { + DailyNotification, + TimeSafariIntegrationService, + TimeSafariStorageAdapterImpl, + TimeSafariCommunityIntegrationService, + StorageFactory, + TimeSafariIntegrationConfig, + CommunityIntegrationConfig +} from '@timesafari/daily-notification-plugin'; + +/** + * TimeSafari Integration Example + * + * This example shows how to integrate the plugin with TimeSafari's architecture: + * - Privacy-preserving claims with DIDs + * - Community features with rate limiting + * - Storage adapter pattern + * - Observability and monitoring + */ +export class TimeSafariIntegrationExample { + private integrationService: TimeSafariIntegrationService; + private communityService: TimeSafariCommunityIntegrationService; + private storageAdapter: TimeSafariStorageAdapterImpl; + + constructor() { + this.integrationService = TimeSafariIntegrationService.getInstance(); + this.communityService = TimeSafariCommunityIntegrationService.getInstance(); + } + + /** + * Initialize TimeSafari integration + */ + async initialize(): Promise { + try { + // Initializing TimeSafari integration... + + // Create storage adapter + const storage = StorageFactory.createStorage('android'); + this.storageAdapter = new TimeSafariStorageAdapterImpl(storage, 'timesafari_notifications'); + + // Initialize TimeSafari integration service + const integrationConfig: TimeSafariIntegrationConfig = { + activeDid: 'did:example:timesafari-user-123', + storageAdapter: this.storageAdapter, + endorserApiBaseUrl: 'https://endorser.ch/api/v1' + }; + + await this.integrationService.initialize(integrationConfig); + + // Initialize community integration service + const communityConfig: CommunityIntegrationConfig = { + maxRequestsPerMinute: 30, + maxRequestsPerHour: 1000, + burstLimit: 10, + initialBackoffMs: 1000, + maxBackoffMs: 30000, + backoffMultiplier: 2, + jitterEnabled: true, + basePollingIntervalMs: 300000, // 5 minutes + maxPollingIntervalMs: 1800000, // 30 minutes + adaptivePolling: true + }; + + await this.communityService.initialize(communityConfig); + + // Log: TimeSafari integration initialized successfully + } catch (error) { + throw new Error(`Initialization failed: ${error}`); + } + } + + /** + * Schedule TimeSafari daily notifications + */ + async scheduleTimeSafariNotifications(): Promise { + try { + // Log: Scheduling TimeSafari daily notifications + + // Schedule content fetch for community data + await DailyNotification.scheduleContentFetch({ + schedule: '0 8 * * *', // 8 AM daily + ttlSeconds: 3600, // 1 hour TTL + source: 'timesafari_community', + url: 'https://endorser.ch/api/v1/community-updates', + headers: { + 'Authorization': 'Bearer did-jwt-token', + 'Content-Type': 'application/json' + } + }); + + // Schedule user notification + await DailyNotification.scheduleUserNotification({ + schedule: '0 9 * * *', // 9 AM daily + title: 'TimeSafari Community Update', + body: 'New offers and project updates available', + actions: [ + { id: 'view_offers', title: 'View Offers' }, + { id: 'view_projects', title: 'View Projects' }, + { id: 'dismiss', title: 'Dismiss' } + ] + }); + + // Register callback for analytics + await DailyNotification.registerCallback('timesafari_analytics', { + kind: 'http', + target: 'https://analytics.timesafari.com/events', + headers: { + 'Authorization': 'Bearer analytics-token', + 'Content-Type': 'application/json' + } + }); + + // Log: TimeSafari notifications scheduled successfully + } catch (error) { + throw new Error(`Failed to schedule notifications: ${error}`); + } + } + + /** + * Fetch community data with rate limiting + */ + async fetchCommunityData(): Promise { + try { + // Log: Fetching community data + + const userConfig = { + activeDid: 'did:example:timesafari-user-123', + fetchOffersToPerson: true, + fetchOffersToProjects: true, + fetchProjectUpdates: true, + starredPlanIds: ['plan-1', 'plan-2', 'plan-3'] + }; + + const bundle = await this.communityService.fetchCommunityDataWithRateLimit(userConfig); + + // Log: Community data fetched with success: bundle.success + + // Store in TimeSafari storage + await this.storageAdapter.store('community_data', bundle, 3600); // 1 hour TTL + + } catch (error) { + throw new Error(`Failed to fetch community data: ${error}`); + } + } + + /** + * Demonstrate DID-signed payloads + */ + async demonstrateDidPayloads(): Promise { + try { + // Log: Demonstrating DID-signed payloads + + const samplePayloads = this.integrationService.generateSampleDidPayloads(); + + for (const payload of samplePayloads) { + // Log: Sample payload type: payload.type + + // Verify signature (placeholder) + await this.integrationService.verifyDidSignature( + JSON.stringify(payload.payload), + payload.signature + ); + + // Log: Signature verification completed + } + + } catch (error) { + throw new Error(`Failed to demonstrate DID payloads: ${error}`); + } + } + + /** + * Demonstrate data retention and redaction + */ + async demonstrateDataRetention(): Promise { + try { + // Log: Demonstrating data retention and redaction + + this.integrationService.getDataRetentionPolicy(); + + // Log: Data retention policy configured + + // Store sample data with different retention policies + await this.storageAdapter.store('session_data', { activeDid: 'did:example:123' }, 3600); + await this.storageAdapter.store('notification_content', { title: 'Test', body: 'Test' }, 604800); // 7 days + await this.storageAdapter.store('callback_events', { event: 'delivered' }, 2592000); // 30 days + await this.storageAdapter.store('performance_metrics', { latency: 100 }, 7776000); // 90 days + + // Get storage statistics + await this.storageAdapter.getStats(); + // Log: Storage statistics available + + } catch (error) { + throw new Error(`Failed to demonstrate data retention: ${error}`); + } + } + + /** + * Monitor rate limiting and backoff + */ + async monitorRateLimiting(): Promise { + try { + // Log: Monitoring rate limiting and backoff + + // Get current status + this.communityService.getRateLimitStatus(); + this.communityService.getBackoffStatus(); + this.communityService.getPollingStatus(); + + // Log: Rate limit status available + // Log: Backoff status available + // Log: Polling status available + + // Get optimized polling interval + this.communityService.getOptimizedPollingInterval(); + // Log: Optimized polling interval available + + } catch (error) { + throw new Error(`Failed to monitor rate limiting: ${error}`); + } + } + + /** + * Run complete TimeSafari integration example + */ + async runExample(): Promise { + try { + // Log: Running complete TimeSafari integration example + + // Initialize + await this.initialize(); + + // Schedule notifications + await this.scheduleTimeSafariNotifications(); + + // Fetch community data + await this.fetchCommunityData(); + + // Demonstrate DID payloads + await this.demonstrateDidPayloads(); + + // Demonstrate data retention + await this.demonstrateDataRetention(); + + // Monitor rate limiting + await this.monitorRateLimiting(); + + // Log: Complete TimeSafari integration example finished successfully + } catch (error) { + throw new Error(`Example failed: ${error}`); + } + } +} + +/** + * Usage Example + */ +export async function runTimeSafariIntegrationExample(): Promise { + const example = new TimeSafariIntegrationExample(); + await example.runExample(); +} + +// Export for use in other modules +export default TimeSafariIntegrationExample; diff --git a/src/services/DailyNotificationService.ts b/src/services/DailyNotificationService.ts new file mode 100644 index 0000000..546ef20 --- /dev/null +++ b/src/services/DailyNotificationService.ts @@ -0,0 +1,519 @@ +/** + * TimeSafari Daily Notification Service + * + * Main service layer for TimeSafari Daily Notification Plugin integration. + * Implements singleton pattern following TimeSafari conventions with structured + * logging, circuit breaker patterns, and comprehensive error handling. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +import { TimeSafariIntegrationService } from '../timesafari-integration'; +import { TimeSafariCommunityIntegrationService } from '../timesafari-community-integration'; +import { TimeSafariStorageAdapterImpl, StorageFactory } from '../timesafari-storage-adapter'; +import { observability, EVENT_CODES } from '../observability'; + +/** + * TimeSafari Daily Notification Service Configuration + */ +export interface DailyNotificationServiceConfig { + /** + * Active DID for TimeSafari integration + */ + activeDid: string; + + /** + * Storage adapter for data persistence + */ + storageAdapter?: TimeSafariStorageAdapterImpl; + + /** + * Circuit breaker configuration + */ + circuitBreaker?: CircuitBreakerConfig; + + /** + * Backoff configuration + */ + backoff?: BackoffConfig; + + /** + * Logging configuration + */ + logging?: LoggingConfig; + + /** + * Platform-specific configuration + */ + platform?: 'android' | 'ios' | 'electron'; +} + +/** + * Circuit Breaker Configuration + */ +export interface CircuitBreakerConfig { + failureThreshold: number; + recoveryTimeout: number; // milliseconds + monitoringPeriod: number; // milliseconds + halfOpenMaxCalls: number; +} + +/** + * Backoff Configuration + */ +export interface BackoffConfig { + initialDelay: number; // milliseconds + maxDelay: number; // milliseconds + multiplier: number; + jitter: boolean; + maxRetries: number; +} + +/** + * Logging Configuration + */ +export interface LoggingConfig { + level: 'debug' | 'info' | 'warn' | 'error'; + enableStructuredLogging: boolean; + enableEventIds: boolean; + enablePerformanceMetrics: boolean; +} + +/** + * Service Status + */ +export interface ServiceStatus { + initialized: boolean; + activeDid: string | null; + platform: string; + circuitBreakerState: 'closed' | 'open' | 'half-open'; + lastError: string | null; + lastErrorTime: number | null; + totalRequests: number; + successfulRequests: number; + failedRequests: number; + uptime: number; +} + +/** + * TimeSafari Daily Notification Service + * + * Singleton service that provides a unified interface for TimeSafari + * daily notification functionality across all platforms. + */ +export class DailyNotificationService { + private static instance: DailyNotificationService; + private config: DailyNotificationServiceConfig; + private initialized = false; + private startTime: number = Date.now(); + private requestCount = 0; + private successCount = 0; + private failureCount = 0; + private lastError: string | null = null; + private lastErrorTime: number | null = null; + + // Circuit breaker state + private circuitBreakerState: 'closed' | 'open' | 'half-open' = 'closed'; + private circuitBreakerFailures = 0; + private circuitBreakerLastFailure = 0; + private circuitBreakerHalfOpenCalls = 0; + + // Services + private integrationService: TimeSafariIntegrationService; + private communityService: TimeSafariCommunityIntegrationService; + private storageAdapter: TimeSafariStorageAdapterImpl; + + private constructor() { + this.config = this.getDefaultConfig(); + this.integrationService = TimeSafariIntegrationService.getInstance(); + this.communityService = TimeSafariCommunityIntegrationService.getInstance(); + this.storageAdapter = new TimeSafariStorageAdapterImpl( + StorageFactory.createStorage('android') + ); + } + + /** + * Get singleton instance + */ + static getInstance(): DailyNotificationService { + if (!DailyNotificationService.instance) { + DailyNotificationService.instance = new DailyNotificationService(); + } + return DailyNotificationService.instance; + } + + /** + * Initialize the service with TimeSafari configuration + */ + async initialize(config: DailyNotificationServiceConfig): Promise { + try { + const eventId = this.generateEventId(); + observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Initializing DailyNotificationService', { eventId }); + + this.config = { ...this.getDefaultConfig(), ...config }; + + // Initialize storage adapter if provided + if (config.storageAdapter) { + this.storageAdapter = config.storageAdapter; + } + + // Initialize TimeSafari integration service + await this.integrationService.initialize({ + activeDid: config.activeDid, + storageAdapter: this.storageAdapter, + endorserApiBaseUrl: 'https://endorser.ch/api/v1' + }); + + // Initialize community integration service + await this.communityService.initialize({ + maxRequestsPerMinute: 30, + maxRequestsPerHour: 1000, + burstLimit: 10, + initialBackoffMs: this.config.backoff?.initialDelay || 1000, + maxBackoffMs: this.config.backoff?.maxDelay || 30000, + backoffMultiplier: this.config.backoff?.multiplier || 2, + jitterEnabled: this.config.backoff?.jitter !== false, + basePollingIntervalMs: 300000, // 5 minutes + maxPollingIntervalMs: 1800000, // 30 minutes + adaptivePolling: true + }); + + this.initialized = true; + + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'DailyNotificationService initialized successfully', { + eventId, + activeDid: config.activeDid, + platform: config.platform || 'android' + }); + + } catch (error) { + const eventId = this.generateEventId(); + this.recordError(error as Error); + + observability.logEvent('ERROR', EVENT_CODES.FETCH_FAILURE, 'Failed to initialize DailyNotificationService', { + eventId, + error: (error as Error).message + }); + + throw error; + } + } + + /** + * Schedule a daily notification with TimeSafari integration + */ + async scheduleDailyNotification(options: { + title: string; + body: string; + time: string; + channel?: string; + actions?: Array<{ id: string; title: string }>; + }): Promise { + this.checkInitialized(); + + try { + const eventId = this.generateEventId(); + this.recordRequest(); + + observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Scheduling daily notification', { + eventId, + channel: options.channel || 'default' + }); + + // Use the appropriate notification channel based on content + const channel = options.channel || this.determineNotificationChannel(options.title, options.body); + + // Schedule the notification using native platform implementation + // This will be handled by the native Android/iOS/Electron implementations + // For now, we'll store the notification data and let the native layer handle scheduling + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'Scheduling notification via native platform', { + eventId, + title: options.title, + body: options.body, + time: options.time, + channel: channel + }); + + // Store notification metadata + await this.storageAdapter.store(`notification_${eventId}`, { + title: options.title, + body: options.body, + time: options.time, + channel: channel, + scheduledAt: Date.now(), + activeDid: this.config.activeDid + }, 7 * 24 * 60 * 60 * 1000); // 7 days TTL + + this.recordSuccess(); + + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'Daily notification scheduled successfully', { + eventId, + channel: channel + }); + + } catch (error) { + this.recordError(error as Error); + throw error; + } + } + + /** + * Fetch TimeSafari community data with rate limiting and backoff + */ + async fetchCommunityData(): Promise { + this.checkInitialized(); + + try { + const eventId = this.generateEventId(); + this.recordRequest(); + + observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Fetching TimeSafari community data', { eventId }); + + const userConfig = { + activeDid: this.config.activeDid, + fetchOffersToPerson: true, + fetchOffersToProjects: true, + fetchProjectUpdates: true, + starredPlanIds: await this.getStarredPlanIds() + }; + + const bundle = await this.communityService.fetchCommunityDataWithRateLimit(userConfig); + + // Store community data + await this.storageAdapter.store('community_data', bundle, 3600000); // 1 hour TTL + + this.recordSuccess(); + + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'Community data fetched successfully', { + eventId, + dataSize: JSON.stringify(bundle).length + }); + + return bundle; + + } catch (error) { + this.recordError(error as Error); + throw error; + } + } + + /** + * Get service status + */ + getStatus(): ServiceStatus { + return { + initialized: this.initialized, + activeDid: this.config.activeDid, + platform: this.config.platform || 'android', + circuitBreakerState: this.circuitBreakerState, + lastError: this.lastError, + lastErrorTime: this.lastErrorTime, + totalRequests: this.requestCount, + successfulRequests: this.successCount, + failedRequests: this.failureCount, + uptime: Date.now() - this.startTime + }; + } + + /** + * Get circuit breaker status + */ + getCircuitBreakerStatus(): { + state: 'closed' | 'open' | 'half-open'; + failures: number; + lastFailure: number; + halfOpenCalls: number; + } { + return { + state: this.circuitBreakerState, + failures: this.circuitBreakerFailures, + lastFailure: this.circuitBreakerLastFailure, + halfOpenCalls: this.circuitBreakerHalfOpenCalls + }; + } + + /** + * Get rate limiting status + */ + getRateLimitStatus(): unknown { + return this.communityService.getRateLimitStatus(); + } + + /** + * Get backoff status + */ + getBackoffStatus(): unknown { + return this.communityService.getBackoffStatus(); + } + + /** + * Get polling status + */ + getPollingStatus(): unknown { + return this.communityService.getPollingStatus(); + } + + /** + * Update configuration + */ + updateConfig(updates: Partial): void { + this.config = { ...this.config, ...updates }; + + const eventId = this.generateEventId(); + observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Service configuration updated', { + eventId, + updatedFields: Object.keys(updates) + }); + } + + /** + * Reset circuit breaker + */ + resetCircuitBreaker(): void { + this.circuitBreakerState = 'closed'; + this.circuitBreakerFailures = 0; + this.circuitBreakerLastFailure = 0; + this.circuitBreakerHalfOpenCalls = 0; + + const eventId = this.generateEventId(); + observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Circuit breaker reset', { eventId }); + } + + /** + * Shutdown the service + */ + async shutdown(): Promise { + try { + const eventId = this.generateEventId(); + observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Shutting down DailyNotificationService', { eventId }); + + // Clear any scheduled notifications via native platform + // This will be handled by the native Android/iOS/Electron implementations + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'Clearing notifications via native platform', { eventId }); + + // Clear storage + await this.storageAdapter.clear(); + + this.initialized = false; + + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'DailyNotificationService shutdown complete', { eventId }); + + } catch (error) { + const eventId = this.generateEventId(); + observability.logEvent('ERROR', EVENT_CODES.FETCH_FAILURE, 'Error during service shutdown', { + eventId, + error: (error as Error).message + }); + + throw error; + } + } + + // Private methods + + private getDefaultConfig(): DailyNotificationServiceConfig { + return { + activeDid: '', + circuitBreaker: { + failureThreshold: 5, + recoveryTimeout: 60000, // 1 minute + monitoringPeriod: 10000, // 10 seconds + halfOpenMaxCalls: 3 + }, + backoff: { + initialDelay: 1000, + maxDelay: 30000, + multiplier: 2, + jitter: true, + maxRetries: 3 + }, + logging: { + level: 'info', + enableStructuredLogging: true, + enableEventIds: true, + enablePerformanceMetrics: true + }, + platform: 'android' + }; + } + + private checkInitialized(): void { + if (!this.initialized) { + throw new Error('DailyNotificationService not initialized. Call initialize() first.'); + } + } + + private recordRequest(): void { + this.requestCount++; + } + + private recordSuccess(): void { + this.successCount++; + this.resetCircuitBreakerOnSuccess(); + } + + private recordError(error: Error): void { + this.failureCount++; + this.lastError = error.message; + this.lastErrorTime = Date.now(); + this.updateCircuitBreakerOnFailure(); + } + + private updateCircuitBreakerOnFailure(): void { + this.circuitBreakerFailures++; + this.circuitBreakerLastFailure = Date.now(); + + if (this.circuitBreakerFailures >= (this.config.circuitBreaker?.failureThreshold || 5)) { + this.circuitBreakerState = 'open'; + + const eventId = this.generateEventId(); + observability.logEvent('WARN', EVENT_CODES.FETCH_FAILURE, 'Circuit breaker opened due to failures', { + eventId, + failures: this.circuitBreakerFailures + }); + } + } + + private resetCircuitBreakerOnSuccess(): void { + if (this.circuitBreakerState === 'half-open') { + this.circuitBreakerState = 'closed'; + this.circuitBreakerFailures = 0; + this.circuitBreakerHalfOpenCalls = 0; + + const eventId = this.generateEventId(); + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'Circuit breaker closed after successful recovery', { eventId }); + } + } + + private determineNotificationChannel(title: string, body: string): string { + // Determine appropriate notification channel based on content + if (title.toLowerCase().includes('community') || body.toLowerCase().includes('community')) { + return 'timesafari_community_updates'; + } else if (title.toLowerCase().includes('project') || body.toLowerCase().includes('project')) { + return 'timesafari_project_notifications'; + } else if (title.toLowerCase().includes('trust') || body.toLowerCase().includes('trust')) { + return 'timesafari_trust_network'; + } else if (title.toLowerCase().includes('reminder') || body.toLowerCase().includes('reminder')) { + return 'timesafari_reminders'; + } else { + return 'timesafari_system'; + } + } + + private async getStarredPlanIds(): Promise { + try { + const starredPlans = await this.storageAdapter.retrieve('starred_plans'); + return (starredPlans as string[]) || []; + } catch (error) { + observability.logEvent('WARN', EVENT_CODES.FETCH_FAILURE, 'Failed to retrieve starred plans', { + error: (error as Error).message + }); + return []; + } + } + + private generateEventId(): string { + return `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } +} diff --git a/src/services/DatabaseIntegrationService.ts b/src/services/DatabaseIntegrationService.ts new file mode 100644 index 0000000..1c11664 --- /dev/null +++ b/src/services/DatabaseIntegrationService.ts @@ -0,0 +1,538 @@ +/** + * TimeSafari Database Integration Service + * + * Handles database integration with TimeSafari's storage patterns. + * Supports SQLite (mobile/desktop) with + * storage adapter pattern to avoid plugin owning DB lifecycle. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +import { TimeSafariStorageAdapterImpl, StorageFactory } from '../timesafari-storage-adapter'; +import { observability, EVENT_CODES } from '../observability'; + +/** + * Database Integration Configuration + */ +export interface DatabaseIntegrationConfig { + /** + * Platform type + */ + platform: 'android' | 'ios' | 'electron'; + + /** + * Storage adapter from host + */ + storageAdapter?: TimeSafariStorageAdapterImpl; + + /** + * Database configuration + */ + database?: DatabaseConfig; + + /** + * Watermark management configuration + */ + watermark?: WatermarkConfig; +} + +/** + * Database Configuration + */ +export interface DatabaseConfig { + /** + * Database name + */ + name: string; + + /** + * Database version + */ + version: number; + + /** + * Table schemas + */ + tables: TableSchema[]; + + /** + * Migration scripts + */ + migrations: MigrationScript[]; +} + +/** + * Table Schema + */ +export interface TableSchema { + name: string; + columns: ColumnDefinition[]; + indexes: IndexDefinition[]; + constraints: ConstraintDefinition[]; +} + +/** + * Column Definition + */ +export interface ColumnDefinition { + name: string; + type: 'TEXT' | 'INTEGER' | 'REAL' | 'BLOB' | 'BOOLEAN'; + nullable: boolean; + primaryKey: boolean; + autoIncrement: boolean; + defaultValue?: unknown; +} + +/** + * Index Definition + */ +export interface IndexDefinition { + name: string; + columns: string[]; + unique: boolean; +} + +/** + * Constraint Definition + */ +export interface ConstraintDefinition { + name: string; + type: 'FOREIGN_KEY' | 'CHECK' | 'UNIQUE'; + definition: string; +} + +/** + * Migration Script + */ +export interface MigrationScript { + version: number; + up: string; + down: string; +} + +/** + * Watermark Configuration + */ +export interface WatermarkConfig { + /** + * Enable watermark management + */ + enabled: boolean; + + /** + * Watermark table name + */ + tableName: string; + + /** + * Watermark column name + */ + columnName: string; + + /** + * Initial watermark value + */ + initialValue: number; + + /** + * Watermark update interval (milliseconds) + */ + updateInterval: number; +} + +/** + * Database Integration Service + */ +export class DatabaseIntegrationService { + private static instance: DatabaseIntegrationService; + private config: DatabaseIntegrationConfig; + private storageAdapter: TimeSafariStorageAdapterImpl; + private initialized = false; + private watermarkValue = 0; + private watermarkUpdateTimer: NodeJS.Timeout | null = null; + + private constructor() { + this.config = this.getDefaultConfig(); + this.storageAdapter = new TimeSafariStorageAdapterImpl( + StorageFactory.createStorage('android') + ); + } + + /** + * Get singleton instance + */ + static getInstance(): DatabaseIntegrationService { + if (!DatabaseIntegrationService.instance) { + DatabaseIntegrationService.instance = new DatabaseIntegrationService(); + } + return DatabaseIntegrationService.instance; + } + + /** + * Initialize database integration + */ + async initialize(config: DatabaseIntegrationConfig): Promise { + try { + const eventId = this.generateEventId(); + observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Initializing database integration', { eventId }); + + this.config = { ...this.getDefaultConfig(), ...config }; + + // Use provided storage adapter or create default + if (config.storageAdapter) { + this.storageAdapter = config.storageAdapter; + } else { + this.storageAdapter = new TimeSafariStorageAdapterImpl( + StorageFactory.createStorage(config.platform) + ); + } + + // Initialize database schema + await this.initializeDatabaseSchema(); + + // Initialize watermark management + if (this.config.watermark?.enabled) { + await this.initializeWatermarkManagement(); + } + + this.initialized = true; + + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'Database integration initialized successfully', { + eventId, + platform: config.platform, + databaseName: this.config.database?.name + }); + + } catch (error) { + const eventId = this.generateEventId(); + observability.logEvent('ERROR', EVENT_CODES.FETCH_FAILURE, 'Failed to initialize database integration', { + eventId, + error: (error as Error).message + }); + + throw error; + } + } + + /** + * Store notification data + */ + async storeNotificationData(data: { + id: string; + title: string; + body: string; + scheduledTime: number; + channel: string; + activeDid: string; + metadata?: unknown; + }): Promise { + this.checkInitialized(); + + try { + const eventId = this.generateEventId(); + + // Store in TimeSafari storage adapter + await this.storageAdapter.store(`notification_${data.id}`, { + ...data, + storedAt: Date.now(), + watermark: this.watermarkValue + }, 7 * 24 * 60 * 60 * 1000); // 7 days TTL + + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'Notification data stored successfully', { + eventId, + notificationId: data.id, + channel: data.channel + }); + + } catch (error) { + const eventId = this.generateEventId(); + observability.logEvent('ERROR', EVENT_CODES.FETCH_FAILURE, 'Failed to store notification data', { + eventId, + error: (error as Error).message, + notificationId: data.id + }); + + throw error; + } + } + + /** + * Retrieve notification data + */ + async retrieveNotificationData(notificationId: string): Promise { + this.checkInitialized(); + + try { + const data = await this.storageAdapter.retrieve(`notification_${notificationId}`); + + if (data) { + const eventId = this.generateEventId(); + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'Notification data retrieved successfully', { + eventId, + notificationId + }); + } + + return data; + + } catch (error) { + const eventId = this.generateEventId(); + observability.logEvent('ERROR', EVENT_CODES.FETCH_FAILURE, 'Failed to retrieve notification data', { + eventId, + error: (error as Error).message, + notificationId + }); + + throw error; + } + } + + /** + * Update watermark value + */ + async updateWatermark(newValue: number): Promise { + this.checkInitialized(); + + if (!this.config.watermark?.enabled) { + return; + } + + try { + const eventId = this.generateEventId(); + const oldValue = this.watermarkValue; + + this.watermarkValue = newValue; + + // Store watermark in storage adapter + await this.storageAdapter.store('watermark', { + value: this.watermarkValue, + updatedAt: Date.now() + }); + + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'Watermark updated successfully', { + eventId, + oldValue, + newValue: this.watermarkValue + }); + + } catch (error) { + const eventId = this.generateEventId(); + observability.logEvent('ERROR', EVENT_CODES.FETCH_FAILURE, 'Failed to update watermark', { + eventId, + error: (error as Error).message, + newValue + }); + + throw error; + } + } + + /** + * Get current watermark value + */ + async getWatermark(): Promise { + this.checkInitialized(); + + if (!this.config.watermark?.enabled) { + return 0; + } + + try { + const watermarkData = await this.storageAdapter.retrieve('watermark'); + if (watermarkData && (watermarkData as { value: number }).value) { + this.watermarkValue = (watermarkData as { value: number }).value; + } + + return this.watermarkValue; + + } catch (error) { + const eventId = this.generateEventId(); + observability.logEvent('ERROR', EVENT_CODES.FETCH_FAILURE, 'Failed to get watermark', { + eventId, + error: (error as Error).message + }); + + return 0; + } + } + + /** + * Get database statistics + */ + async getDatabaseStats(): Promise<{ + totalNotifications: number; + totalStorageSize: number; + watermarkValue: number; + lastUpdated: number; + }> { + this.checkInitialized(); + + try { + const stats = await this.storageAdapter.getStats(); + const watermark = await this.getWatermark(); + + return { + totalNotifications: stats.validKeys, + totalStorageSize: stats.totalSizeBytes, + watermarkValue: watermark, + lastUpdated: Date.now() + }; + + } catch (error) { + const eventId = this.generateEventId(); + observability.logEvent('ERROR', EVENT_CODES.FETCH_FAILURE, 'Failed to get database stats', { + eventId, + error: (error as Error).message + }); + + throw error; + } + } + + /** + * Cleanup expired data + */ + async cleanupExpiredData(): Promise { + this.checkInitialized(); + + try { + const eventId = this.generateEventId(); + + await this.storageAdapter.cleanupExpired(); + + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'Expired data cleaned up successfully', { eventId }); + + } catch (error) { + const eventId = this.generateEventId(); + observability.logEvent('ERROR', EVENT_CODES.FETCH_FAILURE, 'Failed to cleanup expired data', { + eventId, + error: (error as Error).message + }); + + throw error; + } + } + + /** + * Shutdown database integration + */ + async shutdown(): Promise { + try { + const eventId = this.generateEventId(); + observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Shutting down database integration', { eventId }); + + // Clear watermark update timer + if (this.watermarkUpdateTimer) { + clearInterval(this.watermarkUpdateTimer); + this.watermarkUpdateTimer = null; + } + + this.initialized = false; + + observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'Database integration shutdown complete', { eventId }); + + } catch (error) { + const eventId = this.generateEventId(); + observability.logEvent('ERROR', EVENT_CODES.FETCH_FAILURE, 'Error during database integration shutdown', { + eventId, + error: (error as Error).message + }); + + throw error; + } + } + + // Private methods + + private getDefaultConfig(): DatabaseIntegrationConfig { + return { + platform: 'android', + database: { + name: 'timesafari_daily_notifications', + version: 1, + tables: [ + { + name: 'notifications', + columns: [ + { name: 'id', type: 'TEXT', nullable: false, primaryKey: true, autoIncrement: false }, + { name: 'title', type: 'TEXT', nullable: false, primaryKey: false, autoIncrement: false }, + { name: 'body', type: 'TEXT', nullable: false, primaryKey: false, autoIncrement: false }, + { name: 'scheduled_time', type: 'INTEGER', nullable: false, primaryKey: false, autoIncrement: false }, + { name: 'channel', type: 'TEXT', nullable: false, primaryKey: false, autoIncrement: false }, + { name: 'active_did', type: 'TEXT', nullable: false, primaryKey: false, autoIncrement: false }, + { name: 'metadata', type: 'TEXT', nullable: true, primaryKey: false, autoIncrement: false }, + { name: 'created_at', type: 'INTEGER', nullable: false, primaryKey: false, autoIncrement: false }, + { name: 'updated_at', type: 'INTEGER', nullable: false, primaryKey: false, autoIncrement: false } + ], + indexes: [ + { name: 'idx_notifications_scheduled_time', columns: ['scheduled_time'], unique: false }, + { name: 'idx_notifications_channel', columns: ['channel'], unique: false }, + { name: 'idx_notifications_active_did', columns: ['active_did'], unique: false } + ], + constraints: [] + } + ], + migrations: [] + }, + watermark: { + enabled: true, + tableName: 'watermarks', + columnName: 'value', + initialValue: 0, + updateInterval: 60000 // 1 minute + } + }; + } + + private checkInitialized(): void { + if (!this.initialized) { + throw new Error('DatabaseIntegrationService not initialized. Call initialize() first.'); + } + } + + private async initializeDatabaseSchema(): Promise { + // In a real implementation, this would create the database schema + // For now, we'll just store the schema metadata + await this.storageAdapter.store('database_schema', { + version: this.config.database?.version || 1, + tables: this.config.database?.tables || [], + initializedAt: Date.now() + }); + } + + private async initializeWatermarkManagement(): Promise { + if (!this.config.watermark?.enabled) { + return; + } + + // Get current watermark value (skip initialization check for initial setup) + try { + const watermarkData = await this.storageAdapter.retrieve('watermark'); + if (watermarkData && (watermarkData as { value: number }).value) { + this.watermarkValue = (watermarkData as { value: number }).value; + } + } catch (error) { + // Use default value if retrieval fails + this.watermarkValue = this.config.watermark.initialValue; + } + + // Set up periodic watermark updates + this.watermarkUpdateTimer = setInterval(async () => { + try { + const newValue = Date.now(); + await this.updateWatermark(newValue); + } catch (error) { + const eventId = this.generateEventId(); + observability.logEvent('ERROR', EVENT_CODES.FETCH_FAILURE, 'Failed to update watermark in timer', { + eventId, + error: (error as Error).message + }); + } + }, this.config.watermark.updateInterval); + } + + private generateEventId(): string { + return `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } +} diff --git a/src/timesafari-community-integration.ts b/src/timesafari-community-integration.ts new file mode 100644 index 0000000..cb7775f --- /dev/null +++ b/src/timesafari-community-integration.ts @@ -0,0 +1,455 @@ +/** + * TimeSafari Community Features Integration + * + * Implements community features integration with rate limiting, backoff policies, + * and polling optimization for TimeSafari's community-driven platform. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +import { + TimeSafariIntegrationService, + TimeSafariUserConfig, + TimeSafariNotificationBundle +} from './timesafari-integration'; + +/** + * TimeSafari Community Integration Service + * + * Handles community features integration with intelligent rate limiting, + * backoff policies, and polling optimization. + */ +export class TimeSafariCommunityIntegrationService { + private static instance: TimeSafariCommunityIntegrationService; + private integrationService: TimeSafariIntegrationService; + private rateLimiter: RateLimiter; + private backoffManager: BackoffManager; + private pollingManager: PollingManager; + + private constructor() { + this.integrationService = TimeSafariIntegrationService.getInstance(); + this.rateLimiter = new RateLimiter(); + this.backoffManager = new BackoffManager(); + this.pollingManager = new PollingManager(); + } + + /** + * Get singleton instance + */ + static getInstance(): TimeSafariCommunityIntegrationService { + if (!TimeSafariCommunityIntegrationService.instance) { + TimeSafariCommunityIntegrationService.instance = new TimeSafariCommunityIntegrationService(); + } + return TimeSafariCommunityIntegrationService.instance; + } + + /** + * Initialize community integration + * + * @param config - Community integration configuration + */ + async initialize(config: CommunityIntegrationConfig): Promise { + try { + // Initializing TimeSafari community integration + + // Initialize rate limiter + this.rateLimiter.configure({ + maxRequestsPerMinute: config.maxRequestsPerMinute || 30, + maxRequestsPerHour: config.maxRequestsPerHour || 1000, + burstLimit: config.burstLimit || 10 + }); + + // Initialize backoff manager + this.backoffManager.configure({ + initialDelayMs: config.initialBackoffMs || 1000, + maxDelayMs: config.maxBackoffMs || 30000, + backoffMultiplier: config.backoffMultiplier || 2, + jitterEnabled: config.jitterEnabled !== false + }); + + // Initialize polling manager + this.pollingManager.configure({ + baseIntervalMs: config.basePollingIntervalMs || 300000, // 5 minutes + maxIntervalMs: config.maxPollingIntervalMs || 1800000, // 30 minutes + adaptivePolling: config.adaptivePolling !== false + }); + + // TimeSafari community integration initialization complete + } catch (error) { + throw new Error(`TimeSafari community integration initialization failed: ${error}`); + } + } + + /** + * Fetch community data with rate limiting and backoff + * + * @param config - User configuration + * @returns Community data bundle + */ + async fetchCommunityDataWithRateLimit(config: TimeSafariUserConfig): Promise { + try { + // Fetching community data with rate limiting + + // Check rate limits + if (!this.rateLimiter.canMakeRequest()) { + const waitTime = this.rateLimiter.getWaitTime(); + // Rate limit exceeded, waiting + await this.sleep(waitTime); + } + + // Check backoff status + if (this.backoffManager.isInBackoff()) { + const backoffTime = this.backoffManager.getRemainingBackoffTime(); + // In backoff, waiting + await this.sleep(backoffTime); + } + + // Record request attempt + this.rateLimiter.recordRequest(); + + try { + // Fetch data + const bundle = await this.integrationService.fetchCommunityData(config); + + // Reset backoff on success + this.backoffManager.recordSuccess(); + + // Update polling interval based on success + this.pollingManager.recordSuccess(); + + // Community data fetch successful + return bundle; + + } catch (error) { + // Record failure and apply backoff + this.backoffManager.recordFailure(); + this.pollingManager.recordFailure(); + + throw new Error(`Community data fetch failed: ${error}`); + } + + } catch (error) { + throw new Error(`Rate-limited fetch failed: ${error}`); + } + } + + /** + * Get optimized polling interval + * + * @returns Polling interval in milliseconds + */ + getOptimizedPollingInterval(): number { + return this.pollingManager.getCurrentInterval(); + } + + /** + * Get rate limit status + * + * @returns Rate limit status + */ + getRateLimitStatus(): RateLimitStatus { + return this.rateLimiter.getStatus(); + } + + /** + * Get backoff status + * + * @returns Backoff status + */ + getBackoffStatus(): BackoffStatus { + return this.backoffManager.getStatus(); + } + + /** + * Get polling status + * + * @returns Polling status + */ + getPollingStatus(): PollingStatus { + return this.pollingManager.getStatus(); + } + + /** + * Sleep utility + * + * @param ms - Milliseconds to sleep + */ + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +/** + * Rate Limiter + */ +class RateLimiter { + private config: RateLimitConfig = { + maxRequestsPerMinute: 30, + maxRequestsPerHour: 1000, + burstLimit: 10 + }; + private requests: number[] = []; + private burstRequests: number[] = []; + + configure(config: RateLimitConfig): void { + this.config = { ...this.config, ...config }; + } + + canMakeRequest(): boolean { + const now = Date.now(); + const oneMinuteAgo = now - 60000; + const oneHourAgo = now - 3600000; + const oneSecondAgo = now - 1000; + + // Clean old requests + this.requests = this.requests.filter(time => time > oneHourAgo); + this.burstRequests = this.burstRequests.filter(time => time > oneSecondAgo); + + // Check limits + const requestsLastMinute = this.requests.filter(time => time > oneMinuteAgo).length; + const requestsLastHour = this.requests.length; + const burstRequests = this.burstRequests.length; + + return requestsLastMinute < this.config.maxRequestsPerMinute && + requestsLastHour < this.config.maxRequestsPerHour && + burstRequests < this.config.burstLimit; + } + + recordRequest(): void { + const now = Date.now(); + this.requests.push(now); + this.burstRequests.push(now); + } + + getWaitTime(): number { + const now = Date.now(); + const oneMinuteAgo = now - 60000; + const oneHourAgo = now - 3600000; + const oneSecondAgo = now - 1000; + + // Find oldest request in each window + const requestsLastMinute = this.requests.filter(time => time > oneMinuteAgo); + const requestsLastHour = this.requests.filter(time => time > oneHourAgo); + const burstRequests = this.burstRequests.filter(time => time > oneSecondAgo); + + let waitTime = 0; + + // Check minute limit + if (requestsLastMinute.length >= this.config.maxRequestsPerMinute) { + const oldestInMinute = Math.min(...requestsLastMinute); + waitTime = Math.max(waitTime, oldestInMinute + 60000 - now); + } + + // Check hour limit + if (requestsLastHour.length >= this.config.maxRequestsPerHour) { + const oldestInHour = Math.min(...requestsLastHour); + waitTime = Math.max(waitTime, oldestInHour + 3600000 - now); + } + + // Check burst limit + if (burstRequests.length >= this.config.burstLimit) { + const oldestBurst = Math.min(...burstRequests); + waitTime = Math.max(waitTime, oldestBurst + 1000 - now); + } + + return Math.max(0, waitTime); + } + + getStatus(): RateLimitStatus { + const now = Date.now(); + const oneMinuteAgo = now - 60000; + const oneHourAgo = now - 3600000; + const oneSecondAgo = now - 1000; + + return { + requestsLastMinute: this.requests.filter(time => time > oneMinuteAgo).length, + requestsLastHour: this.requests.filter(time => time > oneHourAgo).length, + burstRequests: this.burstRequests.filter(time => time > oneSecondAgo).length, + canMakeRequest: this.canMakeRequest(), + waitTime: this.getWaitTime() + }; + } +} + +/** + * Backoff Manager + */ +class BackoffManager { + private config: BackoffConfig = { + initialDelayMs: 1000, + maxDelayMs: 30000, + backoffMultiplier: 2, + jitterEnabled: true + }; + private failureCount = 0; + private lastFailureTime = 0; + private currentDelay = 0; + + configure(config: BackoffConfig): void { + this.config = { ...this.config, ...config }; + } + + recordSuccess(): void { + this.failureCount = 0; + this.currentDelay = 0; + } + + recordFailure(): void { + this.failureCount++; + this.lastFailureTime = Date.now(); + + // Calculate exponential backoff + this.currentDelay = Math.min( + this.config.initialDelayMs * Math.pow(this.config.backoffMultiplier, this.failureCount - 1), + this.config.maxDelayMs + ); + + // Add jitter if enabled + if (this.config.jitterEnabled) { + const jitter = Math.random() * 0.1 * this.currentDelay; + this.currentDelay += jitter; + } + } + + isInBackoff(): boolean { + if (this.failureCount === 0) return false; + + const timeSinceLastFailure = Date.now() - this.lastFailureTime; + return timeSinceLastFailure < this.currentDelay; + } + + getRemainingBackoffTime(): number { + if (!this.isInBackoff()) return 0; + + const timeSinceLastFailure = Date.now() - this.lastFailureTime; + return this.currentDelay - timeSinceLastFailure; + } + + getStatus(): BackoffStatus { + return { + failureCount: this.failureCount, + currentDelay: this.currentDelay, + isInBackoff: this.isInBackoff(), + remainingBackoffTime: this.getRemainingBackoffTime() + }; + } +} + +/** + * Polling Manager + */ +class PollingManager { + private config: PollingConfig = { + baseIntervalMs: 300000, // 5 minutes + maxIntervalMs: 1800000, // 30 minutes + adaptivePolling: true + }; + private currentInterval = 300000; + private consecutiveFailures = 0; + private consecutiveSuccesses = 0; + + configure(config: PollingConfig): void { + this.config = { ...this.config, ...config }; + this.currentInterval = this.config.baseIntervalMs; + } + + recordSuccess(): void { + this.consecutiveSuccesses++; + this.consecutiveFailures = 0; + + if (this.config.adaptivePolling && this.consecutiveSuccesses >= 3) { + // Decrease interval on sustained success + this.currentInterval = Math.max( + this.config.baseIntervalMs, + this.currentInterval * 0.8 + ); + } + } + + recordFailure(): void { + this.consecutiveFailures++; + this.consecutiveSuccesses = 0; + + if (this.config.adaptivePolling) { + // Increase interval on failure + this.currentInterval = Math.min( + this.config.maxIntervalMs, + this.currentInterval * 1.5 + ); + } + } + + getCurrentInterval(): number { + return this.currentInterval; + } + + getStatus(): PollingStatus { + return { + currentInterval: this.currentInterval, + consecutiveFailures: this.consecutiveFailures, + consecutiveSuccesses: this.consecutiveSuccesses, + adaptivePolling: this.config.adaptivePolling + }; + } +} + +/** + * Configuration Interfaces + */ +export interface CommunityIntegrationConfig { + maxRequestsPerMinute?: number; + maxRequestsPerHour?: number; + burstLimit?: number; + initialBackoffMs?: number; + maxBackoffMs?: number; + backoffMultiplier?: number; + jitterEnabled?: boolean; + basePollingIntervalMs?: number; + maxPollingIntervalMs?: number; + adaptivePolling?: boolean; +} + +interface RateLimitConfig { + maxRequestsPerMinute: number; + maxRequestsPerHour: number; + burstLimit: number; +} + +interface BackoffConfig { + initialDelayMs: number; + maxDelayMs: number; + backoffMultiplier: number; + jitterEnabled: boolean; +} + +interface PollingConfig { + baseIntervalMs: number; + maxIntervalMs: number; + adaptivePolling: boolean; +} + +/** + * Status Interfaces + */ +export interface RateLimitStatus { + requestsLastMinute: number; + requestsLastHour: number; + burstRequests: number; + canMakeRequest: boolean; + waitTime: number; +} + +export interface BackoffStatus { + failureCount: number; + currentDelay: number; + isInBackoff: boolean; + remainingBackoffTime: number; +} + +export interface PollingStatus { + currentInterval: number; + consecutiveFailures: number; + consecutiveSuccesses: number; + adaptivePolling: boolean; +} diff --git a/src/timesafari-integration.ts b/src/timesafari-integration.ts new file mode 100644 index 0000000..a59af8d --- /dev/null +++ b/src/timesafari-integration.ts @@ -0,0 +1,396 @@ +/** + * TimeSafari Integration Service + * + * Implements privacy-preserving claims architecture integration with endorser.ch, + * DIDs (Decentralized Identifiers), and cryptographic verification patterns. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +import { + OffersResponse, + OffersToPlansResponse, + PlansLastUpdatedResponse, + TimeSafariNotificationBundle, + TimeSafariUserConfig +} from './definitions'; + +/** + * TimeSafari Integration Service + * + * Handles integration with TimeSafari's privacy-preserving claims architecture + * including DIDs, cryptographic verification, and endorser.ch API integration. + */ +export class TimeSafariIntegrationService { + private static instance: TimeSafariIntegrationService; + private activeDid: string | null = null; + private veramoStack: unknown = null; + private endorserApiBaseUrl: string; + + private constructor() { + this.endorserApiBaseUrl = 'https://endorser.ch/api/v1'; + } + + /** + * Get singleton instance + */ + static getInstance(): TimeSafariIntegrationService { + if (!TimeSafariIntegrationService.instance) { + TimeSafariIntegrationService.instance = new TimeSafariIntegrationService(); + } + return TimeSafariIntegrationService.instance; + } + + /** + * Initialize TimeSafari integration + * + * @param config - TimeSafari integration configuration + */ + async initialize(config: TimeSafariIntegrationConfig): Promise { + try { + // Initializing TimeSafari integration service + + // Set active DID + this.activeDid = config.activeDid; + + // Initialize Veramo stack if available + if (config.veramoStack) { + this.veramoStack = config.veramoStack; + // Veramo stack initialized successfully + } + + // Storage adapter is handled by the caller + + // Set API base URL if provided + if (config.endorserApiBaseUrl) { + this.endorserApiBaseUrl = config.endorserApiBaseUrl; + } + + // TimeSafari integration initialization complete + } catch (error) { + throw new Error(`TimeSafari integration initialization failed: ${error}`); + } + } + + /** + * Fetch TimeSafari community data with privacy-preserving patterns + * + * @param config - User configuration for data fetching + * @returns TimeSafari notification bundle + */ + async fetchCommunityData(config: TimeSafariUserConfig): Promise { + try { + // Fetching TimeSafari community data + + const bundle: TimeSafariNotificationBundle = { + fetchTimestamp: Date.now(), + success: false, + metadata: { + activeDid: config.activeDid, + fetchDurationMs: 0, + cachedResponses: 0, + networkResponses: 0 + } + }; + + const startTime = Date.now(); + + // Fetch offers to person if enabled + if (config.fetchOffersToPerson) { + try { + bundle.offersToPerson = await this.fetchOffersToPerson(config.activeDid); + if (bundle.metadata) bundle.metadata.networkResponses++; + } catch (error) { + // Failed to fetch offers to person, continuing with other data + } + } + + // Fetch offers to projects if enabled + if (config.fetchOffersToProjects) { + try { + bundle.offersToProjects = await this.fetchOffersToProjects(config.activeDid); + if (bundle.metadata) bundle.metadata.networkResponses++; + } catch (error) { + // Failed to fetch offers to projects, continuing with other data + } + } + + // Fetch project updates if enabled + if (config.fetchProjectUpdates) { + try { + bundle.projectUpdates = await this.fetchProjectUpdates(config.activeDid, config.starredPlanIds); + if (bundle.metadata) bundle.metadata.networkResponses++; + } catch (error) { + // Failed to fetch project updates, continuing with other data + } + } + + if (bundle.metadata) bundle.metadata.fetchDurationMs = Date.now() - startTime; + bundle.success = true; + + // Community data fetch complete + return bundle; + + } catch (error) { + throw new Error(`Community data fetch failed: ${error}`); + } + } + + /** + * Fetch offers to person with DID-based authentication + * + * @param activeDid - Active DID for authentication + * @returns Offers response + */ + private async fetchOffersToPerson(activeDid: string): Promise { + const url = `${this.endorserApiBaseUrl}/offers-to-person`; + const headers = await this.getAuthenticatedHeaders(activeDid); + + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return response.json(); + } + + /** + * Fetch offers to projects with DID-based authentication + * + * @param activeDid - Active DID for authentication + * @returns Offers to plans response + */ + private async fetchOffersToProjects(activeDid: string): Promise { + const url = `${this.endorserApiBaseUrl}/offers-to-plans`; + const headers = await this.getAuthenticatedHeaders(activeDid); + + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return response.json(); + } + + /** + * Fetch project updates with DID-based authentication + * + * @param activeDid - Active DID for authentication + * @param starredPlanIds - Optional list of starred plan IDs + * @returns Plans last updated response + */ + private async fetchProjectUpdates(activeDid: string, starredPlanIds?: string[]): Promise { + const url = `${this.endorserApiBaseUrl}/plans-last-updated`; + const headers = await this.getAuthenticatedHeaders(activeDid); + + // Add starred plan IDs as query parameter if provided + const params = new URLSearchParams(); + if (starredPlanIds && starredPlanIds.length > 0) { + params.append('starred', starredPlanIds.join(',')); + } + + const fullUrl = params.toString() ? `${url}?${params.toString()}` : url; + const response = await fetch(fullUrl, { headers }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return response.json(); + } + + /** + * Get authenticated headers for API requests + * + * @param activeDid - Active DID for authentication + * @returns Headers with authentication + */ + private async getAuthenticatedHeaders(activeDid: string): Promise> { + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }; + + // Add DID-based authentication if Veramo stack is available + if (this.veramoStack) { + try { + // Create JWT token with DID + const token = await this.createDidJwt(activeDid); + headers['Authorization'] = `Bearer ${token}`; + } catch (error) { + // Failed to create DID JWT, continuing without signature + } + } + + return headers; + } + + /** + * Create DID-based JWT token + * + * @param activeDid - Active DID for token creation + * @returns JWT token + */ + private async createDidJwt(activeDid: string): Promise { + if (!this.veramoStack) { + throw new Error('Veramo stack not initialized'); + } + + // This would integrate with the actual Veramo stack + // For now, return a placeholder token + // In a real implementation, this would create a JWT with: + // - iss: activeDid (issuer) + // - sub: activeDid (subject) + // - aud: this.endorserApiBaseUrl (audience) + // - exp: Math.floor(Date.now() / 1000) + 3600 (1 hour expiration) + // - iat: Math.floor(Date.now() / 1000) (issued at) + + return `placeholder-jwt-${activeDid}`; + } + + /** + * Verify DID-signed payload + * + * @param _payload - Payload to verify + * @param _signature - Signature to verify against + * @returns Verification result + */ + async verifyDidSignature(_payload: string, _signature: string): Promise { + if (!this.veramoStack) { + // Veramo stack not available for signature verification + return false; + } + + try { + // This would integrate with Veramo's signature verification + // For now, return true as placeholder + // Verifying DID signature + return true; + } catch (error) { + // Signature verification failed + return false; + } + } + + /** + * Generate sample DID-signed payloads for documentation + * + * @returns Sample payloads with signatures + */ + generateSampleDidPayloads(): DidSignedPayload[] { + const activeDid = this.activeDid || 'did:example:123'; + return [ + { + type: 'notification_content', + payload: { + title: 'Daily Community Update', + body: 'New offers and project updates available', + timestamp: Date.now(), + activeDid: activeDid + }, + signature: 'sample-signature-1', + verificationSteps: [ + 'Extract DID from payload', + 'Resolve DID document', + 'Verify signature with public key', + 'Check payload expiration' + ] + }, + { + type: 'callback_event', + payload: { + eventType: 'notification_delivered', + notificationId: 'notif-123', + timestamp: Date.now(), + activeDid: activeDid + }, + signature: 'sample-signature-2', + verificationSteps: [ + 'Extract DID from payload', + 'Resolve DID document', + 'Verify signature with public key', + 'Validate event type and timestamp' + ] + } + ]; + } + + /** + * Get data retention and redaction policy + * + * @returns Data retention policy + */ + getDataRetentionPolicy(): DataRetentionPolicy { + return { + fields: { + activeDid: { retention: 'session', redaction: 'hash' }, + notificationContent: { retention: '7_days', redaction: 'partial' }, + callbackEvents: { retention: '30_days', redaction: 'full' }, + performanceMetrics: { retention: '90_days', redaction: 'none' } + }, + redactionMethods: { + hash: 'SHA-256 hash of original value', + partial: 'Replace sensitive parts with [REDACTED]', + full: 'Replace entire field with [REDACTED]', + none: 'No redaction applied' + }, + storageLocations: { + session: 'Memory only, cleared on app close', + '7_days': 'Local storage with TTL', + '30_days': 'Local storage with TTL', + '90_days': 'Local storage with TTL' + } + }; + } +} + +/** + * TimeSafari Integration Configuration + */ +export interface TimeSafariIntegrationConfig { + activeDid: string; + veramoStack?: unknown; // Veramo stack instance + storageAdapter: TimeSafariStorageAdapter; + endorserApiBaseUrl?: string; +} + +// Re-export types from definitions +export type { TimeSafariUserConfig, TimeSafariNotificationBundle } from './definitions'; + +/** + * TimeSafari Storage Adapter Interface + */ +export interface TimeSafariStorageAdapter { + store(key: string, value: unknown): Promise; + retrieve(key: string): Promise; + remove(key: string): Promise; + clear(): Promise; +} + +/** + * DID-signed payload for documentation + */ +export interface DidSignedPayload { + type: string; + payload: Record; + signature: string; + verificationSteps: string[]; +} + +/** + * Data retention and redaction policy + */ +export interface DataRetentionPolicy { + fields: Record; + redactionMethods: Record; + storageLocations: Record; +} + +/** + * Field-specific retention policy + */ +export interface FieldRetentionPolicy { + retention: 'session' | '7_days' | '30_days' | '90_days'; + redaction: 'hash' | 'partial' | 'full' | 'none'; +} diff --git a/src/utils/PlatformServiceMixin.ts b/src/utils/PlatformServiceMixin.ts new file mode 100644 index 0000000..e649d76 --- /dev/null +++ b/src/utils/PlatformServiceMixin.ts @@ -0,0 +1,311 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * TimeSafari Platform Service Mixin + * + * TypeScript mixin for TimeSafari Daily Notification Plugin integration. + * Provides type-safe service integration for Vue components and other frameworks. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +import { DailyNotificationService, DailyNotificationServiceConfig } from '../services/DailyNotificationService'; + +/** + * TimeSafari Daily Notification Mixin Interface + */ +export interface TimeSafariDailyNotificationMixin { + // Service instance + dailyNotificationService: DailyNotificationService; + + // Service methods + initializeDailyNotifications(config: DailyNotificationServiceConfig): Promise; + scheduleDailyNotification(options: { + title: string; + body: string; + time: string; + channel?: string; + actions?: Array<{ id: string; title: string }>; + }): Promise; + fetchCommunityData(): Promise; + getServiceStatus(): unknown; + shutdownDailyNotifications(): Promise; +} + +/** + * TimeSafari Daily Notification Mixin Implementation + */ +export class TimeSafariDailyNotificationMixin implements TimeSafariDailyNotificationMixin { + // Service instance + public dailyNotificationService: DailyNotificationService = DailyNotificationService.getInstance(); + + // Reactive state + private _serviceInitialized = false; + private _serviceError: string | null = null; + private _serviceStatus: unknown = null; + + /** + * Initialize daily notification service + */ + async initializeDailyNotifications(config: DailyNotificationServiceConfig): Promise { + try { + await this.dailyNotificationService.initialize(config); + this._serviceInitialized = true; + this._serviceError = null; + this._serviceStatus = this.dailyNotificationService.getStatus(); + + } catch (error) { + this._serviceError = (error as Error).message; + this._serviceInitialized = false; + throw error; + } + } + + /** + * Schedule a daily notification + */ + async scheduleDailyNotification(options: { + title: string; + body: string; + time: string; + channel?: string; + actions?: Array<{ id: string; title: string }>; + }): Promise { + if (!this._serviceInitialized) { + throw new Error('Daily notification service not initialized'); + } + + try { + await this.dailyNotificationService.scheduleDailyNotification(options); + } catch (error) { + this._serviceError = (error as Error).message; + throw error; + } + } + + /** + * Fetch community data + */ + async fetchCommunityData(): Promise { + if (!this._serviceInitialized) { + throw new Error('Daily notification service not initialized'); + } + + try { + const data = await this.dailyNotificationService.fetchCommunityData(); + return data; + } catch (error) { + this._serviceError = (error as Error).message; + throw error; + } + } + + /** + * Get service status + */ + getServiceStatus(): unknown { + if (!this._serviceInitialized) { + return null; + } + + this._serviceStatus = this.dailyNotificationService.getStatus(); + return this._serviceStatus; + } + + /** + * Shutdown daily notification service + */ + async shutdownDailyNotifications(): Promise { + if (!this._serviceInitialized) { + return; + } + + try { + await this.dailyNotificationService.shutdown(); + this._serviceInitialized = false; + this._serviceError = null; + this._serviceStatus = null; + } catch (error) { + this._serviceError = (error as Error).message; + throw error; + } + } + + /** + * Get service ready status + */ + isServiceReady(): boolean { + return this._serviceInitialized; + } + + /** + * Get service status + */ + getServiceStatusData(): unknown { + return this._serviceStatus; + } + + /** + * Get service error + */ + getServiceError(): string | null { + return this._serviceError; + } +} + +/** + * Type-safe decorator for components + */ +export function WithTimeSafariDailyNotifications(Base: T): T { + return class extends Base { + public dailyNotificationService: DailyNotificationService = DailyNotificationService.getInstance(); + private _serviceInitialized = false; + private _serviceError: string | null = null; + private _serviceStatus: unknown = null; + + async initializeDailyNotifications(config: DailyNotificationServiceConfig): Promise { + try { + await this.dailyNotificationService.initialize(config); + this._serviceInitialized = true; + this._serviceError = null; + this._serviceStatus = this.dailyNotificationService.getStatus(); + } catch (error) { + this._serviceError = (error as Error).message; + this._serviceInitialized = false; + throw error; + } + } + + async scheduleDailyNotification(options: { + title: string; + body: string; + time: string; + channel?: string; + actions?: Array<{ id: string; title: string }>; + }): Promise { + if (!this._serviceInitialized) { + throw new Error('Daily notification service not initialized'); + } + + try { + await this.dailyNotificationService.scheduleDailyNotification(options); + } catch (error) { + this._serviceError = (error as Error).message; + throw error; + } + } + + async fetchCommunityData(): Promise { + if (!this._serviceInitialized) { + throw new Error('Daily notification service not initialized'); + } + + try { + const data = await this.dailyNotificationService.fetchCommunityData(); + return data; + } catch (error) { + this._serviceError = (error as Error).message; + throw error; + } + } + + getServiceStatus(): unknown { + if (!this._serviceInitialized) { + return null; + } + + this._serviceStatus = this.dailyNotificationService.getStatus(); + return this._serviceStatus; + } + + async shutdownDailyNotifications(): Promise { + if (!this._serviceInitialized) { + return; + } + + try { + await this.dailyNotificationService.shutdown(); + this._serviceInitialized = false; + this._serviceError = null; + this._serviceStatus = null; + } catch (error) { + this._serviceError = (error as Error).message; + throw error; + } + } + + isServiceReady(): boolean { + return this._serviceInitialized; + } + + getServiceStatusData(): unknown { + return this._serviceStatus; + } + + getServiceError(): string | null { + return this._serviceError; + } + }; +} + +/** + * Constructor type for components + * Note: any[] is required for TypeScript mixin pattern compatibility + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Constructor> = new (...args: any[]) => T; + +/** + * Example component using the mixin + */ +export class TimeSafariDailyNotificationExample extends TimeSafariDailyNotificationMixin { + private activeDid = 'did:example:timesafari-user-123'; + + async initialize(): Promise { + try { + // Initialize the service + await this.initializeDailyNotifications({ + activeDid: this.activeDid, + platform: 'android', + logging: { + level: 'info', + enableStructuredLogging: true, + enableEventIds: true, + enablePerformanceMetrics: true + } + }); + + // Schedule a daily notification + await this.scheduleDailyNotification({ + title: 'TimeSafari Community Update', + body: 'New offers and project updates available', + time: '09:00', + channel: 'timesafari_community_updates' + }); + + } catch (error) { + throw new Error(`Failed to initialize daily notifications: ${error}`); + } + } + + async handleFetchCommunityData(): Promise { + try { + await this.fetchCommunityData(); + // Community data fetched successfully + } catch (error) { + throw new Error(`Failed to fetch community data: ${error}`); + } + } + + get serviceReady(): boolean { + return this.isServiceReady(); + } + + get serviceStatus(): unknown { + return this.getServiceStatusData(); + } + + get serviceError(): string | null { + return this.getServiceError(); + } +} \ No newline at end of file