Browse Source

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.
master
Matthew Raymer 2 days ago
parent
commit
0e8986d3cc
  1. 108
      test-apps/daily-notification-test/src/lib/bridge.ts
  2. 161
      test-apps/daily-notification-test/src/lib/error-handling.ts
  3. 200
      test-apps/daily-notification-test/src/lib/schema-validation.ts
  4. 259
      test-apps/daily-notification-test/src/lib/typed-plugin.ts
  5. 31
      test-apps/daily-notification-test/src/views/StatusView.vue

108
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<ScheduleResponse>
checkPermissions(): Promise<PermissionStatus>
getNotificationStatus(): Promise<NotificationStatus>
getExactAlarmStatus(): Promise<ExactAlarmStatus>
requestPermissions(): Promise<PermissionResult>
openExactAlarmSettings(): Promise<void>
openChannelSettings(): Promise<void>
requestBatteryOptimizationExemption(): Promise<void>
cancelAllNotifications(): Promise<void>
getLastNotification(): Promise<NotificationContent | null>
}
// 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'

161
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)
}

200
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)
}

259
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<ScheduleResponse> {
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<PermissionStatus> {
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<NotificationStatus> {
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<ExactAlarmStatus> {
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<PermissionResult> {
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<void> {
try {
await this.plugin.openExactAlarmSettings()
} catch (error) {
logError(error, 'openExactAlarmSettings')
throw error
}
}
/**
* Open channel settings
*/
async openChannelSettings(): Promise<void> {
try {
await this.plugin.openChannelSettings()
} catch (error) {
logError(error, 'openChannelSettings')
throw error
}
}
/**
* Request battery optimization exemption
*/
async requestBatteryOptimizationExemption(): Promise<void> {
try {
await this.plugin.requestBatteryOptimizationExemption()
} catch (error) {
logError(error, 'requestBatteryOptimizationExemption')
throw error
}
}
/**
* Cancel all notifications
*/
async cancelAllNotifications(): Promise<void> {
try {
await this.plugin.cancelAllNotifications()
} catch (error) {
logError(error, 'cancelAllNotifications')
throw error
}
}
/**
* Get last notification
*/
async getLastNotification(): Promise<NotificationContent | null> {
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<TypedDailyNotificationPlugin | null> {
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'

31
test-apps/daily-notification-test/src/views/StatusView.vue

@ -88,6 +88,7 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator' import { Vue, Component, toNative } from 'vue-facing-decorator'
import StatusCard from '../components/cards/StatusCard.vue' import StatusCard from '../components/cards/StatusCard.vue'
import { createTypedPlugin, type PermissionStatus, type NotificationStatus, type ExactAlarmStatus } from '../lib/typed-plugin'
interface StatusItem { interface StatusItem {
key: string key: string
@ -213,19 +214,18 @@ class StatusView extends Vue {
try { try {
console.log('🔄 Refreshing status matrix...') console.log('🔄 Refreshing status matrix...')
// Import the plugin dynamically // Create typed plugin instance
const { DailyNotification } = await import('@timesafari/daily-notification-plugin') const typedPlugin = await createTypedPlugin()
const plugin = DailyNotification
if (!plugin) { if (!typedPlugin) {
throw new Error('DailyNotification plugin not available') throw new Error('DailyNotification plugin not available')
} }
// Collect comprehensive status // Collect comprehensive status with type safety
const [notificationStatus, permissions, exactAlarmStatus] = await Promise.all([ const [notificationStatus, permissions, exactAlarmStatus] = await Promise.all([
plugin.getNotificationStatus().catch(() => ({ isEnabled: false, isScheduled: false })), typedPlugin.getNotificationStatus(),
plugin.checkPermissions().catch(() => ({ notifications: 'denied' })), typedPlugin.checkPermissions(),
plugin.getExactAlarmStatus().catch(() => ({ enabled: false, supported: false })) typedPlugin.getExactAlarmStatus()
]) ])
// Update diagnostics // Update diagnostics
@ -262,21 +262,24 @@ class StatusView extends Vue {
try { try {
console.log(`🔧 Executing action: ${action.method}`) console.log(`🔧 Executing action: ${action.method}`)
const { DailyNotification } = await import('@timesafari/daily-notification-plugin') const typedPlugin = await createTypedPlugin()
const plugin = DailyNotification
if (!typedPlugin) {
throw new Error('Plugin not available')
}
switch (action.method) { switch (action.method) {
case 'requestPermissions': case 'requestPermissions':
await plugin.requestPermissions() await typedPlugin.requestPermissions()
break break
case 'openExactAlarmSettings': case 'openExactAlarmSettings':
await plugin.openExactAlarmSettings() await typedPlugin.openExactAlarmSettings()
break break
case 'openChannelSettings': case 'openChannelSettings':
await plugin.openChannelSettings() await typedPlugin.openChannelSettings()
break break
case 'requestBatteryOptimizationExemption': case 'requestBatteryOptimizationExemption':
await plugin.requestBatteryOptimizationExemption() await typedPlugin.requestBatteryOptimizationExemption()
break break
case 'checkPrerequisites': case 'checkPrerequisites':
await this.refreshStatus() await this.refreshStatus()

Loading…
Cancel
Save