From 182ccbb30bdaed33ee565465fd2d79cdbc881d8b Mon Sep 17 00:00:00 2001 From: Corentin Thomasset Date: Sat, 25 Oct 2025 02:12:57 +0200 Subject: [PATCH] fix(webhooks): corrected webhooks last triggered date (#582) --- .changeset/poor-seals-try.md | 5 ++ .../webhooks/webhook.repository.test.ts | 50 +++++++++++++++++++ .../modules/webhooks/webhook.repository.ts | 18 +++++-- 3 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 .changeset/poor-seals-try.md create mode 100644 apps/papra-server/src/modules/webhooks/webhook.repository.test.ts diff --git a/.changeset/poor-seals-try.md b/.changeset/poor-seals-try.md new file mode 100644 index 0000000..d01ed04 --- /dev/null +++ b/.changeset/poor-seals-try.md @@ -0,0 +1,5 @@ +--- +"@papra/docker": patch +--- + +Fixed the webhook last triggered date always showing "never" in the webhook list. diff --git a/apps/papra-server/src/modules/webhooks/webhook.repository.test.ts b/apps/papra-server/src/modules/webhooks/webhook.repository.test.ts new file mode 100644 index 0000000..86b781a --- /dev/null +++ b/apps/papra-server/src/modules/webhooks/webhook.repository.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'vitest'; +import { createInMemoryDatabase } from '../app/database/database.test-utils'; +import { createWebhookRepository } from './webhook.repository'; + +describe('webhook repository', () => { + describe('getOrganizationWebhooks', () => { + test('includes the most recent webhook delivery timestamp as lastTriggeredAt', async () => { + const { db } = await createInMemoryDatabase({ + organizations: [ + { id: 'org_1', name: 'Test Organization' }, + ], + webhooks: [ + { id: 'wbh_1', name: 'Test Webhook', url: 'https://example.com/webhook', organizationId: 'org_1' }, + ], + webhookDeliveries: [ + { id: 'wbh_dlv_1', webhookId: 'wbh_1', eventName: 'document:created', requestPayload: '{}', responsePayload: '{}', responseStatus: 200, createdAt: new Date('2025-01-01') }, + { id: 'wbh_dlv_2', webhookId: 'wbh_1', eventName: 'document:updated', requestPayload: '{}', responsePayload: '{}', responseStatus: 200, createdAt: new Date('2025-01-15') }, + { id: 'wbh_dlv_3', webhookId: 'wbh_1', eventName: 'document:deleted', requestPayload: '{}', responsePayload: '{}', responseStatus: 200, createdAt: new Date('2025-01-10') }, + ], + }); + + const webhookRepository = createWebhookRepository({ db }); + const { webhooks } = await webhookRepository.getOrganizationWebhooks({ organizationId: 'org_1' }); + + expect(webhooks).to.have.length(1); + const [webhook] = webhooks; + + expect(webhook?.lastTriggeredAt).to.eql(new Date('2025-01-15')); + }); + + test('no deliveries results in null lastTriggeredAt', async () => { + const { db } = await createInMemoryDatabase({ + organizations: [ + { id: 'org_1', name: 'Test Organization' }, + ], + webhooks: [ + { id: 'wbh_1', name: 'Test Webhook', url: 'https://example.com/webhook', organizationId: 'org_1' }, + ], + }); + + const webhookRepository = createWebhookRepository({ db }); + const { webhooks } = await webhookRepository.getOrganizationWebhooks({ organizationId: 'org_1' }); + + expect(webhooks).to.have.length(1); + const [webhook] = webhooks; + + expect(webhook?.lastTriggeredAt).to.eql(null); + }); + }); +}); diff --git a/apps/papra-server/src/modules/webhooks/webhook.repository.ts b/apps/papra-server/src/modules/webhooks/webhook.repository.ts index 9b888bd..fc9eb50 100644 --- a/apps/papra-server/src/modules/webhooks/webhook.repository.ts +++ b/apps/papra-server/src/modules/webhooks/webhook.repository.ts @@ -2,7 +2,7 @@ import type { EventName } from '@papra/webhooks'; import type { Database } from '../app/database/database.types'; import type { Webhook, WebhookDeliveryInsert, WebhookEvent } from './webhooks.types'; import { injectArguments } from '@corentinth/chisels'; -import { and, eq, getTableColumns } from 'drizzle-orm'; +import { and, eq, getTableColumns, max } from 'drizzle-orm'; import { omitUndefined } from '../shared/utils'; import { webhookDeliveriesTable, webhookEventsTable, webhooksTable } from './webhooks.tables'; @@ -113,14 +113,25 @@ async function deleteOrganizationWebhook({ db, webhookId, organizationId }: { db } async function getOrganizationWebhooks({ db, organizationId }: { db: Database; organizationId: string }) { + // Create a subquery for the latest delivery date per webhook + const latestDeliverySubquery = db + .select({ + webhookId: webhookDeliveriesTable.webhookId, + lastTriggeredAt: max(webhookDeliveriesTable.createdAt).as('last_triggered_at'), + }) + .from(webhookDeliveriesTable) + .groupBy(webhookDeliveriesTable.webhookId) + .as('latest_delivery'); + const rawWebhooks = await db .select() .from(webhooksTable) .leftJoin(webhookEventsTable, eq(webhooksTable.id, webhookEventsTable.webhookId)) + .leftJoin(latestDeliverySubquery, eq(webhooksTable.id, latestDeliverySubquery.webhookId)) .where(eq(webhooksTable.organizationId, organizationId)); const webhooksRecord = rawWebhooks - .reduce((acc, { webhooks, webhook_events }) => { + .reduce((acc, { webhooks, webhook_events, latest_delivery }) => { const webhookId = webhooks.id; const webhookEvents = webhook_events; @@ -128,6 +139,7 @@ async function getOrganizationWebhooks({ db, organizationId }: { db: Database; o acc[webhookId] = { ...webhooks, events: [], + lastTriggeredAt: latest_delivery?.lastTriggeredAt ?? null, }; } @@ -136,7 +148,7 @@ async function getOrganizationWebhooks({ db, organizationId }: { db: Database; o } return acc; - }, {} as Record); + }, {} as Record); return { webhooks: Object.values(webhooksRecord) }; }