Files
oO/services/api/src/signals/todoist.ts
alvis c4960d0601 feat(observability): structured logs, W3C trace IDs, Sentry hooks (#18)
- TS: pino + pino-http; every HTTP request log includes traceId from
  W3C traceparent header (generated if absent); forwarded to ml/serving
  on all /score, /generate, /reward, and /api/ml proxy calls
- Python: structlog JSON; FastAPI middleware binds trace_id via
  contextvars so every log line within a request carries it
- Sentry: optional SENTRY_DSN init in both runtimes (no-op if unset)
- Replace all console.* calls across services/api with pino logger
- Update tests to spy on logger instead of console

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 03:37:28 +00:00

117 lines
3.6 KiB
TypeScript

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<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) {
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<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();