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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
106
src/app/api/v1/admin/documenso-webhook/health/route.ts
Normal file
106
src/app/api/v1/admin/documenso-webhook/health/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
91
src/app/api/v1/admin/documenso-webhook/test/route.ts
Normal file
91
src/app/api/v1/admin/documenso-webhook/test/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user