import type { NormalizedDelivery, NoteProvenance } from '../types/integration.js'; import type { NoteInput } from '../types/domain.js'; export class NormalizationError extends Error { constructor(message: string) { super(message); this.name = 'NormalizationError'; } } const asRecord = (value: unknown): Record => { if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new NormalizationError('Payload is not an object'); } return value as Record; }; const asString = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined; /** notes.id is VARCHAR(64), so composite keys have to be trimmed to fit. */ const noteId = (provider: string, externalId: string): string => `${provider}_${externalId}`.slice(0, 64); const provenance = ( meta: Omit ): Record => ({ ...meta, receivedAt: new Date().toISOString(), }); const isNoteInput = (value: unknown): value is NoteInput => { if (typeof value !== 'object' || value === null) return false; const note = value as Record; if (typeof note.id !== 'string' || note.id.length === 0) return false; if (typeof note.text !== 'string' || note.text.length === 0) return false; if (typeof note.author !== 'string' || note.author.length === 0) return false; if (note.x !== undefined && typeof note.x !== 'number') return false; if (note.y !== undefined && typeof note.y !== 'number') return false; if (note.color !== undefined && typeof note.color !== 'string') return false; return true; }; export const isNoteInputArray = (value: unknown): value is NoteInput[] => Array.isArray(value) && value.every(isNoteInput); /** * Direct push: the caller already speaks our note shape, so normalization is * a validation step rather than a translation. */ export const normalizeRest = (payload: unknown): NormalizedDelivery => { const body = asRecord(payload); if (!isNoteInputArray(body.notes)) { throw new NormalizationError('Expected a "notes" array of note objects'); } const externalId = asString(body.batchId) ?? randomBatchId(); return { externalId, notes: body.notes.map((note) => ({ ...note, sourceMeta: provenance({ provider: 'rest', externalId }), })), }; }; const randomBatchId = (): string => `batch_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; /** * Linear comment webhook. Kept minimal on purpose: this proves the webhook * pipeline end to end. Label filtering and issue-thread context belong to the * Linear integration proper, not to this infrastructure. */ export const normalizeLinear = (payload: unknown): NormalizedDelivery => { const body = asRecord(payload); const data = asRecord(body.data); const externalId = asString(data.id); if (!externalId) { throw new NormalizationError('Linear payload is missing data.id'); } const text = asString(data.body); if (!text) { return { externalId, notes: [] }; } const user = typeof data.user === 'object' && data.user !== null ? (data.user as Record) : {}; return { externalId, notes: [ { id: noteId('linear', externalId), text, author: asString(user.name) ?? 'unknown', sourceMeta: provenance({ provider: 'linear', externalId, permalink: asString(data.url), authorHandle: asString(user.name), }), }, ], }; };