114 lines
3.5 KiB
TypeScript
114 lines
3.5 KiB
TypeScript
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<string, unknown> => {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
throw new NormalizationError('Payload is not an object');
|
|
}
|
|
return value as Record<string, unknown>;
|
|
};
|
|
|
|
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<NoteProvenance, 'receivedAt'>
|
|
): Record<string, unknown> => ({
|
|
...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<string, unknown>;
|
|
|
|
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<string, unknown>)
|
|
: {};
|
|
|
|
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),
|
|
}),
|
|
},
|
|
],
|
|
};
|
|
};
|