feat(uat-p5): activity-feed module, signing-order tri-state, webhook health card

- Activity-feed: shared formatting module
  (src/components/shared/activity-formatting.ts) centralises action
  verbs, badge variants, entity-type labels, enum-value normalisation,
  shortValue, and buildDiffLine. The dashboard widget feed and the
  per-entity audit feed now both consume it - duplicate ~250 lines
  collapsed, vocabularies aligned, badge palette unified.
- Signing order setting becomes tri-state. The new
  TEMPLATE_DEFAULT value (the new default) skips overriding the
  template's own signingOrder so each Documenso template's stored
  setting wins. PARALLEL / SEQUENTIAL keep forcing the override.
- Admin Documenso page now ships a Webhook health card backed by
  /api/v1/admin/documenso-webhook/health (secret status,
  expected URL, last received event, recent secret rejections) and
  a "Test now" button that fires a synthetic DOCUMENT_OPENED through
  /api/v1/admin/documenso-webhook/test against the local receiver
  to verify the full pipeline without driving a real Documenso event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:05:14 +02:00
parent 6caf41651f
commit 909dd44605
9 changed files with 655 additions and 208 deletions

View File

@@ -4,6 +4,7 @@ import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-fo
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
import { EmbeddedSigningCard } from '@/components/admin/documenso/embedded-signing-card';
import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-button';
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -205,6 +206,8 @@ export default function DocumensoSettingsPage() {
/>
<EmbeddedSigningCard />
<WebhookHealthCard />
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server';
import { and, desc, eq, or, isNull } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { auditLogs, systemSettings } from '@/lib/db/schema/system';
import { errorResponse } from '@/lib/errors';
import { env } from '@/lib/env';
/**
* GET /api/v1/admin/documenso-webhook/health
*
* Surfaces the current state of the inbound Documenso webhook pipeline
* for the Documenso admin page's "Webhook health" card. Reads the
* port's resolved Documenso config + the most recent webhook events
* from audit_logs. Pairs with the `/test` POST that fires a synthetic
* webhook through the receiver to verify the full pipeline.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const [portSecretRow] = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(
and(
eq(systemSettings.key, 'documenso_webhook_secret'),
or(eq(systemSettings.portId, ctx.portId), isNull(systemSettings.portId)),
),
)
.limit(1);
const portWebhookSecret =
typeof portSecretRow?.value === 'string' && portSecretRow.value.length > 0
? portSecretRow.value
: null;
const envWebhookSecret = env.DOCUMENSO_WEBHOOK_SECRET ?? null;
const effectiveSecret = portWebhookSecret ?? envWebhookSecret;
const expectedUrl = `${env.APP_URL?.replace(/\/$/, '') ?? ''}/api/webhooks/documenso`;
// Most recent successful webhook landing (entityType=webhook_inbound,
// action != 'webhook_failed'). The receiver writes one audit row per
// canonical event; we surface the latest so reps see "yes, traffic
// is flowing" rather than guessing from logs.
const [lastReceived] = await db
.select({
id: auditLogs.id,
createdAt: auditLogs.createdAt,
action: auditLogs.action,
metadata: auditLogs.metadata,
})
.from(auditLogs)
.where(
and(eq(auditLogs.entityType, 'webhook_inbound'), eq(auditLogs.entityId, 'documenso')),
)
.orderBy(desc(auditLogs.createdAt))
.limit(1);
// Latest secret-mismatch entry - flags the "Documenso is hitting us
// with a wrong secret" failure mode separately from "we haven't
// heard anything." Combined with `secretConfigured=false` it
// narrows the problem to a misalignment between the secret we
// stored and what Documenso is sending.
const [lastFailed] = await db
.select({
id: auditLogs.id,
createdAt: auditLogs.createdAt,
metadata: auditLogs.metadata,
})
.from(auditLogs)
.where(
and(
eq(auditLogs.entityType, 'webhook_inbound'),
eq(auditLogs.entityId, 'documenso'),
eq(auditLogs.action, 'webhook_failed'),
),
)
.orderBy(desc(auditLogs.createdAt))
.limit(1);
return NextResponse.json({
data: {
secretConfigured: Boolean(effectiveSecret),
secretSource: portWebhookSecret ? 'port' : envWebhookSecret ? 'env' : null,
expectedUrl,
lastReceived: lastReceived
? {
id: lastReceived.id,
receivedAt: lastReceived.createdAt,
action: lastReceived.action,
metadata: lastReceived.metadata,
}
: null,
lastFailed: lastFailed
? {
id: lastFailed.id,
receivedAt: lastFailed.createdAt,
metadata: lastFailed.metadata,
}
: null,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,91 @@
import { NextResponse } from 'next/server';
import { and, eq, isNull, or } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { ValidationError, errorResponse } from '@/lib/errors';
import { env } from '@/lib/env';
/**
* POST /api/v1/admin/documenso-webhook/test
*
* Fires a synthetic Documenso webhook against the local receiver to
* verify the full pipeline: secret check, body parsing, dedup,
* audit-log write. Echoes the receiver's response back to the
* caller so the admin page can render success/failure inline.
*
* Body: { event?: string } - defaults to 'DOCUMENT_OPENED' which is
* the lowest-impact event (no DB mutations beyond the audit-log
* write).
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const [portSecretRow] = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(
and(
eq(systemSettings.key, 'documenso_webhook_secret'),
or(eq(systemSettings.portId, ctx.portId), isNull(systemSettings.portId)),
),
)
.limit(1);
const portWebhookSecret =
typeof portSecretRow?.value === 'string' && portSecretRow.value.length > 0
? portSecretRow.value
: null;
const effectiveSecret = portWebhookSecret ?? env.DOCUMENSO_WEBHOOK_SECRET ?? null;
if (!effectiveSecret) {
throw new ValidationError(
'No Documenso webhook secret configured. Set documenso_webhook_secret or DOCUMENSO_WEBHOOK_SECRET first.',
);
}
const body = (await req.json().catch(() => ({}))) as { event?: string };
const event = body.event ?? 'DOCUMENT_OPENED';
// Synthetic payload that the receiver will accept and audit-log.
// documentId is namespaced so a real Documenso doc can never
// collide with this test ping.
const payload = {
event,
payload: {
id: `test-${Date.now()}`,
status: 'PENDING',
recipients: [],
},
};
const url = `${env.APP_URL?.replace(/\/$/, '') ?? ''}/api/webhooks/documenso`;
const startedAt = Date.now();
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': effectiveSecret,
},
body: JSON.stringify(payload),
});
const elapsedMs = Date.now() - startedAt;
const responseBody = await res.text();
let parsed: unknown = responseBody;
try {
parsed = JSON.parse(responseBody);
} catch {
// non-JSON response, surface as-is
}
return NextResponse.json({
data: {
ok: res.ok,
status: res.status,
elapsedMs,
response: parsed,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);