95 lines
3.5 KiB
TypeScript
95 lines
3.5 KiB
TypeScript
import { Worker as RedisWorker } from "@trigger.dev/redis-worker";
|
|
import { z } from "zod";
|
|
import { env } from "~/env.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { singleton } from "~/utils/singleton";
|
|
import { ssoController } from "~/services/sso.server";
|
|
import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server";
|
|
|
|
// Dedicated worker for inbound account-management webhooks. The webhook
|
|
// proxy route verifies the signature via the plugin and enqueues the
|
|
// parsed event here; this worker calls back into the plugin to apply the
|
|
// DB writes. The plugin owns the vendor-specific logic; the webapp owns
|
|
// the queue runtime (this file), mirroring `commonWorker.server.ts`.
|
|
//
|
|
// Vendor-neutral by design: the catalog/job names and payload shape carry
|
|
// no provider identity.
|
|
const PayloadSchema = z
|
|
.object({
|
|
id: z.string(),
|
|
event: z.string(),
|
|
data: z.unknown(),
|
|
})
|
|
// Zod v3 treats a `z.unknown()` field as optional, so a payload missing
|
|
// `data` entirely would otherwise validate. Require the key's presence
|
|
// at runtime via refine — `.nonoptional()` is a v4-only API and isn't
|
|
// available on the classic `z` import this repo pins.
|
|
.refine((payload) => "data" in payload, {
|
|
message: "data is required",
|
|
path: ["data"],
|
|
});
|
|
|
|
function initializeWorker() {
|
|
const redisOptions = {
|
|
keyPrefix: "accounts-webhook:worker:",
|
|
host: env.COMMON_WORKER_REDIS_HOST,
|
|
port: env.COMMON_WORKER_REDIS_PORT,
|
|
username: env.COMMON_WORKER_REDIS_USERNAME,
|
|
password: env.COMMON_WORKER_REDIS_PASSWORD,
|
|
enableAutoPipelining: true,
|
|
...(env.COMMON_WORKER_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
|
};
|
|
|
|
const worker = new RedisWorker({
|
|
name: "accounts-webhook-worker",
|
|
redisOptions,
|
|
catalog: {
|
|
"account.webhook.event": {
|
|
schema: PayloadSchema,
|
|
visibilityTimeoutMs: 30_000,
|
|
retry: { maxAttempts: 5 },
|
|
},
|
|
},
|
|
concurrency: {
|
|
workers: 2,
|
|
tasksPerWorker: 4,
|
|
limit: 8,
|
|
},
|
|
pollIntervalMs: 1_000,
|
|
immediatePollIntervalMs: 50,
|
|
shutdownTimeoutMs: 30_000,
|
|
jobs: {
|
|
"account.webhook.event": async ({ payload }) => {
|
|
// The plugin returns a Result; throw on error so the worker
|
|
// retries (a resolved err would otherwise be silently dropped).
|
|
// `z.unknown()` infers `data?: unknown`, but the contract's
|
|
// SsoWebhookEvent requires `data: unknown` — restate the fields
|
|
// explicitly so the optional-vs-required shapes line up.
|
|
const result = await ssoController.processWebhookEvent({
|
|
id: payload.id,
|
|
event: payload.event,
|
|
data: payload.data,
|
|
});
|
|
if (result.isErr()) {
|
|
throw new Error(`account webhook processing failed: ${result.error}`);
|
|
}
|
|
// Directory-sync events return membership effects to apply against
|
|
// public.* tables (the plugin never writes those). A throw here
|
|
// bubbles to the worker for retry; effects are idempotent.
|
|
await applyDirectorySyncEffects(result.value.effects);
|
|
},
|
|
},
|
|
});
|
|
|
|
// Only poll on worker-role instances (same gate as commonWorker) and
|
|
// only when the feature is enabled (no plugin loaded otherwise).
|
|
if (env.COMMON_WORKER_ENABLED === "true" && env.SSO_ENABLED) {
|
|
logger.debug(`👨🏭 Starting accounts webhook worker at host ${env.COMMON_WORKER_REDIS_HOST}`);
|
|
worker.start();
|
|
}
|
|
|
|
return worker;
|
|
}
|
|
|
|
export const accountsWebhookWorker = singleton("accountsWebhookWorker", initializeWorker);
|