feat: add webhook logs

This commit is contained in:
David Nguyen
2025-11-24 18:43:59 +11:00
parent ae31860b16
commit c63c1b8963
18 changed files with 1664 additions and 411 deletions

View File

@ -0,0 +1,377 @@
import { expect, test } from '@playwright/test';
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { expectTextToBeVisible } from '../fixtures/generic';
/**
* Helper function to seed a webhook directly in the database for testing.
*/
const seedWebhook = async ({
webhookUrl,
eventTriggers,
secret,
enabled,
userId,
teamId,
}: {
webhookUrl: string;
eventTriggers: WebhookTriggerEvents[];
secret?: string | null;
enabled?: boolean;
userId: number;
teamId: number;
}) => {
return await prisma.webhook.create({
data: {
webhookUrl,
eventTriggers,
secret: secret ?? null,
enabled: enabled ?? true,
userId,
teamId,
},
});
};
test('[WEBHOOKS]: create webhook', async ({ page }) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/webhooks`,
});
const webhookUrl = `https://example.com/webhook-${Date.now()}`;
// Click Create Webhook button
await page.getByRole('button', { name: 'Create Webhook' }).click();
// Fill in the form
await page.getByLabel('Webhook URL*').fill(webhookUrl);
// Select event trigger - click on the triggers field and select DOCUMENT_CREATED
await page.getByLabel('Triggers').click();
await page.waitForTimeout(200); // Wait for dropdown to open
await page.getByText('document.created').click();
// Click outside the triggers field to close the dropdown
await page.getByText('The URL for Documenso to send webhook events to.').click();
// Fill in the form
await page.getByLabel('Secret').fill('secret');
// Submit the form
await page.getByRole('button', { name: 'Create' }).click();
// Wait for success toast
await expectTextToBeVisible(page, 'Webhook created');
await expectTextToBeVisible(page, 'The webhook was successfully created.');
// Verify webhook appears in the list
await expect(page.getByText(webhookUrl)).toBeVisible();
// Directly check database
const dbWebhook = await prisma.webhook.findFirstOrThrow({
where: {
userId: user.id,
},
});
expect(dbWebhook?.eventTriggers).toEqual([WebhookTriggerEvents.DOCUMENT_CREATED]);
expect(dbWebhook?.secret).toBe('secret');
expect(dbWebhook?.enabled).toBe(true);
});
test('[WEBHOOKS]: view webhooks', async ({ page }) => {
const { user, team } = await seedUser();
const webhookUrl = `https://example.com/webhook-${Date.now()}`;
// Create a webhook via seeding
const webhook = await seedWebhook({
webhookUrl,
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED, WebhookTriggerEvents.DOCUMENT_SENT],
userId: user.id,
teamId: team.id,
enabled: true,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/webhooks`,
});
// Verify webhook is visible in the table
await expect(page.getByText(webhookUrl)).toBeVisible();
await expect(page.getByText('Enabled')).toBeVisible();
await expect(page.getByText('2 Events')).toBeVisible();
// Click on webhook to navigate to detail page
await page.getByText(webhookUrl).click();
// Verify detail page shows webhook information
await page.waitForURL(`/t/${team.url}/settings/webhooks/${webhook.id}`);
await expect(page.getByText(webhookUrl)).toBeVisible();
await expect(page.getByText('Enabled')).toBeVisible();
});
test('[WEBHOOKS]: delete webhook', async ({ page }) => {
const { user, team } = await seedUser();
const webhookUrl = `https://example.com/webhook-${Date.now()}`;
// Create a webhook via seeding
const webhook = await seedWebhook({
webhookUrl,
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED],
userId: user.id,
teamId: team.id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/webhooks`,
});
// Verify webhook is visible
await expect(page.getByText(webhookUrl)).toBeVisible();
// Find the row with the webhook and click the action dropdown
const webhookRow = page.locator('tr', { hasText: webhookUrl });
await webhookRow.getByTestId('webhook-table-action-btn').click();
// Click Delete menu item
await page.getByRole('menuitem', { name: 'Delete' }).click();
// Fill in confirmation field
const deleteMessage = `delete ${webhookUrl}`;
// The label contains "Confirm by typing:" followed by the delete message
await page.getByLabel(/Confirm by typing/).fill(deleteMessage);
// Click delete button
await page.getByRole('button', { name: 'Delete' }).click();
// Wait for success toast
await expectTextToBeVisible(page, 'Webhook deleted');
await expectTextToBeVisible(page, 'The webhook has been successfully deleted.');
// Verify webhook is removed from the list
await expect(page.getByText(webhookUrl)).not.toBeVisible();
});
test('[WEBHOOKS]: update webhook', async ({ page }) => {
const { user, team } = await seedUser();
const originalWebhookUrl = `https://example.com/webhook-original-${Date.now()}`;
const updatedWebhookUrl = `https://example.com/webhook-updated-${Date.now()}`;
// Create a webhook via seeding with initial values
const webhook = await seedWebhook({
webhookUrl: originalWebhookUrl,
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED, WebhookTriggerEvents.DOCUMENT_SENT],
userId: user.id,
teamId: team.id,
enabled: true,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/webhooks`,
});
// Verify webhook is visible with original values
await expect(page.getByText(originalWebhookUrl)).toBeVisible();
await expect(page.getByText('Enabled')).toBeVisible();
await expect(page.getByText('2 Events')).toBeVisible();
// Find the row with the webhook and click the action dropdown
const webhookRow = page.locator('tr', { hasText: originalWebhookUrl });
await webhookRow.getByTestId('webhook-table-action-btn').click();
// Click Edit menu item
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Wait for dialog to open
await page.waitForTimeout(200);
// Change the webhook URL
await page.getByLabel('Webhook URL').clear();
await page.getByLabel('Webhook URL').fill(updatedWebhookUrl);
// Disable the webhook (toggle the switch)
const enabledSwitch = page.getByLabel('Enabled');
const isChecked = await enabledSwitch.isChecked();
if (isChecked) {
await enabledSwitch.click();
}
// Change the event triggers - remove one existing event and add a new one
// The selected items are shown as badges with remove buttons
// Remove one of the existing events (DOCUMENT_SENT) by clicking its remove button
const removeButtons = page.locator('button[aria-label="Remove"]');
const removeButtonCount = await removeButtons.count();
// Remove the "DOCUMENT_SENT" event (this will remove one of the two)
if (removeButtonCount > 0) {
await removeButtons.nth(1).click();
await page.waitForTimeout(200);
}
// Add new event triggers
await page.getByLabel('Triggers').click();
await page.waitForTimeout(200);
// Select DOCUMENT_COMPLETED (this will be added to the remaining DOCUMENT_CREATED)
await page.getByText('document.completed').click();
await page.waitForTimeout(200);
// Click outside to close the dropdown
await page.getByText('The URL for Documenso to send webhook events to.').click();
// Submit the form
await page.getByRole('button', { name: 'Update' }).click();
// Wait for success toast
await expectTextToBeVisible(page, 'Webhook updated');
await expectTextToBeVisible(page, 'The webhook has been updated successfully.');
// Verify changes are reflected in the list
// The old URL should be gone and new URL should be visible
await expect(page.getByText(originalWebhookUrl)).not.toBeVisible();
await expect(page.getByText(updatedWebhookUrl)).toBeVisible();
// Verify webhook is disabled
await expect(page.getByText('Disabled')).toBeVisible();
// Verify event count is still 2 (one removed, one added - DOCUMENT_CREATED and DOCUMENT_COMPLETED)
await expect(page.getByText('2 Events')).toBeVisible();
// Check the database directly to verify
const dbWebhook = await prisma.webhook.findUnique({
where: {
id: webhook.id,
},
});
expect(dbWebhook?.eventTriggers).toEqual([
WebhookTriggerEvents.DOCUMENT_CREATED,
WebhookTriggerEvents.DOCUMENT_COMPLETED,
]);
expect(dbWebhook?.enabled).toBe(false);
expect(dbWebhook?.webhookUrl).toBe(updatedWebhookUrl);
expect(dbWebhook?.secret).toBe('');
});
test('[WEBHOOKS]: cannot see unrelated webhooks', async ({ page }) => {
// Create two separate users with teams
const user1Data = await seedUser();
const user2Data = await seedUser();
const webhookUrl1 = `https://example.com/webhook-team1-${Date.now()}`;
const webhookUrl2 = `https://example.com/webhook-team2-${Date.now()}`;
// Create webhooks for both teams with DOCUMENT_CREATED event
const webhook1 = await seedWebhook({
webhookUrl: webhookUrl1,
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED],
userId: user1Data.user.id,
teamId: user1Data.team.id,
enabled: true,
});
const webhook2 = await seedWebhook({
webhookUrl: webhookUrl2,
eventTriggers: [WebhookTriggerEvents.DOCUMENT_SENT],
userId: user2Data.user.id,
teamId: user2Data.team.id,
});
// Create a document on team1 to trigger the webhook
const document = await seedBlankDocument(user1Data.user, user1Data.team.id, {
createDocumentOptions: {
title: 'Test Document for Webhook',
},
});
// Create a webhook call for team1's webhook (simulating the webhook being triggered)
// Since webhooks are triggered via jobs which may not run in tests, we create the call directly
const webhookCall1 = await prisma.webhookCall.create({
data: {
webhookId: webhook1.id,
url: webhookUrl1,
event: WebhookTriggerEvents.DOCUMENT_CREATED,
status: WebhookCallStatus.SUCCESS,
responseCode: 200,
requestBody: {
event: WebhookTriggerEvents.DOCUMENT_CREATED,
payload: {
id: document.id,
title: document.title,
},
createdAt: new Date().toISOString(),
webhookEndpoint: webhookUrl1,
},
},
});
// Sign in as user1
await apiSignin({
page,
email: user1Data.user.email,
redirectPath: `/t/${user1Data.team.url}/settings/webhooks`,
});
// Verify user1 can see their webhook
await expect(page.getByText(webhookUrl1)).toBeVisible();
// Verify user1 cannot see user2's webhook
await expect(page.getByText(webhookUrl2)).not.toBeVisible();
// Navigate to team1's webhook logs page
await page.goto(
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${user1Data.team.url}/settings/webhooks/${webhook1.id}`,
);
// Verify user1 can see their webhook logs
// The webhook call should be visible in the table
await expect(page.getByText(webhookCall1.id)).toBeVisible();
await expect(page.getByText('200')).toBeVisible(); // Response code
// Sign out and sign in as user2
await apiSignout({ page });
await apiSignin({
page,
email: user2Data.user.email,
redirectPath: `/t/${user2Data.team.url}/settings/webhooks`,
});
// Verify user2 can see their webhook
await expect(page.getByText(webhookUrl2)).toBeVisible();
// Verify user2 cannot see user1's webhook
await expect(page.getByText(webhookUrl1)).not.toBeVisible();
// Navigate to team2's webhook logs page
await page.goto(
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${user2Data.team.url}/settings/webhooks/${webhook2.id}`,
);
// Verify user2 cannot see team1's webhook logs
// The webhook call from team1 should not be visible
await expect(page.getByText(webhookCall1.id)).not.toBeVisible();
// Attempt to access user1's webhook detail page directly via URL
await page.goto(
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${user2Data.team.url}/settings/webhooks/${webhook1.id}`,
);
// Verify access is denied - should show error or redirect
// Based on the component, it shows a 404 error page
await expect(page.getByRole('heading', { name: 'Webhook not found' })).toBeVisible();
});

View File

@ -30,6 +30,7 @@ export const run = async ({
webhookEndpoint: url,
};
// Note: This is duplicated in `resend-webhook-call.ts`.
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(payloadData),

View File

@ -0,0 +1,106 @@
import { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZFindWebhookCallsRequestSchema,
ZFindWebhookCallsResponseSchema,
} from './find-webhook-calls.types';
export const findWebhookCallsRoute = authenticatedProcedure
.input(ZFindWebhookCallsRequestSchema)
.output(ZFindWebhookCallsResponseSchema)
.query(async ({ input, ctx }) => {
const { webhookId, page, perPage, status, query, events } = input;
ctx.logger.info({
input: { webhookId, status },
});
return await findWebhookCalls({
userId: ctx.user.id,
teamId: ctx.teamId,
webhookId,
page,
perPage,
status,
query,
events,
});
});
type FindWebhookCallsOptions = {
userId: number;
teamId: number;
webhookId: string;
page?: number;
perPage?: number;
status?: WebhookCallStatus;
events?: WebhookTriggerEvents[];
query?: string;
};
export const findWebhookCalls = async ({
userId,
teamId,
webhookId,
page = 1,
perPage = 20,
events,
query = '',
status,
}: FindWebhookCallsOptions) => {
const webhook = await prisma.webhook.findFirst({
where: {
id: webhookId,
team: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_TEAM,
}),
},
});
if (!webhook) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const whereClause: Prisma.WebhookCallWhereInput = {
webhookId: webhook.id,
status,
id: query || undefined,
event:
events && events.length > 0
? {
in: events,
}
: undefined,
};
const [data, count] = await Promise.all([
prisma.webhookCall.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.webhookCall.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};

View File

@ -0,0 +1,37 @@
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import WebhookCallSchema from '@documenso/prisma/generated/zod/modelSchema/WebhookCallSchema';
export const ZFindWebhookCallsRequestSchema = ZFindSearchParamsSchema.extend({
webhookId: z.string(),
status: z.nativeEnum(WebhookCallStatus).optional(),
events: z
.array(z.nativeEnum(WebhookTriggerEvents))
.optional()
.refine((arr) => !arr || new Set(arr).size === arr.length, {
message: 'Events must be unique',
}),
});
export const ZFindWebhookCallsResponseSchema = ZFindResultResponse.extend({
data: WebhookCallSchema.pick({
webhookId: true,
status: true,
event: true,
id: true,
url: true,
responseCode: true,
createdAt: true,
})
.extend({
requestBody: z.unknown(),
responseHeaders: z.unknown().nullable(),
responseBody: z.unknown().nullable(),
})
.array(),
});
export type TFindWebhookCallsRequest = z.infer<typeof ZFindWebhookCallsRequestSchema>;
export type TFindWebhookCallsResponse = z.infer<typeof ZFindWebhookCallsResponseSchema>;

View File

@ -0,0 +1,80 @@
import { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZResendWebhookCallRequestSchema,
ZResendWebhookCallResponseSchema,
} from './resend-webhook-call.types';
export const resendWebhookCallRoute = authenticatedProcedure
.input(ZResendWebhookCallRequestSchema)
.output(ZResendWebhookCallResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { webhookId, webhookCallId } = input;
ctx.logger.info({
input: { webhookId, webhookCallId },
});
const webhookCall = await prisma.webhookCall.findFirst({
where: {
id: webhookCallId,
webhook: {
id: webhookId,
team: buildTeamWhereQuery({
teamId,
userId: user.id,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_TEAM,
}),
},
},
include: {
webhook: true,
},
});
if (!webhookCall) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { webhook } = webhookCall;
// Note: This is duplicated in `execute-webhook.handler.ts`.
const response = await fetch(webhookCall.url, {
method: 'POST',
body: JSON.stringify(webhookCall.requestBody),
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': webhook.secret ?? '',
},
});
const body = await response.text();
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
try {
responseBody = JSON.parse(body);
} catch (err) {
responseBody = body;
}
return await prisma.webhookCall.update({
where: {
id: webhookCall.id,
},
data: {
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
responseCode: response.status,
responseBody,
responseHeaders: Object.fromEntries(response.headers.entries()),
},
});
});

View File

@ -0,0 +1,26 @@
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod';
import WebhookCallSchema from '@documenso/prisma/generated/zod/modelSchema/WebhookCallSchema';
export const ZResendWebhookCallRequestSchema = z.object({
webhookId: z.string(),
webhookCallId: z.string(),
});
export const ZResendWebhookCallResponseSchema = WebhookCallSchema.pick({
webhookId: true,
status: true,
event: true,
id: true,
url: true,
responseCode: true,
createdAt: true,
}).extend({
requestBody: z.unknown(),
responseHeaders: z.unknown().nullable(),
responseBody: z.unknown().nullable(),
});
export type TResendWebhookRequest = z.infer<typeof ZResendWebhookCallRequestSchema>;
export type TResendWebhookResponse = z.infer<typeof ZResendWebhookCallResponseSchema>;

View File

@ -6,66 +6,61 @@ import { getWebhooksByTeamId } from '@documenso/lib/server-only/webhooks/get-web
import { triggerTestWebhook } from '@documenso/lib/server-only/webhooks/trigger-test-webhook';
import { authenticatedProcedure, router } from '../trpc';
import { findWebhookCallsRoute } from './find-webhook-calls';
import { resendWebhookCallRoute } from './resend-webhook-call';
import {
ZCreateWebhookRequestSchema,
ZDeleteWebhookRequestSchema,
ZEditWebhookRequestSchema,
ZGetTeamWebhooksRequestSchema,
ZGetWebhookByIdRequestSchema,
ZTriggerTestWebhookRequestSchema,
} from './schema';
export const webhookRouter = router({
getTeamWebhooks: authenticatedProcedure
.input(ZGetTeamWebhooksRequestSchema)
.query(async ({ ctx, input }) => {
const { teamId } = input;
calls: {
find: findWebhookCallsRoute,
resend: resendWebhookCallRoute,
},
ctx.logger.info({
input: {
teamId,
},
});
getTeamWebhooks: authenticatedProcedure.query(async ({ ctx }) => {
ctx.logger.info({
input: {
teamId: ctx.teamId,
},
});
return await getWebhooksByTeamId(teamId, ctx.user.id);
}),
return await getWebhooksByTeamId(ctx.teamId, ctx.user.id);
}),
getWebhookById: authenticatedProcedure
.input(ZGetWebhookByIdRequestSchema)
.query(async ({ input, ctx }) => {
const { id, teamId } = input;
const { id } = input;
ctx.logger.info({
input: {
id,
teamId,
},
});
return await getWebhookById({
id,
userId: ctx.user.id,
teamId,
teamId: ctx.teamId,
});
}),
createWebhook: authenticatedProcedure
.input(ZCreateWebhookRequestSchema)
.mutation(async ({ input, ctx }) => {
const { enabled, eventTriggers, secret, webhookUrl, teamId } = input;
ctx.logger.info({
input: {
teamId,
},
});
const { enabled, eventTriggers, secret, webhookUrl } = input;
return await createWebhook({
enabled,
secret,
webhookUrl,
eventTriggers,
teamId,
teamId: ctx.teamId,
userId: ctx.user.id,
});
}),
@ -73,18 +68,17 @@ export const webhookRouter = router({
deleteWebhook: authenticatedProcedure
.input(ZDeleteWebhookRequestSchema)
.mutation(async ({ input, ctx }) => {
const { id, teamId } = input;
const { id } = input;
ctx.logger.info({
input: {
id,
teamId,
},
});
return await deleteWebhookById({
id,
teamId,
teamId: ctx.teamId,
userId: ctx.user.id,
});
}),
@ -92,12 +86,11 @@ export const webhookRouter = router({
editWebhook: authenticatedProcedure
.input(ZEditWebhookRequestSchema)
.mutation(async ({ input, ctx }) => {
const { id, teamId, ...data } = input;
const { id, ...data } = input;
ctx.logger.info({
input: {
id,
teamId,
},
});
@ -105,20 +98,19 @@ export const webhookRouter = router({
id,
data,
userId: ctx.user.id,
teamId,
teamId: ctx.teamId,
});
}),
testWebhook: authenticatedProcedure
.input(ZTriggerTestWebhookRequestSchema)
.mutation(async ({ input, ctx }) => {
const { id, event, teamId } = input;
const { id, event } = input;
ctx.logger.info({
input: {
id,
event,
teamId,
},
});
@ -126,7 +118,7 @@ export const webhookRouter = router({
id,
event,
userId: ctx.user.id,
teamId,
teamId: ctx.teamId,
});
}),
});

View File

@ -1,12 +1,6 @@
import { WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod';
export const ZGetTeamWebhooksRequestSchema = z.object({
teamId: z.number(),
});
export type TGetTeamWebhooksRequestSchema = z.infer<typeof ZGetTeamWebhooksRequestSchema>;
export const ZCreateWebhookRequestSchema = z.object({
webhookUrl: z.string().url(),
eventTriggers: z
@ -14,14 +8,12 @@ export const ZCreateWebhookRequestSchema = z.object({
.min(1, { message: 'At least one event trigger is required' }),
secret: z.string().nullable(),
enabled: z.boolean(),
teamId: z.number(),
});
export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookRequestSchema>;
export const ZGetWebhookByIdRequestSchema = z.object({
id: z.string(),
teamId: z.number(),
});
export type TGetWebhookByIdRequestSchema = z.infer<typeof ZGetWebhookByIdRequestSchema>;
@ -34,7 +26,6 @@ export type TEditWebhookRequestSchema = z.infer<typeof ZEditWebhookRequestSchema
export const ZDeleteWebhookRequestSchema = z.object({
id: z.string(),
teamId: z.number(),
});
export type TDeleteWebhookRequestSchema = z.infer<typeof ZDeleteWebhookRequestSchema>;
@ -42,7 +33,6 @@ export type TDeleteWebhookRequestSchema = z.infer<typeof ZDeleteWebhookRequestSc
export const ZTriggerTestWebhookRequestSchema = z.object({
id: z.string(),
event: z.nativeEnum(WebhookTriggerEvents),
teamId: z.number(),
});
export type TTriggerTestWebhookRequestSchema = z.infer<typeof ZTriggerTestWebhookRequestSchema>;

View File

@ -21,6 +21,8 @@ export interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
columnVisibility?: VisibilityState;
data: TData[];
onRowClick?: (row: TData) => void;
rowClassName?: string;
perPage?: number;
currentPage?: number;
totalPages?: number;
@ -52,6 +54,8 @@ export function DataTable<TData, TValue>({
hasFilters,
onClearFilters,
onPaginationChange,
onRowClick,
rowClassName,
children,
emptyState,
}: DataTableProps<TData, TValue>) {
@ -116,7 +120,12 @@ export function DataTable<TData, TValue>({
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={rowClassName}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}