feat(backend): Add webhook delivery system for card view events#590
feat(backend): Add webhook delivery system for card view events#590Dipti45sktech wants to merge 3 commits into
Conversation
|
@Dipti45sktech is attempting to deploy a commit to the Prashantkumar Khatri's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Hi @Dipti45sktech, Thanks for opening this pull request. This PR has been automatically classified based on the files modified. Applied Labels
Primary Review Area
Reviewer@Harxhit has been identified as the primary reviewer for this pull request. If you have any questions regarding the affected area or implementation details, feel free to reach out to the assigned reviewer. Thank you for your contribution! |
CI — Checks FailedBackend — FAIL
Mobile — SKIP
Web — SKIP
Last updated: |
|
Hi @ShantKhatri please reassign me on issue #40 - I had completed this implementation earlier but the PR had to be closed due to branch conflicts. This is a clean resubmission of the same work. Kindly take a look. |
Harxhit
left a comment
There was a problem hiding this comment.
Review of the webhook delivery system. The route/dispatch design is solid (HMAC signing, encrypted secret at rest, ownership checks, endpoint cap), but there are blocking issues: the Prisma schema is corrupted, the core WebhookEndpoint model is missing, and dispatchWebhook is never called from any event handler — so card-view/contact-save events don't actually trigger anything as shipped. Inline comments below. Note: this PR also carries unrelated changes (CI lint removal, app.ts/auth/seed rewrites) that I'd recommend splitting out.
| ownedTeams Team[] @relation("TeamOwner") | ||
| teamMemberships TeamMember[] @relation("TeamMember") | ||
|
|
||
| webhookEndpoints WebhookEndpoint[] |
There was a problem hiding this comment.
User.webhookEndpoints references a WebhookEndpoint model that is never defined anywhere in the schema (the routes call prisma.webhookEndpoint.*). prisma generate will fail. The model needs to be added, e.g.:
model WebhookEndpoint {
id String @id @default(uuid())
userId String @map("user_id")
url String
secret String
events String[]
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deliveries WebhookDelivery[]
@@index([userId])
@@map("webhook_endpoints")
}| userId String | ||
| eventId String | ||
| joinedAt DateTime | ||
| joinedAt DateTime |
There was a problem hiding this comment.
EventAttendee lost its relations and @@unique, and runs straight into WebhookDelivery with no separator (note the stray } on the next line). This breaks the existing EventAttendee model.
| joinedAt DateTime | |
| joinedAt DateTime | |
| event Event @relation(fields: [eventId], references: [id]) | |
| user User @relation(fields: [userId], references: [id]) | |
| @@unique([userId, eventId]) | |
| } | |
| model WebhookDelivery { |
| joinedAt DateTime | ||
| joinedAt DateTime | ||
| } | ||
| model WebhookDelivery { |
There was a problem hiding this comment.
As written, this WebhookDelivery block wrongly inherited EventAttendee's event/user relations and @@unique([userId, eventId]) (lines ~210-213). It should relate to the endpoint instead:
endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade)| createdAt DateTime @default(now()) @map("created_at") | ||
| updatedAt DateTime @updatedAt @map("updated_at") | ||
| errorMessage String? @map("error_message") | ||
| deliveredAt DateTime? @map("delivered_at") |
There was a problem hiding this comment.
errorMessage and deliveredAt are added here but deliverWebhook in webhookDispatch.ts never writes them — no error captured on failure, no timestamp on success. Either populate them in the delivery update calls or drop the columns.
| cookieName: 'token', | ||
| signed: false, | ||
| }, | ||
| secret: process.env.JWT_SECRET || 'dev-secret-change-me', |
There was a problem hiding this comment.
Regression / security downgrade: this replaces a validated, required JWT_SECRET with a hardcoded fallback, and drops the cookie config. A predictable signing key is used if the env var is unset.
| secret: process.env.JWT_SECRET || 'dev-secret-change-me', | |
| secret: process.env.JWT_SECRET!, | |
| cookie: { | |
| cookieName: 'token', | |
| signed: false, | |
| }, |
| secret: process.env.JWT_SECRET || 'dev-secret-change-me', | ||
| }); | ||
|
|
||
| await app.register(cookie); |
There was a problem hiding this comment.
cookie is already registered a few lines above — this duplicate registration will throw FST_ERR_PLUGIN_ALREADY_PRESENT. Remove it.
| await app.register(cookie); |
| // token is rejected immediately even if it has not yet expired. | ||
| // The blocklist check is skipped when Redis is not registered (test env). | ||
| app.decorate('authenticate', async function (request: FastifyRequest, reply: FastifyReply) { | ||
| app.decorate('authenticate', async function (request: any, reply: any) { |
There was a problem hiding this comment.
Don't loosen the handler signature to any, any — keep the typed Fastify signature (this also reverts existing typing).
| app.decorate('authenticate', async function (request: any, reply: any) { | |
| app.decorate('authenticate', async function (request: FastifyRequest, reply: FastifyReply) { |
| }, | ||
| }, async (request: FastifyRequest, reply: FastifyReply) => { | ||
| const userId = (request.user as any).id; | ||
| const limit = Math.min(100, parseInt((request.query as any).limit || '20', 10)); |
There was a problem hiding this comment.
The Fastify querystring schema already coerces limit to integer with a default of 20, so parseInt(... || '20', 10) runs parseInt on a number. Redundant — rely on the schema:
| const limit = Math.min(100, parseInt((request.query as any).limit || '20', 10)); | |
| const limit = (request.query as any).limit ?? 20; |
| }); | ||
|
|
||
| // Schedule retry (non-blocking, in-process) | ||
| setTimeout(() => { |
There was a problem hiding this comment.
Retries are scheduled with in-process setTimeout (up to 30 min). Any process restart silently drops all pending retries, and the persisted nextRetryAt + @@index([status, nextRetryAt]) you added are never used to recover them. Worth a comment acknowledging the limitation, or a follow-up to drive retries from a DB poller — otherwise the persisted retry state is misleading.
Separately: dispatchWebhook (defined in this file) has no call site anywhere in the diff, so no card-view/contact-save handler actually triggers delivery. It needs to be wired into those event handlers.
Summary
Implements the full webhook delivery system for card view and contact-save events as specified in issue #40. This includes endpoint registration, HMAC-SHA256 payload signing, exponential backoff retry logic, delivery logging, and full test coverage. The idea is simple — when someone views a card or saves a contact, any external system that has registered a webhook endpoint should get notified automatically with a signed POST request.
Closes #40
Type of Change
What Changed
WebhookEndpointandWebhookDeliverymodels to Prisma schemaapps/backend/src/routes/webhooks.tswith 4 REST endpoints (POST to register, GET to list, DELETE to remove, GET deliveries for logs)apps/backend/src/utils/webhookDispatch.tswith HMAC-SHA256 payload signing viax-DevCard-Signatureheader and retry logic with exponential backoff (30s - 5min - 30min)dispatchWebhookinto card view and contact-save events inapp.tswebhooks.test.tscovering endpoint registration, signature generation, and delivery retry logic.How to Test
pnpm -r run test- all 17 webhook tests should passPOST /api/webhooksx-DevCard-SignatureheaderChecklist
pnpm -r run test).console.logor debug statements left in the code.