Files
kongruity/backend/config/normalizers.ts
2026-08-02 06:13:01 -04:00

193 lines
6.2 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),
}),
},
],
};
};
/** Stands in for the message body until the enrichment hook backfills it. */
export const SLACK_PENDING_TEXT = '(pending Slack message text)';
const DEFAULT_CAPTURE_REACTION = 'pushpin';
/** Slack suffixes skin-tone modifiers onto emoji names; only the base name identifies the reaction. */
const baseReaction = (name: string): string => name.split('::')[0];
const nested = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
/**
* Slack Events API. Only one configured reaction is interesting; every other
* delivery is ordinary traffic and yields no notes rather than an error.
*
* A reaction_added event names a message but never carries its text, and this
* runs ahead of the HTTP ack inside Slack's three-second budget, so it cannot
* go fetch it. The note therefore carries placeholder text and needsMessageText,
* which is the enrichment hook's cue to backfill the real body out of band.
*/
export const normalizeSlack = (payload: unknown): NormalizedDelivery => {
const body = asRecord(payload);
if (body.type !== 'event_callback') {
throw new NormalizationError('Slack payload is not an event_callback');
}
const externalId = asString(body.event_id);
if (!externalId) {
throw new NormalizationError('Slack payload is missing event_id');
}
const event = nested(body.event);
if (event.type !== 'reaction_added') {
return { externalId, notes: [] };
}
// Read per call: the captured reaction is operator configuration, not a build-time constant.
const configured = asString(process.env.SLACK_CAPTURE_REACTION) ?? DEFAULT_CAPTURE_REACTION;
const reaction = asString(event.reaction);
if (!reaction || baseReaction(reaction) !== baseReaction(configured)) {
return { externalId, notes: [] };
}
const item = nested(event.item);
const channelId = asString(item.channel);
const messageTs = asString(item.ts);
if (item.type !== 'message' || !channelId || !messageTs) {
return { externalId, notes: [] };
}
const teamId = asString(body.team_id);
const reactedBy = asString(event.user);
const authorUserId = asString(event.item_user);
return {
externalId,
notes: [
{
id: noteId('slack', externalId),
text: SLACK_PENDING_TEXT,
author: authorUserId ?? reactedBy ?? 'unknown',
sourceMeta: {
...provenance({ provider: 'slack', externalId }),
needsMessageText: true,
channelId,
messageTs,
reaction,
...(teamId !== undefined ? { teamId } : {}),
...(reactedBy !== undefined ? { reactedBy } : {}),
...(authorUserId !== undefined ? { authorUserId } : {}),
},
},
],
};
};