feat(FN-2878): add webhook notification provider support
- Add webhook settings fields and defaults for enablement, URL, format, and event filtering - Implement WebhookNotificationProvider with payload formatting support for generic, Slack, and Discord endpoints - Extend NotificationService to manage both ntfy and webhook providers with live settings sync - Export webhook notification types/providers through engine notification entry points
This commit is contained in:
@@ -24,6 +24,10 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
ntfyBaseUrl: undefined,
|
||||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input", "gridlock"],
|
||||
ntfyDashboardHost: undefined,
|
||||
webhookEnabled: false,
|
||||
webhookUrl: undefined,
|
||||
webhookFormat: "generic",
|
||||
webhookEvents: [],
|
||||
notificationProviders: [],
|
||||
defaultProjectId: undefined,
|
||||
setupComplete: undefined,
|
||||
|
||||
@@ -1117,6 +1117,23 @@ export interface GlobalSettings {
|
||||
* ?project=<id>&task=<id> so the dashboard opens the correct project first.
|
||||
* Example: "http://localhost:3000" or "https://fusion.example.com" */
|
||||
ntfyDashboardHost?: string;
|
||||
/** When true, enables webhook notifications for task lifecycle events.
|
||||
* Requires webhookUrl to be set. Default: false. */
|
||||
webhookEnabled?: boolean;
|
||||
/** URL to send webhook notifications to.
|
||||
* Must be an http:// or https:// URL. */
|
||||
webhookUrl?: string;
|
||||
/** Format of the webhook payload.
|
||||
* - "slack": Slack incoming webhook format ({ text: message })
|
||||
* - "discord": Discord webhook format ({ content: message })
|
||||
* - "generic": Structured JSON with event/task/timestamp fields
|
||||
* Default: "generic". */
|
||||
webhookFormat?: "slack" | "discord" | "generic";
|
||||
/** List of notification events to send via webhook.
|
||||
* When webhookEnabled is true, only events in this list trigger webhooks.
|
||||
* If undefined or empty when webhookEnabled is true, all events are sent.
|
||||
* Default: [] (all events). */
|
||||
webhookEvents?: string[];
|
||||
/** Pluggable notification providers configuration. Additive to legacy ntfy
|
||||
* settings so existing ntfy configuration continues working unchanged. */
|
||||
notificationProviders?: NotificationProviderConfig[];
|
||||
|
||||
164
packages/engine/src/__tests__/webhook-provider.test.ts
Normal file
164
packages/engine/src/__tests__/webhook-provider.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WebhookNotificationProvider } from "../notification/webhook-provider.js";
|
||||
|
||||
describe("WebhookNotificationProvider", () => {
|
||||
let provider: WebhookNotificationProvider;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new WebhookNotificationProvider();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("getProviderId returns webhook", () => {
|
||||
expect(provider.getProviderId()).toBe("webhook");
|
||||
});
|
||||
|
||||
it("initialize stores config", async () => {
|
||||
await expect(
|
||||
provider.initialize({
|
||||
webhookUrl: "https://example.com/hook",
|
||||
webhookFormat: "slack",
|
||||
events: ["in-review"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(provider.isEventSupported("in-review")).toBe(true);
|
||||
expect(provider.isEventSupported("failed")).toBe(false);
|
||||
});
|
||||
|
||||
it("initialize rejects missing URL", async () => {
|
||||
await expect(provider.initialize({ webhookFormat: "slack" })).rejects.toThrow("webhookUrl is required");
|
||||
});
|
||||
|
||||
it("sendNotification formats Slack correctly", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });
|
||||
|
||||
await provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://example.com/hook",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const body = JSON.parse(String(requestInit.body));
|
||||
expect(body.text).toContain("My Task");
|
||||
});
|
||||
|
||||
it("sendNotification formats Discord correctly", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "discord" });
|
||||
|
||||
await provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" });
|
||||
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const body = JSON.parse(String(requestInit.body));
|
||||
expect(body.content).toContain("My Task");
|
||||
});
|
||||
|
||||
it("sendNotification formats Generic correctly", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic" });
|
||||
|
||||
await provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" });
|
||||
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const payload = JSON.parse(String(requestInit.body));
|
||||
expect(payload.event).toBe("in-review");
|
||||
expect(payload.timestamp).toEqual(expect.any(String));
|
||||
expect(payload.task).toEqual({ id: "FN-1", title: "My Task" });
|
||||
});
|
||||
|
||||
it("sendNotification returns success on HTTP 200", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic" });
|
||||
|
||||
await expect(
|
||||
provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" }),
|
||||
).resolves.toEqual({ success: true, providerId: "webhook" });
|
||||
});
|
||||
|
||||
it("sendNotification returns failure on HTTP error", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Internal Server Error" });
|
||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic" });
|
||||
|
||||
await expect(
|
||||
provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" }),
|
||||
).resolves.toEqual({ success: false, providerId: "webhook", error: expect.stringContaining("500") });
|
||||
});
|
||||
|
||||
it("sendNotification returns failure on network error", async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError("network error"));
|
||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic" });
|
||||
|
||||
await expect(
|
||||
provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" }),
|
||||
).resolves.toEqual({ success: false, providerId: "webhook", error: expect.any(String) });
|
||||
});
|
||||
|
||||
it("isEventSupported returns true when no filter", async () => {
|
||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic", events: [] });
|
||||
expect(provider.isEventSupported("in-review")).toBe(true);
|
||||
expect(provider.isEventSupported("custom-event")).toBe(true);
|
||||
});
|
||||
|
||||
it("isEventSupported filters by configured events", async () => {
|
||||
await provider.initialize({
|
||||
webhookUrl: "https://example.com/hook",
|
||||
webhookFormat: "generic",
|
||||
events: ["in-review", "merged"],
|
||||
});
|
||||
|
||||
expect(provider.isEventSupported("in-review")).toBe(true);
|
||||
expect(provider.isEventSupported("failed")).toBe(false);
|
||||
});
|
||||
|
||||
it("shutdown aborts requests", async () => {
|
||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "generic" });
|
||||
await provider.shutdown();
|
||||
|
||||
await expect(
|
||||
provider.sendNotification("in-review", { taskId: "FN-1", taskTitle: "My Task", event: "in-review" }),
|
||||
).resolves.toEqual({ success: false, providerId: "webhook", error: "Not initialized" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["in-review", "ready for review"],
|
||||
["merged", "has been merged to main"],
|
||||
["failed", "has failed and needs attention"],
|
||||
["awaiting-approval", "needs your approval before it can proceed"],
|
||||
["awaiting-user-review", "needs human review before it can proceed"],
|
||||
["planning-awaiting-input", "is awaiting your input during planning"],
|
||||
["gridlock", "Pipeline gridlocked"],
|
||||
["unknown-event", 'Event "unknown-event" for task My Task'],
|
||||
])("message formatting for %s", async (event, expectedPart) => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });
|
||||
|
||||
await provider.sendNotification(event, { taskId: "FN-1", taskTitle: "My Task", event });
|
||||
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const body = JSON.parse(String(requestInit.body));
|
||||
expect(body.text).toContain(expectedPart);
|
||||
});
|
||||
|
||||
it("title fallback uses taskId and truncated description snippet", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });
|
||||
|
||||
const description = "a".repeat(220);
|
||||
await provider.sendNotification("failed", { taskId: "FN-1", taskDescription: description, event: "failed" });
|
||||
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const body = JSON.parse(String(requestInit.body));
|
||||
expect(body.text).toContain('Task "FN-1:');
|
||||
expect(body.text).toContain("...");
|
||||
});
|
||||
});
|
||||
@@ -66,8 +66,8 @@ export {
|
||||
type SendNtfyNotificationInput,
|
||||
} from "./notifier.js";
|
||||
// ── Notification Service ──────────────────────────────────────
|
||||
export { NtfyNotificationProvider, NotificationService } from "./notification/index.js";
|
||||
export type { NtfyProviderConfig, NotificationServiceOptions } from "./notification/index.js";
|
||||
export { NtfyNotificationProvider, NotificationService, WebhookNotificationProvider } from "./notification/index.js";
|
||||
export type { NtfyProviderConfig, NotificationServiceOptions, WebhookProviderConfig } from "./notification/index.js";
|
||||
export { CronRunner, type CronRunnerOptions, type AiPromptExecutor, createAiPromptExecutor } from "./cron-runner.js";
|
||||
export { RoutineRunner, type RoutineRunnerOptions } from "./routine-runner.js";
|
||||
export { RoutineScheduler, type RoutineSchedulerOptions } from "./routine-scheduler.js";
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export { NtfyNotificationProvider } from "./ntfy-provider.js";
|
||||
export type { NtfyProviderConfig } from "./ntfy-provider.js";
|
||||
|
||||
export { WebhookNotificationProvider } from "./webhook-provider.js";
|
||||
export type { WebhookProviderConfig } from "./webhook-provider.js";
|
||||
|
||||
export { NotificationService } from "./notification-service.js";
|
||||
export type { NotificationServiceOptions } from "./notification-service.js";
|
||||
|
||||
@@ -11,6 +11,7 @@ import { NotificationDispatcher } from "@fusion/core";
|
||||
import { DEFAULT_NTFY_EVENTS } from "../notifier.js";
|
||||
import { schedulerLog } from "../logger.js";
|
||||
import { NtfyNotificationProvider } from "./ntfy-provider.js";
|
||||
import { WebhookNotificationProvider } from "./webhook-provider.js";
|
||||
|
||||
export interface NotificationServiceOptions {
|
||||
/** Project identifier for notification deep links */
|
||||
@@ -29,8 +30,9 @@ export class NotificationService {
|
||||
private readonly dispatcher = new NotificationDispatcher();
|
||||
private readonly notifiedEvents = new Set<string>();
|
||||
private started = false;
|
||||
private ntfyEnabled = false;
|
||||
private notificationsEnabled = false;
|
||||
private ntfyProvider?: NtfyNotificationProvider;
|
||||
private webhookProvider?: WebhookNotificationProvider;
|
||||
|
||||
constructor(
|
||||
private readonly store: NotificationServiceStore,
|
||||
@@ -47,7 +49,9 @@ export class NotificationService {
|
||||
}
|
||||
|
||||
const settings = await this.store.getSettings();
|
||||
this.setNotificationsEnabledFromSettings(settings);
|
||||
await this.syncNtfyProvider(settings);
|
||||
await this.syncWebhookProvider(settings);
|
||||
|
||||
await this.dispatcher.initializeAll();
|
||||
|
||||
@@ -79,7 +83,7 @@ export class NotificationService {
|
||||
}
|
||||
|
||||
private handleTaskMoved = (data: { task: Task; from: Column; to: Column }): void => {
|
||||
if (!this.ntfyEnabled || data.to !== "in-review") {
|
||||
if (!this.notificationsEnabled || data.to !== "in-review") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,7 +92,7 @@ export class NotificationService {
|
||||
};
|
||||
|
||||
private handleTaskUpdated = (task: Task): void => {
|
||||
if (!this.ntfyEnabled) {
|
||||
if (!this.notificationsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,7 +118,7 @@ export class NotificationService {
|
||||
};
|
||||
|
||||
private handleTaskMerged = (result: MergeResult): void => {
|
||||
if (!this.ntfyEnabled || !result.merged) {
|
||||
if (!this.notificationsEnabled || !result.merged) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -127,6 +131,7 @@ export class NotificationService {
|
||||
|
||||
private handleSettingsUpdated = async (data: { settings: Settings; previous: Settings }): Promise<void> => {
|
||||
const { settings, previous } = data;
|
||||
this.setNotificationsEnabledFromSettings(settings);
|
||||
|
||||
if (
|
||||
settings.ntfyEnabled !== previous.ntfyEnabled ||
|
||||
@@ -154,11 +159,20 @@ export class NotificationService {
|
||||
schedulerLog.log("NotificationService ntfy events updated");
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
settings.webhookEnabled !== previous.webhookEnabled ||
|
||||
settings.webhookUrl !== previous.webhookUrl ||
|
||||
settings.webhookFormat !== previous.webhookFormat ||
|
||||
JSON.stringify(settings.webhookEvents) !== JSON.stringify(previous.webhookEvents)
|
||||
) {
|
||||
await this.syncWebhookProvider(settings);
|
||||
schedulerLog.log("WebhookNotificationProvider config updated");
|
||||
}
|
||||
};
|
||||
|
||||
private async syncNtfyProvider(settings: Settings): Promise<void> {
|
||||
const enabled = Boolean(settings.ntfyEnabled && settings.ntfyTopic);
|
||||
this.ntfyEnabled = enabled;
|
||||
|
||||
if (!enabled) {
|
||||
if (this.ntfyProvider) {
|
||||
@@ -183,6 +197,37 @@ export class NotificationService {
|
||||
});
|
||||
}
|
||||
|
||||
private async syncWebhookProvider(settings: Settings): Promise<void> {
|
||||
const enabled = Boolean(settings.webhookEnabled && settings.webhookUrl);
|
||||
|
||||
if (!enabled) {
|
||||
if (this.webhookProvider) {
|
||||
await this.webhookProvider.shutdown?.();
|
||||
this.dispatcher.unregisterProvider(this.webhookProvider.getProviderId());
|
||||
this.webhookProvider = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.webhookProvider) {
|
||||
this.webhookProvider = new WebhookNotificationProvider();
|
||||
this.registerProvider(this.webhookProvider);
|
||||
}
|
||||
|
||||
await this.webhookProvider.initialize?.({
|
||||
webhookUrl: settings.webhookUrl,
|
||||
webhookFormat: settings.webhookFormat ?? "generic",
|
||||
events: settings.webhookEvents ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
private setNotificationsEnabledFromSettings(settings: Settings): void {
|
||||
this.notificationsEnabled = Boolean(
|
||||
(settings.ntfyEnabled && settings.ntfyTopic) ||
|
||||
(settings.webhookEnabled && settings.webhookUrl),
|
||||
);
|
||||
}
|
||||
|
||||
private createTaskPayload(task: Task, event: NotificationEvent): NotificationPayload {
|
||||
return {
|
||||
taskId: task.id,
|
||||
|
||||
174
packages/engine/src/notification/webhook-provider.ts
Normal file
174
packages/engine/src/notification/webhook-provider.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type {
|
||||
NotificationEvent,
|
||||
NotificationPayload,
|
||||
NotificationProvider,
|
||||
NotificationResult,
|
||||
} from "@fusion/core";
|
||||
import { schedulerLog } from "../logger.js";
|
||||
|
||||
export interface WebhookProviderConfig {
|
||||
/** Webhook endpoint URL */
|
||||
webhookUrl: string;
|
||||
/** Payload format: slack, discord, or generic */
|
||||
webhookFormat: "slack" | "discord" | "generic";
|
||||
/** Events to send (empty = all events) */
|
||||
events?: string[];
|
||||
}
|
||||
|
||||
export class WebhookNotificationProvider implements NotificationProvider {
|
||||
private config: WebhookProviderConfig | null = null;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
getProviderId(): string {
|
||||
return "webhook";
|
||||
}
|
||||
|
||||
async initialize(config: Record<string, unknown>): Promise<void> {
|
||||
const webhookUrl = typeof config.webhookUrl === "string" ? config.webhookUrl.trim() : "";
|
||||
if (!webhookUrl) {
|
||||
throw new Error("webhookUrl is required");
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(webhookUrl);
|
||||
} catch {
|
||||
throw new Error("webhookUrl must be a valid URL");
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
||||
throw new Error("webhookUrl must use http:// or https://");
|
||||
}
|
||||
|
||||
const webhookFormat =
|
||||
config.webhookFormat === "slack" || config.webhookFormat === "discord" || config.webhookFormat === "generic"
|
||||
? config.webhookFormat
|
||||
: "generic";
|
||||
|
||||
this.config = {
|
||||
webhookUrl,
|
||||
webhookFormat,
|
||||
events: Array.isArray(config.events) ? config.events.filter((event): event is string => typeof event === "string") : [],
|
||||
};
|
||||
|
||||
this.abortController?.abort();
|
||||
this.abortController = new AbortController();
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.abortController?.abort();
|
||||
this.abortController = null;
|
||||
this.config = null;
|
||||
}
|
||||
|
||||
isEventSupported(event: NotificationEvent): boolean {
|
||||
if (!this.config?.events || this.config.events.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return this.config.events.includes(event);
|
||||
}
|
||||
|
||||
async sendNotification(event: NotificationEvent, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
if (!this.config) {
|
||||
return { success: false, providerId: this.getProviderId(), error: "Not initialized" };
|
||||
}
|
||||
|
||||
if (!this.isEventSupported(event)) {
|
||||
return {
|
||||
success: false,
|
||||
providerId: this.getProviderId(),
|
||||
error: `unsupported event: ${event}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const message = this.formatMessage(event, payload);
|
||||
const body = this.formatPayload(payload, message);
|
||||
|
||||
const response = await fetch(this.config.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: this.abortController?.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = `Webhook notification failed: ${response.status} ${response.statusText}`;
|
||||
schedulerLog.log(error);
|
||||
return {
|
||||
success: false,
|
||||
providerId: this.getProviderId(),
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, providerId: this.getProviderId() };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
schedulerLog.log(`Failed to send webhook notification: ${message}`);
|
||||
return {
|
||||
success: false,
|
||||
providerId: this.getProviderId(),
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private formatMessage(event: NotificationEvent, payload: NotificationPayload): string {
|
||||
const identifier = this.formatTaskIdentifier(payload);
|
||||
switch (event) {
|
||||
case "in-review":
|
||||
return `Task "${identifier}" is ready for review`;
|
||||
case "merged":
|
||||
return `Task "${identifier}" has been merged to main`;
|
||||
case "failed":
|
||||
return `Task "${identifier}" has failed and needs attention`;
|
||||
case "awaiting-approval":
|
||||
return `Task "${identifier}" needs your approval before it can proceed`;
|
||||
case "awaiting-user-review":
|
||||
return `Task "${identifier}" needs human review before it can proceed`;
|
||||
case "planning-awaiting-input":
|
||||
return `Task "${identifier}" is awaiting your input during planning`;
|
||||
case "gridlock":
|
||||
return "Pipeline gridlocked";
|
||||
default:
|
||||
return `Event "${event}" for task ${identifier}`;
|
||||
}
|
||||
}
|
||||
|
||||
private formatTaskIdentifier(payload: NotificationPayload): string {
|
||||
if (payload.taskTitle?.trim()) {
|
||||
return payload.taskTitle;
|
||||
}
|
||||
|
||||
const description = payload.taskDescription ?? "";
|
||||
const snippet = description.length > 200 ? `${description.slice(0, 200)}...` : description;
|
||||
return `${payload.taskId}: ${snippet}`;
|
||||
}
|
||||
|
||||
private formatPayload(payload: NotificationPayload, message: string): Record<string, unknown> {
|
||||
if (!this.config) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (this.config.webhookFormat === "slack") {
|
||||
return { text: message };
|
||||
}
|
||||
|
||||
if (this.config.webhookFormat === "discord") {
|
||||
return { content: message };
|
||||
}
|
||||
|
||||
return {
|
||||
event: payload.event,
|
||||
timestamp: new Date().toISOString(),
|
||||
task: {
|
||||
id: payload.taskId,
|
||||
title: payload.taskTitle,
|
||||
},
|
||||
metadata: payload.metadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user