Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/services/admin/webhook-inbox.server.ts
T

232 lines
7.0 KiB
TypeScript

import { buildWebhookDeliveryJob, enqueueJob, queueNames, resolveRedisConnectionOptionsFromProcessEnv, type TypedQueue } from "@hivegrid/queue";
import { runWithRlsContext } from "@hivegrid/db/rls-context.server";
import { db } from "~/utils/db.server";
import { assertAdminActionReason, recordAdminAction, type AdminActionAuditContext } from "./admin-action-audit.server";
type EmailQueue = TypedQueue<typeof queueNames.email>;
const WEBHOOK_REPLAY_REQUESTED_MESSAGE_KEY = "admin.webhooks.messages.replayRequested";
const WEBHOOK_QUEUE_UNAVAILABLE_MESSAGE_KEY = "admin.webhooks.messages.queueUnavailable";
type WebhookAttemptForReplay = {
id: string;
endpoint: string;
success: boolean | null;
status: number | null;
eventId: string;
event: {
id: string;
name: string;
data: string;
resource: string | null;
tenantId: string | null;
};
};
export type AdminWebhookInboxDb = {
eventWebhookAttempt: {
findUnique(args: {
where: { id: string };
select: {
id: true;
endpoint: true;
success: true;
status: true;
eventId: true;
event: {
select: {
id: true;
name: true;
data: true;
resource: true;
tenantId: true;
};
};
};
}): Promise<WebhookAttemptForReplay | null>;
create(args: {
data: {
eventId: string;
endpoint: string;
startedAt: Date;
message: string;
};
}): Promise<{ id: string }>;
update(args: {
where: { id: string };
data: {
finishedAt: Date;
success: boolean;
status: number;
message: string;
};
}): Promise<unknown>;
};
};
export type AdminWebhookInboxDeps = {
readonly db: AdminWebhookInboxDb;
readonly now: () => Date;
readonly createQueue: () => Promise<EmailQueue | null>;
readonly enqueue: typeof enqueueJob;
readonly recordAction: (context: AdminActionAuditContext) => Promise<void>;
};
export class AdminWebhookInboxError extends Error {
override readonly name = "AdminWebhookInboxError";
readonly i18nKey: string;
constructor(readonly code: "ATTEMPT_NOT_FOUND" | "REPLAY_NOT_ALLOWED" | "INVALID_EVENT_DATA" | "QUEUE_UNAVAILABLE") {
super(code);
this.i18nKey = `admin.webhooks.errors.${code}`;
}
}
async function createConfiguredEmailQueue(): Promise<EmailQueue | null> {
const connection = resolveRedisConnectionOptionsFromProcessEnv();
if (!connection) return null;
// Lazy-load the BullMQ runtime through an indirect specifier so the web build
// never statically resolves the `@hivegrid/queue/runtime` -> /dev/null alias
// (see apps/web/vite.config.ts). Matches the pattern used by every other
// server-only queue producer in the web app.
const queueRuntimeModule = "@hivegrid/queue/runtime";
const { createQueue } = (await import(/* @vite-ignore */ queueRuntimeModule)) as typeof import("@hivegrid/queue/runtime");
return createQueue(queueNames.email, { connection });
}
function defaultDeps(): AdminWebhookInboxDeps {
return {
db: db as unknown as AdminWebhookInboxDb,
now: () => new Date(),
createQueue: createConfiguredEmailQueue,
enqueue: enqueueJob,
recordAction: recordAdminAction,
};
}
function parseEventPayload(data: string): unknown {
try {
return JSON.parse(data);
} catch {
throw new AdminWebhookInboxError("INVALID_EVENT_DATA");
}
}
function assertReplayAllowed(attempt: Pick<WebhookAttemptForReplay, "success" | "status">): void {
if (attempt.success === false || (attempt.status !== null && attempt.status >= 400)) return;
throw new AdminWebhookInboxError("REPLAY_NOT_ALLOWED");
}
export type RequestWebhookReplayInput = {
readonly attemptId: string;
readonly reason: string;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
};
export async function requestWebhookReplay(input: RequestWebhookReplayInput, deps: AdminWebhookInboxDeps = defaultDeps()): Promise<{ replayAttemptId: string }> {
const reason = assertAdminActionReason(input.reason);
const attempt = await deps.db.eventWebhookAttempt.findUnique({
where: { id: input.attemptId },
select: {
id: true,
endpoint: true,
success: true,
status: true,
eventId: true,
event: { select: { id: true, name: true, data: true, resource: true, tenantId: true } },
},
});
if (!attempt) throw new AdminWebhookInboxError("ATTEMPT_NOT_FOUND");
assertReplayAllowed(attempt);
const execute = () => executeWebhookReplay(input, reason, attempt, deps);
return attempt.event.tenantId ? runWithRlsContext({ tenantId: attempt.event.tenantId }, execute) : execute();
}
async function executeWebhookReplay(
input: RequestWebhookReplayInput,
reason: string,
attempt: WebhookAttemptForReplay,
deps: AdminWebhookInboxDeps,
): Promise<{ replayAttemptId: string }> {
const now = deps.now();
const replayAttempt = await deps.db.eventWebhookAttempt.create({
data: {
eventId: attempt.eventId,
endpoint: attempt.endpoint,
startedAt: now,
message: WEBHOOK_REPLAY_REQUESTED_MESSAGE_KEY,
},
});
const queue = await deps.createQueue();
if (!queue) {
await deps.db.eventWebhookAttempt.update({
where: { id: replayAttempt.id },
data: {
finishedAt: deps.now(),
success: false,
status: 503,
message: WEBHOOK_QUEUE_UNAVAILABLE_MESSAGE_KEY,
},
});
throw new AdminWebhookInboxError("QUEUE_UNAVAILABLE");
}
try {
const descriptor = buildWebhookDeliveryJob({
url: attempt.endpoint,
method: "POST",
eventName: attempt.event.name,
body: parseEventPayload(attempt.event.data),
...(attempt.event.tenantId ? { tenantId: attempt.event.tenantId } : {}),
metadata: {
legacyAttemptId: replayAttempt.id,
legacyAttemptTenantId: attempt.event.tenantId,
adminReplayOfAttemptId: attempt.id,
actorUserId: input.actorUserId ?? null,
},
});
await deps.enqueue(queue, descriptor, {
idempotencyKey: replayAttempt.id,
tenantId: attempt.event.tenantId,
});
} catch {
await deps.db.eventWebhookAttempt.update({
where: { id: replayAttempt.id },
data: {
finishedAt: deps.now(),
success: false,
status: 503,
message: WEBHOOK_QUEUE_UNAVAILABLE_MESSAGE_KEY,
},
});
throw new AdminWebhookInboxError("QUEUE_UNAVAILABLE");
} finally {
await queue.close();
}
await deps.recordAction({
action: "webhook.replayRequested",
actorUserId: input.actorUserId,
actorEmail: input.actorEmail ?? null,
targetKind: "eventWebhookAttempt",
targetId: attempt.id,
reason,
after: {
replayAttemptId: replayAttempt.id,
eventId: attempt.eventId,
eventName: attempt.event.name,
eventResource: attempt.event.resource,
tenantId: attempt.event.tenantId,
},
request: input.request,
});
return { replayAttemptId: replayAttempt.id };
}