feat: SignalSource abstraction — generalize signal ingestion beyond Todoist (#78)
- Add Signal + SignalSource interfaces to packages/shared-types - TipCandidate.features widened to Record<string,number|boolean> to match Signal - TodoistSignalSource: encapsulates fetch, cache, 401 handling, bus events, and act() - SignalAggregator: parallel fan-out across sources with per-source failure isolation - Recommender refactored to consume Signal[] via aggregator; source action dispatch via aggregator.act() - ADR-0009: signal normalization strategy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
39
services/api/src/signals/aggregator.ts
Normal file
39
services/api/src/signals/aggregator.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Signal, SignalSource } from '@oo/shared-types';
|
||||
|
||||
/**
|
||||
* Merges signals from all registered sources for a user.
|
||||
* Sources run in parallel; failures are isolated — a broken source
|
||||
* does not prevent other sources from contributing.
|
||||
*/
|
||||
export class SignalAggregator {
|
||||
private sources: SignalSource[] = [];
|
||||
|
||||
register(source: SignalSource): this {
|
||||
this.sources.push(source);
|
||||
return this;
|
||||
}
|
||||
|
||||
async fetchAll(userId: string): Promise<Signal[]> {
|
||||
const results = await Promise.allSettled(
|
||||
this.sources.map((s) => s.fetchSignals(userId)),
|
||||
);
|
||||
|
||||
const signals: Signal[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i];
|
||||
if (r.status === 'fulfilled') {
|
||||
signals.push(...r.value);
|
||||
} else {
|
||||
console.error(`[aggregator] source '${this.sources[i].id}' failed:`, r.reason);
|
||||
}
|
||||
}
|
||||
return signals;
|
||||
}
|
||||
|
||||
/** Dispatch an action to whichever source owns the signal */
|
||||
async act(userId: string, signalId: string, action: string): Promise<void> {
|
||||
const sourceId = signalId.split(':')[0];
|
||||
const source = this.sources.find((s) => s.id === sourceId);
|
||||
await source?.act?.(userId, signalId, action);
|
||||
}
|
||||
}
|
||||
115
services/api/src/signals/todoist.ts
Normal file
115
services/api/src/signals/todoist.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Signal, SignalSource } from '@oo/shared-types';
|
||||
import { db } from '../db/index.js';
|
||||
import { integrationTokens } from '../db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { bus } from '../events/bus.js';
|
||||
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
|
||||
export function dueAgeDays(due: { date?: string; datetime?: string } | null | undefined): number {
|
||||
if (!due) return 0;
|
||||
const dateStr = due.datetime ?? due.date;
|
||||
if (!dateStr) return 0;
|
||||
return Math.max(0, (Date.now() - new Date(dateStr).getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
export class TodoistSignalSource implements SignalSource {
|
||||
readonly id = 'todoist';
|
||||
|
||||
private cache = new Map<string, { signals: Signal[]; fetchedAt: number }>();
|
||||
|
||||
/** Exposed for tests */
|
||||
clearCache(userId?: string): void {
|
||||
if (userId) this.cache.delete(userId);
|
||||
else this.cache.clear();
|
||||
}
|
||||
|
||||
getCached(userId: string): Signal[] {
|
||||
return this.cache.get(userId)?.signals ?? [];
|
||||
}
|
||||
|
||||
async fetchSignals(userId: string): Promise<Signal[]> {
|
||||
const entry = this.cache.get(userId);
|
||||
if (entry && Date.now() - entry.fetchedAt < CACHE_TTL_MS) return entry.signals;
|
||||
|
||||
const [token] = await db
|
||||
.select()
|
||||
.from(integrationTokens)
|
||||
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'todoist')))
|
||||
.limit(1);
|
||||
|
||||
if (!token) return [];
|
||||
|
||||
const res = await fetch('https://api.todoist.com/api/v1/tasks?filter=today%7Coverdue', {
|
||||
headers: { Authorization: `Bearer ${token.accessToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
console.error(`[todoist] token expired for user ${userId}`);
|
||||
bus.publish('signals.integration.token_expired', {
|
||||
userId,
|
||||
provider: 'todoist',
|
||||
detectedAt: new Date().toISOString(),
|
||||
});
|
||||
await db
|
||||
.update(integrationTokens)
|
||||
.set({ tokenStatus: 'needs_reconnect' })
|
||||
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'todoist')));
|
||||
}
|
||||
return entry?.signals ?? [];
|
||||
}
|
||||
|
||||
const body = (await res.json()) as {
|
||||
results: Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
priority: number;
|
||||
due: { date?: string; datetime?: string; is_recurring?: boolean } | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const signals: Signal[] = (body.results ?? []).map((t) => {
|
||||
const ageDays = dueAgeDays(t.due);
|
||||
return {
|
||||
id: `todoist:${t.id}`,
|
||||
source: 'todoist',
|
||||
kind: 'task',
|
||||
content: t.content,
|
||||
metadata: { todoistId: t.id, due: t.due, priority: t.priority },
|
||||
features: {
|
||||
is_overdue: ageDays > 0,
|
||||
task_age_days: ageDays,
|
||||
priority: t.priority ?? 1,
|
||||
},
|
||||
timestamp: now,
|
||||
};
|
||||
});
|
||||
|
||||
this.cache.set(userId, { signals, fetchedAt: Date.now() });
|
||||
bus.publish('signals.task.synced', { userId, count: signals.length, syncedAt: now });
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
async act(userId: string, signalId: string, action: string): Promise<void> {
|
||||
if (action !== 'done' || !signalId.startsWith('todoist:')) return;
|
||||
const todoistId = signalId.slice(8);
|
||||
|
||||
const [tok] = await db
|
||||
.select()
|
||||
.from(integrationTokens)
|
||||
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'todoist')))
|
||||
.limit(1);
|
||||
|
||||
if (!tok) return;
|
||||
|
||||
await fetch(`https://api.todoist.com/api/v1/tasks/${todoistId}/close`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${tok.accessToken}` },
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export const todoistSource = new TodoistSignalSource();
|
||||
Reference in New Issue
Block a user