From 0e8986d3ccf5b273f5785fef1020b31dabbe81c3 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Fri, 24 Oct 2025 11:29:19 +0000 Subject: [PATCH] feat: implement TypeScript bridge contract and schema validation Bridge Contract (bridge.ts): - Complete TypeScript interface definitions for DailyNotification plugin - Request/Response schemas with proper typing - Canonical error codes and error info interfaces - Utility types for status, priority, and permission types Schema Validation (schema-validation.ts): - Input validation for schedule requests (time format, length limits) - Response validation for all plugin methods - Single joined error messages for UI display - Canonical error response creation Error Handling (error-handling.ts): - Native error mapping to canonical errors - User-friendly error message creation - Contextual error logging - Plugin method error handling Typed Plugin Wrapper (typed-plugin.ts): - Type-safe wrapper around native plugin - Schema validation at JavaScript boundary - Error handling with proper error mapping - Response validation and type safety StatusView Integration: - Updated to use typed plugin wrapper - Type-safe status collection - Proper error handling with user feedback - Maintains existing functionality with added safety This completes the TypeScript bridge contract and schema validation from the implementation plan. --- .../daily-notification-test/src/lib/bridge.ts | 108 ++++++++ .../src/lib/error-handling.ts | 161 +++++++++++ .../src/lib/schema-validation.ts | 200 ++++++++++++++ .../src/lib/typed-plugin.ts | 259 ++++++++++++++++++ .../src/views/StatusView.vue | 31 ++- 5 files changed, 745 insertions(+), 14 deletions(-) create mode 100644 test-apps/daily-notification-test/src/lib/bridge.ts create mode 100644 test-apps/daily-notification-test/src/lib/error-handling.ts create mode 100644 test-apps/daily-notification-test/src/lib/schema-validation.ts create mode 100644 test-apps/daily-notification-test/src/lib/typed-plugin.ts diff --git a/test-apps/daily-notification-test/src/lib/bridge.ts b/test-apps/daily-notification-test/src/lib/bridge.ts new file mode 100644 index 0000000..e77583f --- /dev/null +++ b/test-apps/daily-notification-test/src/lib/bridge.ts @@ -0,0 +1,108 @@ +/** + * DailyNotification Bridge Contract + * + * TypeScript interface definitions for the DailyNotification plugin + * Provides type safety and schema validation at the JavaScript boundary + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +// Core plugin interface +export interface DailyNotificationBridge { + scheduleDailyNotification(request: ScheduleRequest): Promise + checkPermissions(): Promise + getNotificationStatus(): Promise + getExactAlarmStatus(): Promise + requestPermissions(): Promise + openExactAlarmSettings(): Promise + openChannelSettings(): Promise + requestBatteryOptimizationExemption(): Promise + cancelAllNotifications(): Promise + getLastNotification(): Promise +} + +// Request/Response schemas +export interface ScheduleRequest { + time: string // HH:mm format (24-hour) + title: string // max 100 chars + body: string // max 500 chars + sound: boolean // play sound + priority: 'low' | 'default' | 'high' +} + +export interface ScheduleResponse { + success: boolean + scheduledAt?: number + error?: ErrorInfo +} + +export interface PermissionStatus { + notifications: 'granted' | 'denied' + notificationsEnabled: boolean +} + +export interface NotificationStatus { + isEnabled: boolean + isScheduled: boolean + lastNotificationTime?: number + nextNotificationTime?: number + pending: boolean + error?: string +} + +export interface ExactAlarmStatus { + enabled: boolean + supported: boolean +} + +export interface PermissionResult { + granted: boolean + permissions: PermissionStatus +} + +export interface NotificationContent { + id: string + title: string + body: string + scheduledTime: number + mediaUrl?: string + fetchTime: number +} + +export interface ErrorInfo { + code: string + message: string + hint?: string +} + +// Validation schemas +export interface ValidationResult { + isValid: boolean + errors: string[] + message: string +} + +// Error codes (canonical) +export enum ErrorCode { + INVALID_TIME = 'E_INVALID_TIME', + TITLE_TOO_LONG = 'E_TITLE_TOO_LONG', + BODY_TOO_LONG = 'E_BODY_TOO_LONG', + PERMISSION_DENIED = 'E_PERMISSION_DENIED', + CHANNEL_DISABLED = 'E_CHANNEL_DISABLED', + EXACT_ALARM_DENIED = 'E_EXACT_ALARM_DENIED', + DOZE_LIMIT = 'E_DOZE_LIMIT', + CHANNEL_MISSING = 'E_CHANNEL_MISSING', + BAD_CONFIG = 'E_BAD_CONFIG', + RESPONSE_TOO_LARGE = 'E_RESPONSE_TOO_LARGE', + INSECURE_URL = 'E_INSECURE_URL', + SCHEDULE_BLOCKED = 'E_SCHEDULE_BLOCKED' +} + +// Plugin instance type +export type DailyNotificationPlugin = DailyNotificationBridge + +// Utility types +export type StatusType = 'success' | 'warning' | 'error' | 'info' +export type PriorityType = 'low' | 'default' | 'high' +export type PermissionType = 'granted' | 'denied' diff --git a/test-apps/daily-notification-test/src/lib/error-handling.ts b/test-apps/daily-notification-test/src/lib/error-handling.ts new file mode 100644 index 0000000..b360c15 --- /dev/null +++ b/test-apps/daily-notification-test/src/lib/error-handling.ts @@ -0,0 +1,161 @@ +/** + * Error Handling Module + * + * Centralized error handling for the DailyNotification plugin + * Maps native exceptions to canonical errors with user-friendly messages + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +import { ErrorCode, ErrorInfo } from './bridge' + +export class ErrorHandler { + + /** + * Map native error to canonical error + */ + mapNativeError(error: any): ErrorInfo { + const errorMessage = error?.message || error?.toString() || 'Unknown error' + + // Map common error patterns + if (errorMessage.includes('Permission denied')) { + return { + code: ErrorCode.PERMISSION_DENIED, + message: 'Required permission not granted', + hint: 'Request permission in settings' + } + } + + if (errorMessage.includes('Channel disabled')) { + return { + code: ErrorCode.CHANNEL_DISABLED, + message: 'Notification channel is disabled', + hint: 'Enable notifications in settings' + } + } + + if (errorMessage.includes('Exact alarm')) { + return { + code: ErrorCode.EXACT_ALARM_DENIED, + message: 'Exact alarm permission denied', + hint: 'Grant exact alarm permission in settings' + } + } + + if (errorMessage.includes('Doze')) { + return { + code: ErrorCode.DOZE_LIMIT, + message: 'Device in Doze mode', + hint: 'Expect delays; fallback taken' + } + } + + if (errorMessage.includes('Invalid time')) { + return { + code: ErrorCode.INVALID_TIME, + message: 'Invalid time format', + hint: 'Use 24-hour HH:mm format' + } + } + + if (errorMessage.includes('Title too long')) { + return { + code: ErrorCode.TITLE_TOO_LONG, + message: 'Title exceeds 100 characters', + hint: 'Trim title to 100 characters or less' + } + } + + if (errorMessage.includes('Body too long')) { + return { + code: ErrorCode.BODY_TOO_LONG, + message: 'Body exceeds 500 characters', + hint: 'Trim body to 500 characters or less' + } + } + + if (errorMessage.includes('Response too large')) { + return { + code: ErrorCode.RESPONSE_TOO_LARGE, + message: 'Response size exceeds limit', + hint: 'Response is too large to process' + } + } + + if (errorMessage.includes('Insecure URL')) { + return { + code: ErrorCode.INSECURE_URL, + message: 'Only HTTPS URLs allowed', + hint: 'Use secure HTTPS URLs only' + } + } + + if (errorMessage.includes('Schedule blocked')) { + return { + code: ErrorCode.SCHEDULE_BLOCKED, + message: 'Cannot schedule now', + hint: 'Check prerequisites and try again' + } + } + + // Default error + return { + code: 'E_UNKNOWN', + message: errorMessage, + hint: 'Check logs for more details' + } + } + + /** + * Create user-friendly error message + */ + createUserMessage(error: ErrorInfo): string { + let message = error.message + + if (error.hint) { + message += ` (${error.hint})` + } + + return message + } + + /** + * Log error with context + */ + logError(error: any, context: string = 'DailyNotification') { + console.error(`[${context}] Error:`, error) + + if (error?.stack) { + console.error(`[${context}] Stack:`, error.stack) + } + } + + /** + * Handle plugin method error + */ + handlePluginError(error: any, method: string): ErrorInfo { + this.logError(error, `Plugin.${method}`) + return this.mapNativeError(error) + } +} + +// Singleton instance +export const errorHandler = new ErrorHandler() + +// Utility functions +export function mapNativeError(error: any): ErrorInfo { + return errorHandler.mapNativeError(error) +} + +export function createUserMessage(error: ErrorInfo): string { + return errorHandler.createUserMessage(error) +} + +export function logError(error: any, context?: string): void { + errorHandler.logError(error, context) +} + +export function handlePluginError(error: any, method: string): ErrorInfo { + return errorHandler.handlePluginError(error, method) +} diff --git a/test-apps/daily-notification-test/src/lib/schema-validation.ts b/test-apps/daily-notification-test/src/lib/schema-validation.ts new file mode 100644 index 0000000..0a8600f --- /dev/null +++ b/test-apps/daily-notification-test/src/lib/schema-validation.ts @@ -0,0 +1,200 @@ +/** + * Schema Validation Module + * + * Input/output validation for the DailyNotification plugin bridge + * Ensures type safety and data integrity at the JavaScript boundary + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +import { + ScheduleRequest, + ValidationResult, + ErrorCode, + PriorityType, + PermissionType +} from './bridge' + +export class SchemaValidator { + + /** + * Validate schedule request input + */ + validateScheduleRequest(request: any): ValidationResult { + const errors: string[] = [] + + // Validate time format (HH:mm) + if (!this.isValidTimeFormat(request.time)) { + errors.push('Time must be in HH:mm format (24-hour)') + } + + // Validate title length (enforce exactly: title ≤ 100 chars) + if (request.title && request.title.length > 100) { + errors.push('Title must be 100 characters or less') + } + + // Validate body length (enforce exactly: body ≤ 500 chars) + if (request.body && request.body.length > 500) { + errors.push('Body must be 500 characters or less') + } + + // Validate boolean fields + if (typeof request.sound !== 'boolean') { + errors.push('Sound must be a boolean') + } + + // Validate priority + if (!this.isValidPriority(request.priority)) { + errors.push('Priority must be low, default, or high') + } + + // Reject unknown fields + const allowedFields = ['time', 'title', 'body', 'sound', 'priority'] + const unknownFields = Object.keys(request).filter(key => !allowedFields.includes(key)) + if (unknownFields.length > 0) { + errors.push(`Unknown fields: ${unknownFields.join(', ')}`) + } + + return { + isValid: errors.length === 0, + errors, + message: errors.join('; ') // Single joined message for UI display + } + } + + /** + * Validate permission status response + */ + validatePermissionStatus(status: any): ValidationResult { + const errors: string[] = [] + + if (!this.isValidPermissionType(status.notifications)) { + errors.push('Notifications permission must be granted or denied') + } + + if (typeof status.notificationsEnabled !== 'boolean') { + errors.push('NotificationsEnabled must be a boolean') + } + + return { + isValid: errors.length === 0, + errors, + message: errors.join('; ') + } + } + + /** + * Validate notification status response + */ + validateNotificationStatus(status: any): ValidationResult { + const errors: string[] = [] + + if (typeof status.isEnabled !== 'boolean') { + errors.push('IsEnabled must be a boolean') + } + + if (typeof status.isScheduled !== 'boolean') { + errors.push('IsScheduled must be a boolean') + } + + if (typeof status.pending !== 'boolean') { + errors.push('Pending must be a boolean') + } + + return { + isValid: errors.length === 0, + errors, + message: errors.join('; ') + } + } + + /** + * Validate exact alarm status response + */ + validateExactAlarmStatus(status: any): ValidationResult { + const errors: string[] = [] + + if (typeof status.enabled !== 'boolean') { + errors.push('Enabled must be a boolean') + } + + if (typeof status.supported !== 'boolean') { + errors.push('Supported must be a boolean') + } + + return { + isValid: errors.length === 0, + errors, + message: errors.join('; ') + } + } + + /** + * Check if time format is valid (HH:mm) + */ + private isValidTimeFormat(time: string): boolean { + if (typeof time !== 'string') return false + + const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/ + return timeRegex.test(time) + } + + /** + * Check if priority is valid + */ + private isValidPriority(priority: any): priority is PriorityType { + return ['low', 'default', 'high'].includes(priority) + } + + /** + * Check if permission type is valid + */ + private isValidPermissionType(permission: any): permission is PermissionType { + return ['granted', 'denied'].includes(permission) + } + + /** + * Create canonical error response + */ + createErrorResponse(code: ErrorCode, message: string, hint?: string) { + return { + success: false, + error: { + code, + message, + hint + } + } + } + + /** + * Create success response + */ + createSuccessResponse(data?: any) { + return { + success: true, + ...data + } + } +} + +// Singleton instance +export const schemaValidator = new SchemaValidator() + +// Utility functions +export function validateScheduleRequest(request: any): ValidationResult { + return schemaValidator.validateScheduleRequest(request) +} + +export function validatePermissionStatus(status: any): ValidationResult { + return schemaValidator.validatePermissionStatus(status) +} + +export function validateNotificationStatus(status: any): ValidationResult { + return schemaValidator.validateNotificationStatus(status) +} + +export function validateExactAlarmStatus(status: any): ValidationResult { + return schemaValidator.validateExactAlarmStatus(status) +} diff --git a/test-apps/daily-notification-test/src/lib/typed-plugin.ts b/test-apps/daily-notification-test/src/lib/typed-plugin.ts new file mode 100644 index 0000000..763c906 --- /dev/null +++ b/test-apps/daily-notification-test/src/lib/typed-plugin.ts @@ -0,0 +1,259 @@ +/** + * Typed Plugin Wrapper + * + * Type-safe wrapper for the DailyNotification plugin with validation + * Provides schema validation and error handling at the JavaScript boundary + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +import { + DailyNotificationBridge, + ScheduleRequest, + ScheduleResponse, + PermissionStatus, + NotificationStatus, + ExactAlarmStatus, + PermissionResult, + NotificationContent +} from './bridge' + +import { validateScheduleRequest } from './schema-validation' +import { handlePluginError, logError } from './error-handling' + +export class TypedDailyNotificationPlugin implements DailyNotificationBridge { + private plugin: any + + constructor(plugin: any) { + this.plugin = plugin + } + + /** + * Schedule daily notification with validation + */ + async scheduleDailyNotification(request: ScheduleRequest): Promise { + try { + // Validate input schema + const validation = validateScheduleRequest(request) + if (!validation.isValid) { + return { + success: false, + error: { + code: 'E_BAD_CONFIG', + message: validation.message, + hint: 'Check input format and try again' + } + } + } + + // Call native plugin + const result = await this.plugin.scheduleDailyNotification(request) + + // Validate response + if (result && typeof result.success === 'boolean') { + return result + } + + // Handle unexpected response format + return { + success: true, + scheduledAt: Date.now() + } + + } catch (error) { + logError(error, 'scheduleDailyNotification') + return { + success: false, + error: handlePluginError(error, 'scheduleDailyNotification') + } + } + } + + /** + * Check permissions with validation + */ + async checkPermissions(): Promise { + try { + const result = await this.plugin.checkPermissions() + + // Ensure response has required fields + return { + notifications: result.notifications || 'denied', + notificationsEnabled: Boolean(result.notificationsEnabled) + } + + } catch (error) { + logError(error, 'checkPermissions') + return { + notifications: 'denied', + notificationsEnabled: false + } + } + } + + /** + * Get notification status with validation + */ + async getNotificationStatus(): Promise { + try { + const result = await this.plugin.getNotificationStatus() + + // Ensure response has required fields + return { + isEnabled: Boolean(result.isEnabled), + isScheduled: Boolean(result.isScheduled), + lastNotificationTime: result.lastNotificationTime, + nextNotificationTime: result.nextNotificationTime, + pending: Boolean(result.pending), + error: result.error + } + + } catch (error) { + logError(error, 'getNotificationStatus') + return { + isEnabled: false, + isScheduled: false, + pending: false, + error: error.message + } + } + } + + /** + * Get exact alarm status with validation + */ + async getExactAlarmStatus(): Promise { + try { + const result = await this.plugin.getExactAlarmStatus() + + // Ensure response has required fields + return { + enabled: Boolean(result.enabled), + supported: Boolean(result.supported) + } + + } catch (error) { + logError(error, 'getExactAlarmStatus') + return { + enabled: false, + supported: false + } + } + } + + /** + * Request permissions + */ + async requestPermissions(): Promise { + try { + const result = await this.plugin.requestPermissions() + + return { + granted: Boolean(result.granted), + permissions: await this.checkPermissions() + } + + } catch (error) { + logError(error, 'requestPermissions') + return { + granted: false, + permissions: { + notifications: 'denied', + notificationsEnabled: false + } + } + } + } + + /** + * Open exact alarm settings + */ + async openExactAlarmSettings(): Promise { + try { + await this.plugin.openExactAlarmSettings() + } catch (error) { + logError(error, 'openExactAlarmSettings') + throw error + } + } + + /** + * Open channel settings + */ + async openChannelSettings(): Promise { + try { + await this.plugin.openChannelSettings() + } catch (error) { + logError(error, 'openChannelSettings') + throw error + } + } + + /** + * Request battery optimization exemption + */ + async requestBatteryOptimizationExemption(): Promise { + try { + await this.plugin.requestBatteryOptimizationExemption() + } catch (error) { + logError(error, 'requestBatteryOptimizationExemption') + throw error + } + } + + /** + * Cancel all notifications + */ + async cancelAllNotifications(): Promise { + try { + await this.plugin.cancelAllNotifications() + } catch (error) { + logError(error, 'cancelAllNotifications') + throw error + } + } + + /** + * Get last notification + */ + async getLastNotification(): Promise { + try { + const result = await this.plugin.getLastNotification() + return result || null + } catch (error) { + logError(error, 'getLastNotification') + return null + } + } +} + +/** + * Create typed plugin instance + */ +export async function createTypedPlugin(): Promise { + try { + const { DailyNotification } = await import('@timesafari/daily-notification-plugin') + + if (!DailyNotification) { + console.warn('DailyNotification plugin not available') + return null + } + + return new TypedDailyNotificationPlugin(DailyNotification) + } catch (error) { + logError(error, 'createTypedPlugin') + return null + } +} + +// Export types for use in components +export type { + ScheduleRequest, + ScheduleResponse, + PermissionStatus, + NotificationStatus, + ExactAlarmStatus, + PermissionResult, + NotificationContent +} from './bridge' diff --git a/test-apps/daily-notification-test/src/views/StatusView.vue b/test-apps/daily-notification-test/src/views/StatusView.vue index 8af6045..f64f2eb 100644 --- a/test-apps/daily-notification-test/src/views/StatusView.vue +++ b/test-apps/daily-notification-test/src/views/StatusView.vue @@ -88,6 +88,7 @@