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'; import { logger } from '../logger.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(); /** 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 { 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) { logger.warn({ userId }, 'todoist: token expired'); 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, source: 'todoist', count: signals.length, syncedAt: now }); return signals; } async act(userId: string, signalId: string, action: string): Promise { 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();