docs(refactor): add integration point refactor analysis and implementation plan
Add comprehensive documentation and implementation artifacts for refactoring the plugin to use app-provided content fetchers instead of hardcoded TimeSafari integration. Changes: - Add integration-point-refactor-analysis.md with complete ADR, interfaces, migration plan, and 7-PR breakdown - Add INTEGRATION_REFACTOR_QUICK_START.md for quick reference on new machines - Add src/types/content-fetcher.ts with TypeScript SPI interfaces - Add examples/native-fetcher-android.kt with Kotlin implementation skeleton - Add examples/js-fetcher-typescript.ts with TypeScript implementation skeleton - Add tests/fixtures/test-contract.json for golden contract testing Architecture Decisions: - Dual-path SPI: Native Fetcher (background) + JS Fetcher (foreground only) - Background reliability: Native SPI only, no JS bridging in workers - Reversibility: Legacy code behind feature flag for one minor release - Test contract: Single JSON fixture for both fetcher paths This provides complete specification for implementing the refactor in 7 PRs, starting with SPI shell and progressing through background workers, deduplication, failure policies, and finally legacy code removal. All documentation is self-contained and ready for implementation on any machine.
This commit is contained in:
156
examples/js-fetcher-typescript.ts
Normal file
156
examples/js-fetcher-typescript.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* JavaScript Content Fetcher Implementation (TypeScript)
|
||||
*
|
||||
* Example implementation of JsNotificationContentFetcher for foreground/manual refresh.
|
||||
*
|
||||
* NOTE: This is used ONLY for foreground operations. Background workers use
|
||||
* the Native Fetcher SPI (Kotlin/Swift) for reliability.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { DailyNotification } from '@timesafari/daily-notification-plugin';
|
||||
import type {
|
||||
JsNotificationContentFetcher,
|
||||
FetchContext,
|
||||
NotificationContent
|
||||
} from '@timesafari/daily-notification-plugin';
|
||||
import { TimeSafariAPI } from './timesafari-api'; // Host app's API client
|
||||
|
||||
/**
|
||||
* TimeSafari JavaScript content fetcher
|
||||
*
|
||||
* Implements JsNotificationContentFetcher for foreground/manual refresh.
|
||||
* Background workers use the native Kotlin/Swift implementation.
|
||||
*/
|
||||
class TimeSafariJsFetcher implements JsNotificationContentFetcher {
|
||||
constructor(
|
||||
private api: TimeSafariAPI,
|
||||
private activeDid: string,
|
||||
private starredPlanIds: string[]
|
||||
) {}
|
||||
|
||||
async fetchContent(context: FetchContext): Promise<NotificationContent[]> {
|
||||
try {
|
||||
// 1. Generate JWT for authentication
|
||||
const jwt = await this.api.generateJWT(this.activeDid);
|
||||
|
||||
// 2. Fetch TimeSafari data in parallel
|
||||
const [offersToPerson, offersToPlans, projectUpdates] = await Promise.all([
|
||||
this.api.fetchOffersToPerson(jwt),
|
||||
this.api.fetchOffersToPlans(jwt),
|
||||
this.starredPlanIds.length > 0
|
||||
? this.api.fetchProjectsLastUpdated(this.starredPlanIds, jwt)
|
||||
: Promise.resolve(null)
|
||||
]);
|
||||
|
||||
// 3. Convert to NotificationContent array
|
||||
const contents: NotificationContent[] = [];
|
||||
|
||||
// Convert offers to person
|
||||
if (offersToPerson?.data) {
|
||||
for (const offer of offersToPerson.data) {
|
||||
contents.push({
|
||||
id: `offer_person_${offer.id}`,
|
||||
title: `New Offer: ${offer.title}`,
|
||||
body: offer.description || '',
|
||||
scheduledTime: context.scheduledTime || Date.now() + 3600000,
|
||||
fetchTime: context.fetchTime,
|
||||
mediaUrl: offer.imageUrl,
|
||||
ttlSeconds: 86400, // 24 hours
|
||||
dedupeKey: `offer_person_${offer.id}_${offer.updatedAt}`,
|
||||
priority: 'default',
|
||||
metadata: {
|
||||
offerId: offer.id,
|
||||
issuerDid: offer.issuerDid,
|
||||
source: 'offers_to_person'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert offers to plans
|
||||
if (offersToPlans?.data) {
|
||||
for (const offer of offersToPlans.data) {
|
||||
contents.push({
|
||||
id: `offer_plan_${offer.id}`,
|
||||
title: `Offer for Your Project: ${offer.projectName}`,
|
||||
body: offer.description || '',
|
||||
scheduledTime: context.scheduledTime || Date.now() + 3600000,
|
||||
fetchTime: context.fetchTime,
|
||||
mediaUrl: offer.imageUrl,
|
||||
ttlSeconds: 86400,
|
||||
dedupeKey: `offer_plan_${offer.id}_${offer.updatedAt}`,
|
||||
priority: 'default',
|
||||
metadata: {
|
||||
offerId: offer.id,
|
||||
planHandleId: offer.planHandleId,
|
||||
source: 'offers_to_plans'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert project updates
|
||||
if (projectUpdates?.data) {
|
||||
for (const update of projectUpdates.data) {
|
||||
contents.push({
|
||||
id: `project_${update.planSummary.jwtId}`,
|
||||
title: `${update.planSummary.name} Updated`,
|
||||
body: `New updates for ${update.planSummary.name}`,
|
||||
scheduledTime: context.scheduledTime || Date.now() + 3600000,
|
||||
fetchTime: context.fetchTime,
|
||||
mediaUrl: update.planSummary.image,
|
||||
ttlSeconds: 86400,
|
||||
dedupeKey: `project_${update.planSummary.handleId}_${update.planSummary.jwtId}`,
|
||||
priority: 'default',
|
||||
metadata: {
|
||||
planHandleId: update.planSummary.handleId,
|
||||
jwtId: update.planSummary.jwtId,
|
||||
source: 'project_updates'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return contents;
|
||||
} catch (error) {
|
||||
console.error('TimeSafari fetch failed:', error);
|
||||
// Return empty array - plugin will handle retry based on policy
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register fetcher and configure policy
|
||||
*/
|
||||
export async function setupDailyNotification(
|
||||
api: TimeSafariAPI,
|
||||
activeDid: string,
|
||||
starredPlanIds: string[]
|
||||
): Promise<void> {
|
||||
// Create JS fetcher for foreground operations
|
||||
const jsFetcher = new TimeSafariJsFetcher(api, activeDid, starredPlanIds);
|
||||
DailyNotification.setJsContentFetcher(jsFetcher);
|
||||
|
||||
// Configure scheduling policy
|
||||
await DailyNotification.setPolicy({
|
||||
prefetchWindowMs: 5 * 60 * 1000, // 5 minutes before scheduled time
|
||||
retryBackoff: {
|
||||
minMs: 2000,
|
||||
maxMs: 600000, // 10 minutes max
|
||||
factor: 2,
|
||||
jitterPct: 20
|
||||
},
|
||||
maxBatchSize: 50,
|
||||
dedupeHorizonMs: 24 * 60 * 60 * 1000, // 24 hours
|
||||
cacheTtlSeconds: 6 * 60 * 60, // 6 hours default
|
||||
exactAlarmsAllowed: true // If app has permission
|
||||
});
|
||||
|
||||
// Enable native fetcher (required for background workers)
|
||||
await DailyNotification.enableNativeFetcher(true);
|
||||
}
|
||||
|
||||
153
examples/native-fetcher-android.kt
Normal file
153
examples/native-fetcher-android.kt
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* TimeSafari Native Fetcher Implementation (Android/Kotlin)
|
||||
*
|
||||
* Example implementation of NativeNotificationContentFetcher for Android.
|
||||
* This runs in background workers and does NOT require JavaScript bridge.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.notification
|
||||
|
||||
import com.timesafari.dailynotification.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* TimeSafari native content fetcher
|
||||
*
|
||||
* Implements the NativeNotificationContentFetcher SPI to provide
|
||||
* TimeSafari-specific notification content fetching for background workers.
|
||||
*/
|
||||
class TimeSafariNativeFetcher(
|
||||
private val api: TimeSafariApi,
|
||||
private val tokenProvider: TokenProvider,
|
||||
private val starredPlanIds: List<String>
|
||||
) : NativeNotificationContentFetcher {
|
||||
|
||||
override suspend fun fetchContent(context: FetchContext): List<NotificationContent> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// 1. Get fresh authentication token
|
||||
val jwt = tokenProvider.freshToken()
|
||||
|
||||
// 2. Fetch TimeSafari data in parallel
|
||||
val offersToPerson = async { api.fetchOffersToPerson(jwt) }
|
||||
val offersToPlans = async { api.fetchOffersToPlans(jwt) }
|
||||
val projectUpdates = async {
|
||||
if (starredPlanIds.isNotEmpty()) {
|
||||
api.fetchProjectsLastUpdated(starredPlanIds, jwt)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all requests
|
||||
val offersPersonResult = offersToPerson.await()
|
||||
val offersPlansResult = offersToPlans.await()
|
||||
val updatesResult = projectUpdates.await()
|
||||
|
||||
// 3. Convert to NotificationContent list
|
||||
buildList {
|
||||
// Add offers to person
|
||||
offersPersonResult?.data?.forEach { offer ->
|
||||
add(
|
||||
NotificationContent(
|
||||
id = "offer_person_${offer.id}",
|
||||
title = "New Offer: ${offer.title}",
|
||||
body = offer.description ?: "",
|
||||
scheduledTime = context.scheduledTime
|
||||
?: (System.currentTimeMillis() + 3_600_000),
|
||||
fetchTime = context.fetchTime,
|
||||
mediaUrl = offer.imageUrl,
|
||||
ttlSeconds = 86_400, // 24 hours
|
||||
dedupeKey = "offer_person_${offer.id}_${offer.updatedAt}",
|
||||
priority = "default",
|
||||
metadata = mapOf(
|
||||
"offerId" to offer.id,
|
||||
"issuerDid" to offer.issuerDid,
|
||||
"source" to "offers_to_person"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Add offers to plans
|
||||
offersPlansResult?.data?.forEach { offer ->
|
||||
add(
|
||||
NotificationContent(
|
||||
id = "offer_plan_${offer.id}",
|
||||
title = "Offer for Your Project: ${offer.projectName}",
|
||||
body = offer.description ?: "",
|
||||
scheduledTime = context.scheduledTime
|
||||
?: (System.currentTimeMillis() + 3_600_000),
|
||||
fetchTime = context.fetchTime,
|
||||
mediaUrl = offer.imageUrl,
|
||||
ttlSeconds = 86_400,
|
||||
dedupeKey = "offer_plan_${offer.id}_${offer.updatedAt}",
|
||||
priority = "default",
|
||||
metadata = mapOf(
|
||||
"offerId" to offer.id,
|
||||
"planHandleId" to offer.planHandleId,
|
||||
"source" to "offers_to_plans"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Add project updates
|
||||
updatesResult?.data?.forEach { update ->
|
||||
add(
|
||||
NotificationContent(
|
||||
id = "project_${update.planSummary.jwtId}",
|
||||
title = "${update.planSummary.name} Updated",
|
||||
body = "New updates for ${update.planSummary.name}",
|
||||
scheduledTime = context.scheduledTime
|
||||
?: (System.currentTimeMillis() + 3_600_000),
|
||||
fetchTime = context.fetchTime,
|
||||
mediaUrl = update.planSummary.image,
|
||||
ttlSeconds = 86_400,
|
||||
dedupeKey = "project_${update.planSummary.handleId}_${update.planSummary.jwtId}",
|
||||
priority = "default",
|
||||
metadata = mapOf(
|
||||
"planHandleId" to update.planSummary.handleId,
|
||||
"jwtId" to update.planSummary.jwtId,
|
||||
"source" to "project_updates"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Log error and return empty list
|
||||
// Plugin will handle retry based on SchedulingPolicy
|
||||
android.util.Log.e("TimeSafariFetcher", "Fetch failed", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration in Application.onCreate()
|
||||
*/
|
||||
class TimeSafariApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Register native fetcher
|
||||
val api = TimeSafariApiClient(context = this)
|
||||
val tokenProvider = JWTokenProvider(activeDid = getActiveDid())
|
||||
val starredPlanIds = getStarredPlanIds()
|
||||
|
||||
val fetcher = TimeSafariNativeFetcher(
|
||||
api = api,
|
||||
tokenProvider = tokenProvider,
|
||||
starredPlanIds = starredPlanIds
|
||||
)
|
||||
|
||||
DailyNotification.setNativeFetcher(fetcher)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user