232 lines
7.0 KiB
TypeScript
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 };
|
|
}
|