-
-
Notifications
You must be signed in to change notification settings - Fork 285
webhooks baby #294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
webhooks baby #294
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughAdds a full webhook subsystem: DB schema (Webhook, WebhookCall, enums, indexes, FKs); backend services (WebhookService and WebhookQueueService) with BullMQ dispatch, retries, locking, signing, and SDK verification utilities; TRPC router endpoints for webhook management and call operations; integrations emitting webhook events from contact, domain, and SES/email processing; UI for creating, editing, testing, and viewing webhooks and calls; a daily cleanup job; queue constants; workspace package Possibly related PRs
Suggested labels
Pre-merge checks❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Deploying usesend with
|
| Latest commit: |
b4e1dfc
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://a7f7a18a.usesend.pages.dev |
| Branch Preview URL: | https://km-2025-11-17-webhook.usesend.pages.dev |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/src/app/(dashboard)/emails/email-details.tsx (1)
18-22: Revert import to use the package's main entry point instead of direct src path.The constants
BOUNCE_ERROR_MESSAGES,COMPLAINT_ERROR_MESSAGES, andDELIVERY_DELAY_ERRORSare already re-exported from the@usesend/libpackage's main entry point (index.ts). The direct src path import bypasses this and creates inconsistency—seeapps/web/src/server/api/routers/email.tswhich correctly importsBOUNCE_ERROR_MESSAGESfrom@usesend/libdirectly.Change:
import { BOUNCE_ERROR_MESSAGES, COMPLAINT_ERROR_MESSAGES, DELIVERY_DELAY_ERRORS, } from "@usesend/lib";
🧹 Nitpick comments (6)
apps/web/src/server/jobs/webhook-cleanup-job.ts (2)
10-34: Consider lazy initialization to avoid module-level side effects.The queue and worker are instantiated at module load time, which means importing this file triggers immediate connections to Redis. This can cause issues during builds, testing, or when imported in contexts where Redis isn't available.
Consider wrapping initialization in a factory function:
-const webhookCleanupQueue = new Queue(WEBHOOK_CLEANUP_QUEUE, { - connection: getRedis(), -}); - -const worker = new Worker( - WEBHOOK_CLEANUP_QUEUE, - async () => { +let webhookCleanupQueue: Queue | null = null; +let worker: Worker | null = null; + +export function initWebhookCleanupJob() { + if (webhookCleanupQueue) return { queue: webhookCleanupQueue, worker }; + + webhookCleanupQueue = new Queue(WEBHOOK_CLEANUP_QUEUE, { + connection: getRedis(), + }); + + worker = new Worker( + WEBHOOK_CLEANUP_QUEUE, + async () => { // ... handler logic + }, + { connection: getRedis() } + ); + // ... event handlers and scheduler setup + return { queue: webhookCleanupQueue, worker }; +}
49-55: Consider adding graceful shutdown handling.The worker lacks shutdown handling, which could lead to incomplete jobs or connection leaks when the process terminates.
process.on("SIGTERM", async () => { await worker.close(); await webhookCleanupQueue.close(); });apps/web/src/server/service/domain-service.ts (1)
10-13: Domain webhook payload and emit helper are well-structured and fail-safe
buildDomainPayloadcorrectly mapsDomainfields toDomainPayload(including ISO timestamps and optional tracking/SES fields), andemitDomainEventwrapsWebhookService.emitin atry/catch, ensuring domain operations won’t fail due to webhook issues. Usingdomain.updatedAt.toISOString()as the eventcreatedAtis a reasonable choice to represent the state-change time.If you ever want finer-grained event timing (e.g., transient verification checks), consider passing
new Date().toISOString()forcreatedAtand keeping theupdatedAtvalue only insidedataas the domain’s own timestamp.Also applies to: 16-16, 90-106, 387-401
apps/web/src/server/service/ses-hook-parser.ts (1)
308-371: Email webhook payload, status mapping, and metadata extraction look consistent with SES events
EmailBounceSubTypeandnormalizeBounceSubTypecorrectly normalize SES bounce subtypes (trimming whitespace and falling back to"General"when unknown), matching the allowed webhook payload union.buildEmailWebhookPayloadbuilds a solidEmailBasePayloadand augments it forBOUNCED,OPENED, andCLICKEDstatuses using the appropriate SES event fragments; for other statuses it returns the base payload, which is a sensible default.emailStatusToEventprovides a clear mapping fromEmailStatustoEmailWebhookEventType, covering all SES-driven statuses used here; the default case will not be hit in this code path becausegetEmailStatusalready constrains the values.buildEmailMetadataextracts status-specific details (bounce diagnostics, complaint feedback, open/click info, rendering failures, delivery delays, and rejects) into a structured metadata object, which complements the main payload well.Overall, the typing and extraction logic are coherent with SES event structures and the shared webhook types in
@usesend/lib.If you want to avoid the mild duplication between
buildEmailWebhookPayloadandbuildEmailMetadatafor some fields, you could eventually consolidate them into a single builder that returns both payload and metadata, but it’s fine to keep them separate for now.Also applies to: 373-396, 398-428, 431-490
apps/web/src/server/service/webhook-service.ts (2)
43-71: Static initialization of queue/worker may cause issues during cold starts.The
QueueandWorkerare instantiated at module load time via static class properties. If Redis is unavailable when the module is first imported, this will throw and potentially crash the application. Consider lazy initialization or a separate initialization function called during app startup.// Alternative: lazy initialization pattern export class WebhookQueueService { private static _queue: Queue<WebhookCallJobData> | null = null; private static _worker: Worker | null = null; private static get queue() { if (!this._queue) { this._queue = new Queue<WebhookCallJobData>(WEBHOOK_DISPATCH_QUEUE, { connection: getRedis(), defaultJobOptions: { /* ... */ }, }); } return this._queue; } static initialize() { // Initialize worker on explicit call during app startup if (!this._worker) { this._worker = new Worker(/* ... */); this._worker.on("error", (error) => { /* ... */ }); } } // ... }
263-274: Double retry scheduling when lock acquisition fails.When lock acquisition fails, the code both sets
nextAttemptAtin the database (line 268) and throws an error (line 273), causing BullMQ to schedule its own retry with exponential backoff. This results in two competing retry mechanisms.Consider either:
- Not throwing and returning early (call stays PENDING, background job retries later)
- Not setting
nextAttemptAtsince BullMQ handles retry timingif (!lockAcquired) { - await db.webhookCall.update({ - where: { id: call.id }, - data: { - nextAttemptAt: new Date(Date.now() + WEBHOOK_LOCK_RETRY_DELAY_MS), - status: WebhookCallStatus.PENDING, - }, - }); - // Let BullMQ handle retry timing; this records observability. - throw new Error("Webhook lock not acquired"); + // Let BullMQ handle retry with its backoff strategy + throw new Error("Webhook lock not acquired, will retry"); }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (19)
apps/web/package.json(1 hunks)apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql(1 hunks)apps/web/prisma/schema.prisma(3 hunks)apps/web/src/app/(dashboard)/emails/email-details.tsx(2 hunks)apps/web/src/server/api/root.ts(2 hunks)apps/web/src/server/api/routers/email.ts(2 hunks)apps/web/src/server/api/routers/webhook.ts(1 hunks)apps/web/src/server/jobs/webhook-cleanup-job.ts(1 hunks)apps/web/src/server/queue/queue-constants.ts(1 hunks)apps/web/src/server/service/domain-service.ts(8 hunks)apps/web/src/server/service/ses-hook-parser.ts(3 hunks)apps/web/src/server/service/webhook-service.ts(1 hunks)apps/web/tailwind.config.ts(1 hunks)packages/lib/.eslintrc.cjs(1 hunks)packages/lib/package.json(1 hunks)packages/lib/src/index.ts(1 hunks)packages/lib/src/webhook/webhook-events.ts(1 hunks)packages/lib/tsconfig.json(1 hunks)packages/lib/tsconfig.lint.json(1 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/server/queue/queue-constants.tsapps/web/src/server/api/routers/email.tsapps/web/tailwind.config.tspackages/lib/src/index.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/api/routers/webhook.tsapps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/server/api/root.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/webhook-service.tsapps/web/src/app/(dashboard)/emails/email-details.tsxpackages/lib/src/webhook/webhook-events.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/server/queue/queue-constants.tsapps/web/src/server/api/routers/email.tsapps/web/tailwind.config.tspackages/lib/src/index.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/api/routers/webhook.tsapps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/server/api/root.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/webhook-service.tsapps/web/src/app/(dashboard)/emails/email-details.tsxpackages/lib/src/webhook/webhook-events.ts
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/server/queue/queue-constants.tsapps/web/src/server/api/routers/email.tsapps/web/tailwind.config.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/api/routers/webhook.tsapps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/server/api/root.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/webhook-service.tsapps/web/src/app/(dashboard)/emails/email-details.tsx
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/server/queue/queue-constants.tsapps/web/src/server/api/routers/email.tsapps/web/tailwind.config.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/api/routers/webhook.tsapps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/server/api/root.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/webhook-service.tsapps/web/src/app/(dashboard)/emails/email-details.tsx
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/server/queue/queue-constants.tsapps/web/src/server/api/routers/email.tsapps/web/tailwind.config.tspackages/lib/src/index.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/api/routers/webhook.tsapps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/server/api/root.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/webhook-service.tsapps/web/src/app/(dashboard)/emails/email-details.tsxpackages/lib/src/webhook/webhook-events.ts
**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
React components must use PascalCase naming convention (e.g., AppSideBar.tsx)
Files:
apps/web/src/app/(dashboard)/emails/email-details.tsx
🧠 Learnings (8)
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Applied to files:
packages/lib/tsconfig.jsonapps/web/tailwind.config.tspackages/lib/package.jsonpackages/lib/.eslintrc.cjspackages/lib/tsconfig.lint.json
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Run ESLint via usesend/eslint-config and ensure no warnings remain before submitting PRs
Applied to files:
packages/lib/tsconfig.jsonapps/web/tailwind.config.tspackages/lib/package.jsonpackages/lib/.eslintrc.cjspackages/lib/tsconfig.lint.json
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx,js,jsx} : Use alias `~/` for src imports in apps/web (e.g., `import { x } from "~/utils/x"`)
Applied to files:
apps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:13:56.758Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: .cursor/rules/general.mdc:0-0
Timestamp: 2025-11-28T21:13:56.758Z
Learning: Applies to **/*.{tsx,ts,jsx,js} : Include all required imports and ensure proper naming of key components in React/NextJS code
Applied to files:
apps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,md} : Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Applied to files:
apps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Never use dynamic imports; always import on the top level
Applied to files:
apps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx} : Use Prisma for database access in apps/web
Applied to files:
apps/web/prisma/schema.prisma
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx} : Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Applied to files:
apps/web/src/server/api/routers/webhook.tsapps/web/src/server/api/root.ts
🧬 Code graph analysis (4)
apps/web/src/server/service/domain-service.ts (4)
packages/lib/src/webhook/webhook-events.ts (2)
DomainPayload(74-88)DomainWebhookEventType(6-10)apps/web/src/server/db.ts (1)
db(20-20)apps/web/src/server/service/webhook-service.ts (1)
WebhookService(85-199)apps/web/src/server/logger/log.ts (1)
logger(31-63)
apps/web/src/server/jobs/webhook-cleanup-job.ts (4)
apps/web/src/server/queue/queue-constants.ts (2)
WEBHOOK_CLEANUP_QUEUE(7-7)DEFAULT_QUEUE_OPTIONS(9-14)apps/web/src/server/redis.ts (1)
getRedis(6-13)apps/web/src/server/db.ts (1)
db(20-20)apps/web/src/server/logger/log.ts (1)
logger(31-63)
apps/web/src/server/api/root.ts (1)
apps/web/src/server/api/routers/webhook.ts (1)
webhookRouter(23-216)
apps/web/src/server/service/webhook-service.ts (5)
apps/web/src/server/queue/bullmq-context.ts (2)
TeamJob(5-5)createWorkerHandler(10-24)apps/web/src/server/queue/queue-constants.ts (2)
WEBHOOK_DISPATCH_QUEUE(6-6)DEFAULT_QUEUE_OPTIONS(9-14)apps/web/src/server/redis.ts (1)
getRedis(6-13)apps/web/src/server/db.ts (1)
db(20-20)apps/web/src/server/service/idempotency-service.ts (2)
acquireLock(69-79)releaseLock(81-84)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: cubic · AI code reviewer
- GitHub Check: Cloudflare Pages
🔇 Additional comments (18)
packages/lib/tsconfig.json (1)
1-8: LGTM!Standard TypeScript configuration for the new library package. Properly extends the shared base config and correctly scopes the build to
srcwhile excluding build artifacts.packages/lib/tsconfig.lint.json (1)
6-6: Verify the include patterns are intentional.The include pattern
["src", "turbo", "**/*.ts", "**/*.tsx"]differs significantly from the build config's["src/**/*.ts", "src/**/*.tsx"]. The**/*.tspattern already encompassessrc, making"src"redundant. Also,"turbo"seems unusual for a library package unless there are TypeScript files in aturbo/directory.Consider aligning with the build config:
- "include": ["src", "turbo", "**/*.ts", "**/*.tsx"], + "include": ["src/**/*.ts", "src/**/*.tsx"],packages/lib/.eslintrc.cjs (1)
1-10: LGTM!ESLint configuration correctly extends the shared library config and sets up TypeScript-aware linting with proper project references. As per coding guidelines, this uses the
@usesend/eslint-config.apps/web/src/server/queue/queue-constants.ts (1)
6-7: LGTM!New webhook queue constants follow the established naming pattern and are appropriately named for their purposes.
packages/lib/src/index.ts (1)
1-22: Utility helpers and SES error exports look solid
invariant,assertUnreachable, andisDefinedare idiomatic and correctly typed, and re-exporting SES error constants here gives a clean central surface for other packages. No changes needed.apps/web/tailwind.config.ts (1)
10-10: Including@usesend/libin Tailwind content is correctThe new glob for
@usesend/libmirrors existing patterns for internal packages and will prevent class purging for shared components/utilities. Looks good.apps/web/package.json (1)
42-42: Workspace dependency wiring for@usesend/libis appropriateAdding
@usesend/libas a workspace dependency matches its usage in the web app and enables Tailwind/content resolution. No further changes needed here.apps/web/src/server/api/routers/email.ts (1)
5-5: Bounce mapping now sourced from@usesend/liband remains behaviorally equivalentSwitching
BOUNCE_ERROR_MESSAGESto@usesend/libcentralizes SES-related constants without changing lookup behavior; the export logic for non-bounced emails andbounceReasoncomputation is unchanged apart from formatting. Assuming the constant shape is preserved in the lib package, this is safe.Please double-check that
BOUNCE_ERROR_MESSAGESin@usesend/libhasPermanent,Transient, andUndeterminedkeys with the same subtype strings as the previous~/lib/constants/ses-errorsversion to avoid subtle mismatches.Also applies to: 203-209, 217-219
apps/web/src/server/api/root.ts (1)
16-16: Webhook router integration intoappRouterlooks correctImporting
webhookRouterand exposing it aswebhookonappRouterfollows the existing routing pattern (e.g.,campaign, etc.) and will surface webhook procedures via tRPC as expected.Also applies to: 23-39
apps/web/src/server/service/domain-service.ts (1)
177-183: Domain lifecycle webhook emissions are correctly placed, with one nuance on verification transitions
createDomain: Emittingdomain.createdright after the DB insert is correct, and the helper’s internal catch ensures this doesn’t impact the create flow.getDomain(verifying path): CapturingpreviousStatusand only emitting whenstatuschanges avoids noisy duplicate events. MappingDomainStatus.SUCCESS→"domain.verified"and all other changes →"domain.updated"is sensible.updateDomain: Emitting"domain.updated"after toggling tracking flags is aligned with expectations.deleteDomain: Emitting"domain.deleted"after SES + DB deletion, using the pre-deletedomainsnapshot, gives consumers all relevant data about the removed domain.All of these calls rely on
emitDomainEvent’s internal error handling, which is appropriate for side-effectful webhooks.Please confirm that
DomainStatus.SUCCESSis indeed the only “verified” terminal status; if additional success-like statuses exist in the enum, you may want to expand the condition when choosing between"domain.verified"and"domain.updated".Also applies to: 207-228, 230-233, 235-318, 320-332, 334-358
apps/web/src/server/service/ses-hook-parser.ts (1)
4-11: SES hook now emits webhooks in a safe, non-intrusive wayThe new block in
parseSesHookemits a webhook after core processing (status update, metrics, suppression, andEmailEventcreation), and wraps the emission in atry/catchthat only logs on error. This ensures SES callbacks still succeed even if the webhook subsystem is degraded. The event timestamp (occurredAt) derived fromdata.mail.timestampwith anew Date()fallback is reasonable for event ordering.Given this function already runs inside a BullMQ worker, please confirm in tests that webhook emission latency doesn’t materially impact queue throughput (especially under high SES event volume); if it does, you may want to enqueue a separate job for webhook fan-out.
Also applies to: 33-33, 279-305
apps/web/prisma/schema.prisma (1)
449-507: Well-structured webhook schema with appropriate indexes and relations.The webhook models are well-designed:
- Appropriate cascade deletes on team/webhook deletion
- Composite index on
(teamId, webhookId, status)supports thelistCallsquery patterncreatedAt DESCindex enables efficient paginationconsecutiveFailuresandlastFailureAt/lastSuccessAtfields support the auto-disable logicapps/web/src/server/api/routers/webhook.ts (1)
23-29: LGTM on query endpoints and pagination.The
list,listCalls,getCall, andretryCallendpoints are well-implemented:
- Proper team scoping via
ctx.team.id- Cursor-based pagination with
take: limit + 1pattern is correct- Appropriate error handling for not-found cases
Also applies to: 158-189, 191-215
apps/web/src/server/service/webhook-service.ts (3)
500-507:redirect: "manual"prevents automatic redirect following.Setting
redirect: "manual"means 3xx responses won't be followed automatically. A 301/302 response will be treated as a non-2xx failure. If this is intentional (security measure to prevent SSRF-like redirects to internal services), document it. Otherwise, consider usingredirect: "follow"with a limit.
85-133: Well-designed emit method with proper payload construction.The
emitmethod correctly:
- Constructs a typed payload with default ID and timestamp
- Queries only active webhooks matching the event type
- Creates call records and enqueues them asynchronously
- Uses early return for no matching webhooks
379-405: Correct distributed lock and HMAC signing implementation.
acquireLockusesSET ... PX ... NXcorrectly for atomic lock acquisitionreleaseLockuses Lua script to atomically check-and-delete, preventing release of locks held by otherssignBodyuses HMAC-SHA256 with timestamp prefix, following standard webhook signing patternsAlso applies to: 548-552
apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql (1)
1-66: Migration is well-structured and aligns with Prisma schema.The migration correctly:
- Creates both enums with appropriate states
- Establishes proper foreign key relationships with CASCADE behavior
- Adds indexes supporting the main query patterns (team lookup, call filtering, pagination)
- Uses DESC ordering on
createdAtindex for efficient "latest first" queriespackages/lib/src/webhook/webhook-events.ts (1)
1-181: Comprehensive and well-typed webhook event definitions.The type system is well-designed:
- Clear separation of event categories (Contact, Domain, Email)
- Discriminated union pattern with mapped types enables type-safe payload handling
- Specialized payload types for events with additional data (bounced, failed, suppressed, opened, clicked)
- Generic
WebhookEvent<TType>provides strong typing at consumption sites
| import { createTRPCRouter, teamProcedure } from "~/server/api/trpc"; | ||
| import { db } from "~/server/db"; | ||
| import { Plan, WebhookCallStatus, WebhookStatus } from "@prisma/client"; | ||
| import { WEBHOOK_EVENT_TYPES } from "~/server/service/webhook-events"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for webhook-events files throughout the codebase
echo "=== Finding webhook-events files ==="
fd -t f 'webhook-events' --full-path
echo -e "\n=== Checking if the import path exists in apps/web/src/server/service/ ==="
ls -la apps/web/src/server/service/webhook-events.ts 2>/dev/null || echo "File not found at apps/web/src/server/service/webhook-events.ts"
echo -e "\n=== Searching for WEBHOOK_EVENT_TYPES definition ==="
rg -n 'WEBHOOK_EVENT_TYPES' --type ts --type tsx -B2 -A2Repository: usesend/useSend
Length of output: 377
🏁 Script executed:
#!/bin/bash
# View the webhook-events file in packages/lib
echo "=== Content of packages/lib/src/webhook/webhook-events.ts ==="
cat packages/lib/src/webhook/webhook-events.ts
echo -e "\n=== Search for WEBHOOK_EVENT_TYPES with corrected command ==="
rg -n 'WEBHOOK_EVENT_TYPES' --type ts -B2 -A2
echo -e "\n=== Check how imports from @usesend/lib are used in apps/web ==="
rg -n "from ['\"]@usesend/lib" apps/web/src/server/api/routers/ -A1Repository: usesend/useSend
Length of output: 5282
🏁 Script executed:
#!/bin/bash
# Search for WEBHOOK_EVENT_TYPES definition (not just imports)
echo "=== Search for WEBHOOK_EVENT_TYPES definition (const/export const) ==="
rg -n 'export.*WEBHOOK_EVENT_TYPES|const.*WEBHOOK_EVENT_TYPES' --type ts
echo -e "\n=== Check apps/web/src/server/service directory structure ==="
find apps/web/src/server/service -type f -name "*.ts" 2>/dev/null | head -20
echo -e "\n=== Check if WEBHOOK_EVENT_TYPES is used elsewhere ==="
rg -n 'WEBHOOK_EVENT_TYPES' --type ts -B1 -A1
echo -e "\n=== Check packages/lib exports/index ==="
find packages/lib/src -name "index.ts" -o -name "index.js" | xargs cat 2>/dev/null | head -100Repository: usesend/useSend
Length of output: 2213
🏁 Script executed:
#!/bin/bash
echo "=== Full content of apps/web/src/server/api/routers/webhook.ts ==="
cat apps/web/src/server/api/routers/webhook.ts | head -50
echo -e "\n=== Check if WebhookEventType is available from @usesend/lib ==="
rg -n 'export.*WebhookEventType' packages/lib/src/webhook/webhook-events.ts
echo -e "\n=== Look for similar event type constants in the codebase ==="
rg -n 'as const|as \[string' apps/web/src/server/api/routers/ --type ts | head -20Repository: usesend/useSend
Length of output: 2341
🏁 Script executed:
#!/bin/bash
echo "=== Search for all webhook event type string values ==="
rg -n '"(email\.|domain\.|contact\.)' packages/lib/src/webhook/webhook-events.ts | grep -E ':\s*"'
echo -e "\n=== Check git history for webhook-events related changes ==="
git log --oneline --all -- '*webhook-events*' 2>/dev/null | head -10
echo -e "\n=== Check if there are any recent changes to webhook.ts ==="
git log --oneline -n 20 -- 'apps/web/src/server/api/routers/webhook.ts' 2>/dev/null
echo -e "\n=== Search for TypeScript const assertions with event types ==="
rg -n 'satisfies.*Record|as const' packages/lib/src/webhook/ --type tsRepository: usesend/useSend
Length of output: 1207
🏁 Script executed:
#!/bin/bash
echo "=== Show changes from recent webhook commit ==="
git show f5afe91 --stat
echo -e "\n=== Show the specific changes to webhook.ts ==="
git show f5afe91 -- apps/web/src/server/api/routers/webhook.ts | head -100Repository: usesend/useSend
Length of output: 37475
Fix the missing WEBHOOK_EVENT_TYPES constant and correct the import path.
The import on line 6 references a non-existent file and undefined constant. WEBHOOK_EVENT_TYPES needs to be created as an array of webhook event type strings in packages/lib/src/webhook/webhook-events.ts and exported from @usesend/lib, then imported correctly in this file.
The constant should contain all 20 event type strings:
- Email:
"email.queued","email.sent","email.delivery_delayed","email.delivered","email.bounced","email.rejected","email.rendering_failure","email.complained","email.failed","email.cancelled","email.suppressed","email.opened","email.clicked" - Domain:
"domain.created","domain.verified","domain.updated","domain.deleted" - Contact:
"contact.created","contact.updated","contact.deleted"
Change the import from:
import { WEBHOOK_EVENT_TYPES } from "~/server/service/webhook-events";
To:
import { WEBHOOK_EVENT_TYPES } from "@usesend/lib";
🤖 Prompt for AI Agents
In apps/web/src/server/api/routers/webhook.ts around line 6, the file imports a
nonexistent WEBHOOK_EVENT_TYPES constant from the wrong path; create a new file
packages/lib/src/webhook/webhook-events.ts that exports a constant
WEBHOOK_EVENT_TYPES array containing the 20 specified event type strings (the 13
email events, 4 domain events, and 3 contact events), re-export that constant
from the package entry so it is available via @usesend/lib, and update the
import in this file to: import { WEBHOOK_EVENT_TYPES } from "@usesend/lib";
| .mutation(async ({ ctx, input }) => { | ||
| const limit = getWebhookLimit(ctx.team); | ||
| const count = await db.webhook.count({ | ||
| where: { | ||
| teamId: ctx.team.id, | ||
| status: { not: WebhookStatus.DELETED }, | ||
| }, | ||
| }); | ||
|
|
||
| if (count >= limit) { | ||
| throw new TRPCError({ | ||
| code: "FORBIDDEN", | ||
| message: `Webhook limit reached: ${count}/${limit}. Upgrade plan to add more.`, | ||
| }); | ||
| } | ||
|
|
||
| const secret = input.secret ?? WebhookService.generateSecret(); | ||
|
|
||
| return db.webhook.create({ | ||
| data: { | ||
| teamId: ctx.team.id, | ||
| url: input.url, | ||
| description: input.description, | ||
| secret, | ||
| eventTypes: input.eventTypes, | ||
| status: WebhookStatus.ACTIVE, | ||
| createdByUserId: ctx.session.user.id, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential race condition in webhook limit enforcement.
There's a TOCTOU (time-of-check to time-of-use) race between counting existing webhooks and creating a new one. Two concurrent requests could both pass the limit check and create webhooks exceeding the limit.
Given the low webhook limits (1-3) and the nature of the operation (user-initiated, not high-frequency), this is unlikely to be exploited in practice. If stricter enforcement is needed, consider using a database transaction with a subquery or a unique partial index.
| await webhookCleanupQueue.upsertJobScheduler( | ||
| "webhook-cleanup-daily", | ||
| { | ||
| pattern: "0 3 * * *", // daily at 03:00 UTC | ||
| tz: "UTC", | ||
| }, | ||
| { | ||
| opts: { | ||
| ...DEFAULT_QUEUE_OPTIONS, | ||
| }, | ||
| } | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check package.json for type: module in apps/web
echo "=== Checking apps/web/package.json ==="
jq '.type // "commonjs (default)"' apps/web/package.json
# Check tsconfig settings
echo -e "\n=== Checking apps/web/tsconfig.json ==="
jq '.compilerOptions.module // "not specified"' apps/web/tsconfig.json
# Find imports of webhook-cleanup-job
echo -e "\n=== Searching for webhook-cleanup-job imports ==="
rg -n "webhook-cleanup-job" --type ts --type tsx
# Check the actual file for context
echo -e "\n=== Checking webhook-cleanup-job.ts file ==="
wc -l apps/web/src/server/jobs/webhook-cleanup-job.tsRepository: usesend/useSend
Length of output: 356
🏁 Script executed:
#!/bin/bash
# Read the full webhook-cleanup-job.ts file
echo "=== webhook-cleanup-job.ts content ==="
cat -n apps/web/src/server/jobs/webhook-cleanup-job.ts
# Look for job initialization or index files
echo -e "\n=== Looking for job initialization patterns ==="
fd -e ts -e tsx "jobs" apps/web/src/server/ | head -20
# Search for how jobs directory is imported
echo -e "\n=== Searching for jobs directory imports ==="
rg "from.*jobs" --type ts -n apps/web/src/server/
# Check if there's an index file in the jobs directory
echo -e "\n=== Checking jobs directory structure ==="
ls -la apps/web/src/server/jobs/Repository: usesend/useSend
Length of output: 2254
🏁 Script executed:
#!/bin/bash
# Search for any imports from the server directory that might load jobs
echo "=== Searching for server directory imports ==="
rg "from.*server" apps/web/src --type ts -n | grep -i job | head -20
# Check the main entry points
echo -e "\n=== Looking for app initialization files ==="
fd -e ts "app\|main\|index" apps/web/src/ | grep -E "pages|app\.|_app\." | head -10
# Search for any dynamic imports or require patterns for jobs
echo -e "\n=== Searching for dynamic job loading ==="
rg "import.*from.*jobs|require.*jobs" apps/web/src --type ts -n
# Check if there's a server initialization file
echo -e "\n=== Searching for server initialization ==="
fd -e ts "init\|setup\|start" apps/web/src/server/
# Look at the structure of server directory
echo -e "\n=== Server directory structure ==="
ls -la apps/web/src/server/Repository: usesend/useSend
Length of output: 1751
🏁 Script executed:
#!/bin/bash
# Search the entire apps/web/src for any mention of webhook-cleanup-job or jobs directory
echo "=== Searching entire apps/web/src for job file references ==="
rg "webhook-cleanup-job|campaign-scheduler-job|usage-job" apps/web/src --type ts -n
# Check if there's a pages or app directory
echo -e "\n=== Looking for pages or app directory ==="
ls -la apps/web/src/ 2>/dev/null | grep -E "pages|app|middleware"
# Look for any TypeScript or Next.js configuration that might load jobs
echo -e "\n=== Checking for any job loading in config files ==="
find apps/web -maxdepth 2 -name "*.config.*" -o -name "next.config.*" | xargs ls -la 2>/dev/null
# Search for any files that might be importing or requiring the jobs directory
echo -e "\n=== Searching for any imports from jobs directory ==="
rg "from.*['\"].*jobs" apps/web/src --type ts -A2 -B2
# Check if jobs are loaded in instrumentation or edge config
echo -e "\n=== Looking for instrumentation or special Next.js files ==="
fd -e ts "instrumentation|middleware" apps/web/Repository: usesend/useSend
Length of output: 807
🏁 Script executed:
#!/bin/bash
# Read instrumentation.ts to see how jobs are loaded
echo "=== Reading instrumentation.ts ==="
cat -n apps/web/src/instrumentation.tsRepository: usesend/useSend
Length of output: 1256
Add webhook-cleanup-job import to instrumentation.ts – the job file exists but is never loaded.
The webhook-cleanup-job.ts file has top-level await code that will never execute because it's not imported anywhere. The ESM context is properly configured ("type": "module" in apps/web/package.json), so top-level await will work fine once imported. However, unlike usage-job and campaign-scheduler-job, this file is missing from the instrumentation.ts register function. Add the import to the initialization hook:
// apps/web/src/instrumentation.ts (around line 32)
await import("~/server/jobs/webhook-cleanup-job");🤖 Prompt for AI Agents
In apps/web/src/instrumentation.ts around line 32, the webhook-cleanup-job file
defines top-level await code but is never imported so it never runs; add an ESM
dynamic import for the job (await import("~/server/jobs/webhook-cleanup-job");)
inside the register/initialization hook alongside the existing usage-job and
campaign-scheduler-job imports so the top-level await executes and the scheduler
is registered.
| const updatedWebhook = await db.webhook.update({ | ||
| where: { id: call.webhookId }, | ||
| data: { | ||
| consecutiveFailures: { | ||
| increment: 1, | ||
| }, | ||
| lastFailureAt: new Date(), | ||
| status: | ||
| call.webhook.consecutiveFailures + 1 >= WEBHOOK_AUTO_DISABLE_THRESHOLD | ||
| ? WebhookStatus.AUTO_DISABLED | ||
| : call.webhook.status, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stale consecutiveFailures value in auto-disable check.
The auto-disable threshold check on line 329 uses call.webhook.consecutiveFailures + 1, but consecutiveFailures is incremented atomically in the same update. Under concurrent failures, the local value may be stale, potentially delaying auto-disable.
For stricter enforcement, read the updated value after the increment or use a returning clause:
- const updatedWebhook = await db.webhook.update({
- where: { id: call.webhookId },
- data: {
- consecutiveFailures: {
- increment: 1,
- },
- lastFailureAt: new Date(),
- status:
- call.webhook.consecutiveFailures + 1 >= WEBHOOK_AUTO_DISABLE_THRESHOLD
- ? WebhookStatus.AUTO_DISABLED
- : call.webhook.status,
- },
- });
+ const updatedWebhook = await db.webhook.update({
+ where: { id: call.webhookId },
+ data: {
+ consecutiveFailures: {
+ increment: 1,
+ },
+ lastFailureAt: new Date(),
+ },
+ });
+
+ // Check threshold after atomic increment
+ if (updatedWebhook.consecutiveFailures >= WEBHOOK_AUTO_DISABLE_THRESHOLD) {
+ await db.webhook.update({
+ where: { id: call.webhookId },
+ data: { status: WebhookStatus.AUTO_DISABLED },
+ });
+ updatedWebhook.status = WebhookStatus.AUTO_DISABLED;
+ }🤖 Prompt for AI Agents
In apps/web/src/server/service/webhook-service.ts around lines 321 to 333, the
auto-disable check uses the stale local value call.webhook.consecutiveFailures +
1 while the DB increments consecutiveFailures atomically in the same update;
change the flow to read the updated value returned by the database (use an
update with a returning clause or perform a transaction that increments and then
selects the new consecutiveFailures) and then set status based on that returned
value so the auto-disable decision uses the actual incremented count under
concurrency.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
5 issues found across 22 files
Prompt for AI agents (all 5 issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="apps/web/src/server/service/webhook-service.ts">
<violation number="1" location="apps/web/src/server/service/webhook-service.ts:287">
Clear `responseText` (and any other error-only fields) when marking a webhook call as delivered so old failure payloads do not persist on successful deliveries.</violation>
<violation number="2" location="apps/web/src/server/service/webhook-service.ts:321">
Wrap the webhook and call updates in the failure path inside a single transaction so the webhook’s failure counters and the call record stay consistent if either write fails.</violation>
<violation number="3" location="apps/web/src/server/service/webhook-service.ts:613">
Flush the TextDecoder once the stream finishes so partial multi-byte characters aren’t discarded at the end of the response body.</violation>
</file>
<file name="packages/lib/package.json">
<violation number="1" location="packages/lib/package.json:5">
The package exposes raw TypeScript as its "main" and "types" entry, but there is no build that emits JavaScript or declaration files, so consumers cannot require @usesend/lib without Node throwing on the .ts extension.</violation>
</file>
<file name="apps/web/src/server/service/domain-service.ts">
<violation number="1" location="apps/web/src/server/service/domain-service.ts:393">
`createdAt` on emitted webhook events is set to the domain’s last database update time, so delete events get stale timestamps and can arrive out of order. Use the actual emission time instead.</violation>
</file>
Reply to cubic to teach it or ask questions. Re-run a review with @cubic-dev-ai review this PR
| return `${chunks}...<truncated>`; | ||
| } | ||
|
|
||
| return chunks; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Flush the TextDecoder once the stream finishes so partial multi-byte characters aren’t discarded at the end of the response body.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/server/service/webhook-service.ts, line 613:
<comment>Flush the TextDecoder once the stream finishes so partial multi-byte characters aren’t discarded at the end of the response body.</comment>
<file context>
@@ -0,0 +1,622 @@
+ return `${chunks}...<truncated>`;
+ }
+
+ return chunks;
+ }
+
</file context>
| }); | ||
|
|
||
| await db.$transaction([ | ||
| db.webhookCall.update({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clear responseText (and any other error-only fields) when marking a webhook call as delivered so old failure payloads do not persist on successful deliveries.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/server/service/webhook-service.ts, line 287:
<comment>Clear `responseText` (and any other error-only fields) when marking a webhook call as delivered so old failure payloads do not persist on successful deliveries.</comment>
<file context>
@@ -0,0 +1,622 @@
+ });
+
+ await db.$transaction([
+ db.webhookCall.update({
+ where: { id: call.id },
+ data: {
</file context>
| ? new Date(Date.now() + computeBackoff(attempt)) | ||
| : null; | ||
|
|
||
| const updatedWebhook = await db.webhook.update({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wrap the webhook and call updates in the failure path inside a single transaction so the webhook’s failure counters and the call record stay consistent if either write fails.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/server/service/webhook-service.ts, line 321:
<comment>Wrap the webhook and call updates in the failure path inside a single transaction so the webhook’s failure counters and the call record stay consistent if either write fails.</comment>
<file context>
@@ -0,0 +1,622 @@
+ ? new Date(Date.now() + computeBackoff(attempt))
+ : null;
+
+ const updatedWebhook = await db.webhook.update({
+ where: { id: call.webhookId },
+ data: {
</file context>
| "name": "@usesend/lib", | ||
| "version": "0.0.0", | ||
| "private": true, | ||
| "main": "./index.ts", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The package exposes raw TypeScript as its "main" and "types" entry, but there is no build that emits JavaScript or declaration files, so consumers cannot require @usesend/lib without Node throwing on the .ts extension.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/lib/package.json, line 5:
<comment>The package exposes raw TypeScript as its "main" and "types" entry, but there is no build that emits JavaScript or declaration files, so consumers cannot require @usesend/lib without Node throwing on the .ts extension.</comment>
<file context>
@@ -0,0 +1,22 @@
+ "name": "@usesend/lib",
+ "version": "0.0.0",
+ "private": true,
+ "main": "./index.ts",
+ "types": "./index.ts",
+ "files": [
</file context>
| teamId: domain.teamId, | ||
| type, | ||
| data: buildDomainPayload(domain), | ||
| createdAt: domain.updatedAt.toISOString(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
createdAt on emitted webhook events is set to the domain’s last database update time, so delete events get stale timestamps and can arrive out of order. Use the actual emission time instead.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/server/service/domain-service.ts, line 393:
<comment>`createdAt` on emitted webhook events is set to the domain’s last database update time, so delete events get stale timestamps and can arrive out of order. Use the actual emission time instead.</comment>
<file context>
@@ -341,3 +383,19 @@ async function getDmarcRecord(domain: string) {
+ teamId: domain.teamId,
+ type,
+ data: buildDomainPayload(domain),
+ createdAt: domain.updatedAt.toISOString(),
+ });
+ } catch (error) {
</file context>
| createdAt: domain.updatedAt.toISOString(), | |
| createdAt: new Date().toISOString(), |
✅ Addressed in c96032e
|
thanks @marconneves for the intial works |
f5afe91 to
7972860
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (7)
apps/web/src/server/jobs/webhook-cleanup-job.ts (1)
36-47: This file must be imported in instrumentation.ts to execute – already flagged in previous review.As noted in the previous review comment, this file contains top-level
awaitcode that will never execute because it's not imported anywhere. The job scheduler registration on lines 36-47 won't run, meaning webhook calls will never be cleaned up and will accumulate indefinitely.apps/web/src/server/service/domain-service.ts (1)
387-401: Use actual emission time for webhook eventcreatedAt.Line 393 uses
domain.updatedAt.toISOString()for the webhook event'screatedAtfield. For delete events, this timestamp reflects the last database update before deletion, not the actual emission time, which can lead to stale timestamps and out-of-order event delivery.Apply this diff:
await WebhookService.emit({ teamId: domain.teamId, type, data: buildDomainPayload(domain), - createdAt: domain.updatedAt.toISOString(), + createdAt: new Date().toISOString(), });apps/web/src/server/service/webhook-service.ts (4)
286-305: ClearresponseTexton successful delivery.The success path clears
lastErrorandnextAttemptAtbut doesn't clearresponseText. If a previous attempt failed and stored error text, it will persist on the successful record, potentially causing confusion.Apply this diff:
db.webhookCall.update({ where: { id: call.id }, data: { status: WebhookCallStatus.DELIVERED, attempt, responseStatus, responseTimeMs, lastError: null, nextAttemptAt: null, + responseText: null, }, }),
321-333: Auto-disable threshold uses stale counter.Line 329 checks
call.webhook.consecutiveFailures + 1against the threshold, butconsecutiveFailuresis atomically incremented in the same update. Under concurrent failures, the local value may be stale, potentially delaying auto-disable.Read the updated value after the atomic increment:
const updatedWebhook = await db.webhook.update({ where: { id: call.webhookId }, data: { consecutiveFailures: { increment: 1, }, lastFailureAt: new Date(), - status: - call.webhook.consecutiveFailures + 1 >= WEBHOOK_AUTO_DISABLE_THRESHOLD - ? WebhookStatus.AUTO_DISABLED - : call.webhook.status, }, }); + + // Check threshold after atomic increment + if (updatedWebhook.consecutiveFailures >= WEBHOOK_AUTO_DISABLE_THRESHOLD) { + await db.webhook.update({ + where: { id: call.webhookId }, + data: { status: WebhookStatus.AUTO_DISABLED }, + }); + updatedWebhook.status = WebhookStatus.AUTO_DISABLED; + }
321-349: Wrap failure updates in a transaction.The webhook and call updates on lines 321-333 and 335-349 are separate operations. If the call update fails, the webhook's failure counters will be inconsistent with the call record.
Wrap both updates in a single transaction:
+ await db.$transaction([ await db.webhook.update({ where: { id: call.webhookId }, data: { consecutiveFailures: { increment: 1, }, lastFailureAt: new Date(), // ... status logic }, }), await db.webhookCall.update({ where: { id: call.id }, data: { status: attempt >= WEBHOOK_MAX_ATTEMPTS ? WebhookCallStatus.FAILED : WebhookCallStatus.PENDING, attempt, nextAttemptAt, lastError: errorMessage, responseStatus: responseStatus ?? undefined, responseTimeMs: responseTimeMs ?? undefined, responseText: responseText ?? undefined, }, }), + ]);
554-622: Flush TextDecoder after streaming finishes.Line 595 uses
decoder.decode(value, { stream: true })to handle multi-byte characters across chunks. However, when the stream ends at line 591, there's no final flush. Any partial multi-byte characters in the decoder's buffer will be discarded.Add a flush after the loop:
const { done, value } = await reader.read(); if (done) { + chunks += decoder.decode(); // flush any buffered bytes break; }packages/lib/src/webhook/webhook-events.ts (1)
1-30: Add runtime constant for webhook event types.This file only exports TypeScript type definitions. The webhook router likely needs a runtime array of event type strings for zod validation, but
WEBHOOK_EVENT_TYPESis not exported.Add a runtime constant:
export type WebhookEventType = | EmailWebhookEventType | DomainWebhookEventType | ContactWebhookEventType; + +export const WEBHOOK_EVENT_TYPES = [ + "contact.created", + "contact.updated", + "contact.deleted", + "domain.created", + "domain.verified", + "domain.updated", + "domain.deleted", + "email.queued", + "email.sent", + "email.delivery_delayed", + "email.delivered", + "email.bounced", + "email.rejected", + "email.rendering_failure", + "email.complained", + "email.failed", + "email.cancelled", + "email.suppressed", + "email.opened", + "email.clicked", +] as const satisfies readonly WebhookEventType[];
🧹 Nitpick comments (1)
apps/web/src/server/jobs/webhook-cleanup-job.ts (1)
8-8: Consider making retention period configurable via environment variable.While the hardcoded 30-day retention is clear and reasonable, making it configurable would allow different policies per environment without code changes.
Example approach:
-const WEBHOOK_RETENTION_DAYS = 30; +const WEBHOOK_RETENTION_DAYS = env.WEBHOOK_RETENTION_DAYS ?? 30;Then add to your env schema with a default value.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (19)
apps/web/package.json(1 hunks)apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql(1 hunks)apps/web/prisma/schema.prisma(3 hunks)apps/web/src/app/(dashboard)/emails/email-details.tsx(2 hunks)apps/web/src/server/api/root.ts(2 hunks)apps/web/src/server/api/routers/email.ts(2 hunks)apps/web/src/server/api/routers/webhook.ts(1 hunks)apps/web/src/server/jobs/webhook-cleanup-job.ts(1 hunks)apps/web/src/server/queue/queue-constants.ts(1 hunks)apps/web/src/server/service/domain-service.ts(8 hunks)apps/web/src/server/service/ses-hook-parser.ts(3 hunks)apps/web/src/server/service/webhook-service.ts(1 hunks)apps/web/tailwind.config.ts(1 hunks)packages/lib/.eslintrc.cjs(1 hunks)packages/lib/package.json(1 hunks)packages/lib/src/index.ts(1 hunks)packages/lib/src/webhook/webhook-events.ts(1 hunks)packages/lib/tsconfig.json(1 hunks)packages/lib/tsconfig.lint.json(1 hunks)
✅ Files skipped from review due to trivial changes (1)
- apps/web/src/server/api/routers/email.ts
🚧 Files skipped from review as they are similar to previous changes (12)
- apps/web/tailwind.config.ts
- packages/lib/tsconfig.lint.json
- apps/web/package.json
- packages/lib/package.json
- apps/web/src/server/api/routers/webhook.ts
- apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql
- packages/lib/tsconfig.json
- packages/lib/.eslintrc.cjs
- apps/web/src/server/api/root.ts
- apps/web/src/server/queue/queue-constants.ts
- apps/web/src/app/(dashboard)/emails/email-details.tsx
- packages/lib/src/index.ts
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/service/webhook-service.tsapps/web/src/server/service/ses-hook-parser.tspackages/lib/src/webhook/webhook-events.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/service/webhook-service.tsapps/web/src/server/service/ses-hook-parser.tspackages/lib/src/webhook/webhook-events.ts
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/service/webhook-service.tsapps/web/src/server/service/ses-hook-parser.ts
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/service/webhook-service.tsapps/web/src/server/service/ses-hook-parser.ts
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/service/webhook-service.tsapps/web/src/server/service/ses-hook-parser.tspackages/lib/src/webhook/webhook-events.ts
🧠 Learnings (1)
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx} : Use Prisma for database access in apps/web
Applied to files:
apps/web/prisma/schema.prisma
🧬 Code graph analysis (3)
apps/web/src/server/jobs/webhook-cleanup-job.ts (4)
apps/web/src/server/queue/queue-constants.ts (2)
WEBHOOK_CLEANUP_QUEUE(7-7)DEFAULT_QUEUE_OPTIONS(9-14)apps/web/src/server/redis.ts (1)
getRedis(6-13)apps/web/src/server/db.ts (1)
db(20-20)apps/web/src/server/logger/log.ts (1)
logger(31-63)
apps/web/src/server/service/webhook-service.ts (5)
apps/web/src/server/queue/bullmq-context.ts (2)
TeamJob(5-5)createWorkerHandler(10-24)packages/lib/src/webhook/webhook-events.ts (3)
WebhookEventType(27-30)WebhookEventPayloadMap(168-170)WebhookEvent(172-177)apps/web/src/server/queue/queue-constants.ts (2)
WEBHOOK_DISPATCH_QUEUE(6-6)DEFAULT_QUEUE_OPTIONS(9-14)apps/web/src/server/redis.ts (1)
getRedis(6-13)apps/web/src/server/db.ts (1)
db(20-20)
apps/web/src/server/service/ses-hook-parser.ts (3)
apps/web/src/server/service/webhook-service.ts (1)
WebhookService(85-199)packages/lib/src/webhook/webhook-events.ts (4)
EmailEventPayloadMap(139-153)EmailStatus(32-46)EmailWebhookEventType(12-25)EmailBasePayload(48-60)apps/web/src/types/aws-types.ts (4)
SesEvent(143-156)SesEventDataKey(132-141)SesBounce(30-50)SesClick(91-97)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (21)
apps/web/src/server/jobs/webhook-cleanup-job.ts (3)
1-8: LGTM!Imports follow the coding guidelines correctly, using the
~/alias for server imports. The retention period constant is clearly named and appropriately scoped.
10-34: LGTM!The Queue and Worker are properly configured with Redis connections. The cleanup logic correctly deletes webhook calls older than 30 days using
subDaysand logs the results appropriately. BullMQ's built-in error handling via the "failed" event is sufficient here.
49-55: LGTM!Event handlers are properly configured with appropriate logging levels and contextual information. The log messages follow a consistent format with clear prefixes.
apps/web/src/server/service/ses-hook-parser.ts (5)
279-303: LGTM: Webhook emission is properly isolated.The webhook emission is correctly wrapped in a try-catch block to ensure SES hook processing isn't interrupted by webhook failures. The use of
data.mail.timestampforoccurredAtmaintains accurate event timing.
311-371: LGTM: Type-safe payload construction.The payload builder properly extends the base payload with status-specific metadata for bounced, opened, and clicked events while maintaining type safety through the discriminated union return type.
373-396: LGTM: Defensive bounce subtype normalization.The function correctly normalizes bounce subtypes by removing whitespace and validating against a known list, with a safe "General" default for unknown values.
398-429: LGTM: Comprehensive status-to-event mapping.The mapping covers all email statuses exhaustively. The default case returning "email.queued" serves as a safe fallback, though it should be unreachable given the complete switch cases.
431-490: LGTM: Comprehensive metadata extraction.The function extracts relevant metadata for each status type with proper fallbacks and returns undefined for statuses without additional context, maintaining clean payload structures.
apps/web/prisma/schema.prisma (4)
450-463: LGTM: Well-defined webhook state enums.The enum definitions appropriately model webhook lifecycle (ACTIVE, PAUSED, AUTO_DISABLED, DELETED) and delivery states (PENDING, IN_PROGRESS, DELIVERED, FAILED, DISCARDED).
465-485: LGTM: Comprehensive webhook model design.The model includes all necessary fields for webhook management: signing secrets, event type filtering, failure tracking for auto-disable, audit trails, and proper cascading relations with appropriate indexes.
487-507: LGTM: Detailed webhook call tracking.The model captures comprehensive delivery metadata (attempt count, response details, timing) and includes well-designed indexes for efficient querying of delivery history by team, webhook, and status.
82-93: LGTM: Proper bidirectional relations.The webhook relations to User and Team models are correctly defined with appropriate naming and cascade behavior.
Also applies to: 101-128
apps/web/src/server/service/webhook-service.ts (7)
1-41: LGTM: Well-defined constants and types.The constants define reasonable defaults for webhook delivery (25 concurrent workers, 6 retry attempts, 10s timeout, 30 consecutive failures for auto-disable). Types are properly structured for type-safe webhook event handling.
43-83: LGTM: Solid queue service initialization.The service properly initializes BullMQ queue and worker with appropriate concurrency, backoff, and error handling. Using
callIdasjobIdensures idempotent job enqueuing.
86-133: LGTM: Efficient event emission.The emit method properly normalizes payloads, filters active webhooks by event type, creates call records, and enqueues deliveries. The early return for zero webhooks avoids unnecessary work.
135-198: LGTM: Clean retry and test methods.The retry method properly resets all failure state before re-enqueuing. The test method creates a well-formed test payload. The 256-bit secret generation provides strong cryptographic strength for HMAC signing.
259-275: LGTM: Robust distributed locking.The per-webhook locking implementation uses Redis SET with NX for atomic acquisition, includes a TTL to prevent deadlocks, and safely releases locks using a Lua script that verifies ownership. The retry delay when lock acquisition fails is appropriate.
Also applies to: 379-405
407-451: LGTM: Proper backoff and payload construction.The exponential backoff with 30% jitter prevents thundering herd issues. The payload builder safely parses stored JSON and enriches it with delivery metadata (attempt, version, timestamps).
471-552: LGTM: Secure and robust HTTP delivery.The delivery implementation includes proper timeout handling, HMAC-based request signing, comprehensive headers for webhook consumers, and structured error capture. The distinction between timeout, HTTP errors, and fetch errors enables appropriate retry logic.
packages/lib/src/webhook/webhook-events.ts (2)
32-137: LGTM: Comprehensive payload type definitions.The type hierarchy is well-designed with a base payload for common fields and specific extensions for bounced, failed, suppressed, opened, and clicked events. The types properly model all necessary metadata for webhook consumers.
139-181: LGTM: Type-safe event system design.The payload maps create a discriminated union that enables type-safe webhook event handling. The generic
WebhookEvent<TType>correctly constrains thedatafield based on the event type through the mapped type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (4)
apps/web/src/server/service/webhook-service.ts (4)
467-486: Success path should clearresponseTextfor consistency.When a call succeeds after previous failures,
responseTextfrom the last failure persists. This was flagged in a previous review.data: { status: WebhookCallStatus.DELIVERED, attempt, responseStatus, responseTimeMs, lastError: null, nextAttemptAt: null, + responseText: null, },
502-514: StaleconsecutiveFailuresin auto-disable check.This uses the pre-increment value (
call.webhook.consecutiveFailures + 1) while the DB increments atomically. Under concurrent failures, the threshold check may be stale. This was flagged in a previous review.
502-530: Failure path updates are not transactional.The webhook and call updates happen in separate statements. If one fails, state becomes inconsistent. This was flagged in a previous review.
769-795: Missing TextDecoder flush after streaming completes.When the stream ends normally (not truncated),
decoder.decode()should be called without{ stream: true }to flush any remaining multi-byte character fragments. This was flagged in a previous review.if (truncated) { return `${chunks}...<truncated>`; } - return chunks; + // Flush any remaining bytes from the decoder + chunks += decoder.decode(); + return chunks; }
🧹 Nitpick comments (7)
apps/web/src/app/(dashboard)/dev-settings/layout.tsx (1)
8-8: Consider renaming the component for clarity.The component is named
ApiKeysPagebut it serves as the layout for the entire dev-settings section (API Keys, Webhooks, SMTP). A name likeDevSettingsLayoutwould better reflect its purpose.apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsx (2)
107-115: Test button disabled state affects all rows.When
testWebhook.isPendingis true, all Test buttons across all webhook rows are disabled. This prevents testing multiple webhooks in quick succession and may confuse users about which webhook is being tested.Consider tracking the pending webhook ID to disable only the specific row:
+ const [testingId, setTestingId] = useState<string | null>(null); async function handleTest(webhookId: string) { + setTestingId(webhookId); testWebhook.mutate( { id: webhookId }, { onSuccess: async () => { await utils.webhook.listCalls.invalidate(); toast.success("Test webhook enqueued"); + setTestingId(null); }, onError: (error) => { toast.error(error.message); + setTestingId(null); }, }, ); } // In the button: - disabled={testWebhook.isPending} + disabled={testingId === webhook.id}
146-162: Consider using the WebhookStatus enum for type safety.The
statusparameter is typed asstringand compared against string literals. Using theWebhookStatusenum from Prisma would provide compile-time safety.+ import { WebhookStatus } from "@prisma/client"; - function WebhookStatusBadge({ status }: { status: string }) { + function WebhookStatusBadge({ status }: { status: WebhookStatus }) { let variant: "default" | "outline" | "destructive" = "outline"; let label = status; - if (status === "ACTIVE") { + if (status === WebhookStatus.ACTIVE) { variant = "default"; label = "Active"; - } else if (status === "PAUSED") { + } else if (status === WebhookStatus.PAUSED) { variant = "outline"; label = "Paused"; - } else if (status === "AUTO_DISABLED") { + } else if (status === WebhookStatus.AUTO_DISABLED) { variant = "destructive"; label = "Auto disabled"; } return <Badge variant={variant}>{label}</Badge>; }apps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsx (2)
46-53: Consider adding.min(1)or.refine()to the schema for stronger validation.The
eventTypesarray schema usesrequired_error, which only triggers when the field isundefined. An empty array[]passes validation, requiring the manual check on lines 100-103. While the manual check works, consolidating validation in the schema would be cleaner.const webhookSchema = z.object({ url: z .string({ required_error: "URL is required" }) .url("Please enter a valid URL"), - eventTypes: z.array(EVENT_TYPES_ENUM, { - required_error: "Select at least one event", - }), + eventTypes: z.array(EVENT_TYPES_ENUM).optional(), });The manual check on line 100 already handles the
allEventsSelectedflag logic, so makingeventTypesoptional in the schema aligns better with the actual validation semantics.
57-64: Event groups ordering differs from the edit dialog.This file orders groups as Contact → Domain → Email, while
webhook-update-dialog.tsxuses Email → Domain → Contact. Consider aligning the order for consistent UX.apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-update-dialog.tsx (1)
78-91: Repeated type casting ofeventTypesis fragile.The
webhook.eventTypes as WebhookEventType[]cast appears multiple times (lines 79, 90, 97, 103). If the database contains invalid event types, this could cause runtime issues. Consider validating or filtering once:+const parseEventTypes = (raw: string[]): WebhookEventType[] => + raw.filter((e): e is WebhookEventType => + (WebhookEvents as readonly string[]).includes(e) + ); + export function EditWebhookDialog({ webhook, open, onOpenChange, }: { webhook: Webhook; open: boolean; onOpenChange: (open: boolean) => void; }) { const updateWebhook = api.webhook.update.useMutation(); const utils = api.useUtils(); - const initialHasAllEvents = - (webhook.eventTypes as WebhookEventType[]).length === 0; + const validatedEventTypes = parseEventTypes(webhook.eventTypes); + const initialHasAllEvents = validatedEventTypes.length === 0;apps/web/src/server/service/webhook-service.ts (1)
130-143: Consider parallelizing webhook call creation.The loop creates and enqueues calls sequentially. For teams with multiple webhooks, this could be parallelized:
- for (const webhook of activeWebhooks) { - const call = await db.webhookCall.create({ - data: { - webhookId: webhook.id, - teamId: webhook.teamId, - type: payload.type, - payload: payloadString, - status: WebhookCallStatus.PENDING, - attempt: 0, - }, - }); - - await WebhookQueueService.enqueueCall(call.id, webhook.teamId); - } + await Promise.all( + activeWebhooks.map(async (webhook) => { + const call = await db.webhookCall.create({ + data: { + webhookId: webhook.id, + teamId: webhook.teamId, + type: payload.type, + payload: payloadString, + status: WebhookCallStatus.PENDING, + attempt: 0, + }, + }); + await WebhookQueueService.enqueueCall(call.id, webhook.teamId); + }), + );
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (14)
apps/web/prisma/schema.prisma(3 hunks)apps/web/src/app/(dashboard)/dev-settings/layout.tsx(2 hunks)apps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsx(1 hunks)apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsx(1 hunks)apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-update-dialog.tsx(1 hunks)apps/web/src/lib/constants/plans.ts(3 hunks)apps/web/src/server/api/routers/limits.ts(2 hunks)apps/web/src/server/api/routers/webhook.ts(1 hunks)apps/web/src/server/service/limit-service.ts(1 hunks)apps/web/src/server/service/webhook-service.ts(1 hunks)packages/lib/src/webhook/webhook-events.ts(1 hunks)packages/ui/src/dropdown-menu.tsx(7 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/src/server/api/routers/webhook.ts
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-update-dialog.tsxapps/web/src/lib/constants/plans.tsapps/web/src/server/api/routers/limits.tsapps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsxpackages/ui/src/dropdown-menu.tsxapps/web/src/server/service/webhook-service.tspackages/lib/src/webhook/webhook-events.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-update-dialog.tsxapps/web/src/lib/constants/plans.tsapps/web/src/server/api/routers/limits.tsapps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsxpackages/ui/src/dropdown-menu.tsxapps/web/src/server/service/webhook-service.tspackages/lib/src/webhook/webhook-events.ts
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-update-dialog.tsxapps/web/src/lib/constants/plans.tsapps/web/src/server/api/routers/limits.tsapps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsxapps/web/src/server/service/webhook-service.ts
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-update-dialog.tsxapps/web/src/lib/constants/plans.tsapps/web/src/server/api/routers/limits.tsapps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsxapps/web/src/server/service/webhook-service.ts
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-update-dialog.tsxapps/web/src/lib/constants/plans.tsapps/web/src/server/api/routers/limits.tsapps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsxpackages/ui/src/dropdown-menu.tsxapps/web/src/server/service/webhook-service.tspackages/lib/src/webhook/webhook-events.ts
**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
React components must use PascalCase naming convention (e.g., AppSideBar.tsx)
Files:
apps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsxpackages/ui/src/dropdown-menu.tsx
🧠 Learnings (1)
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx} : Use Prisma for database access in apps/web
Applied to files:
apps/web/prisma/schema.prisma
🧬 Code graph analysis (6)
apps/web/src/server/service/limit-service.ts (2)
apps/web/src/env.js (2)
env(5-141)env(5-141)apps/web/src/lib/constants/plans.ts (1)
PLAN_LIMITS(13-40)
apps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsx (8)
packages/lib/src/webhook/webhook-events.ts (5)
WebhookEvents(36-40)WebhookEventType(42-42)ContactEvents(1-5)DomainEvents(9-14)EmailEvents(18-32)apps/web/src/store/upgradeModalStore.ts (1)
useUpgradeModalStore(13-20)packages/ui/src/toaster.tsx (1)
toast(31-31)packages/ui/src/dialog.tsx (5)
Dialog(113-113)DialogTrigger(117-117)DialogContent(118-118)DialogHeader(119-119)DialogTitle(121-121)packages/ui/src/button.tsx (1)
Button(80-80)packages/ui/src/form.tsx (7)
Form(170-170)FormField(176-176)FormItem(171-171)FormLabel(172-172)FormControl(173-173)FormMessage(175-175)FormDescription(174-174)packages/ui/src/input.tsx (1)
Input(25-25)packages/ui/src/dropdown-menu.tsx (4)
DropdownMenu(185-185)DropdownMenuTrigger(186-186)DropdownMenuContent(187-187)DropdownMenuCheckboxItem(189-189)
apps/web/src/app/(dashboard)/dev-settings/layout.tsx (1)
apps/web/src/app/(dashboard)/dev-settings/settings-nav-button.tsx (1)
SettingsNavButton(7-39)
apps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsx (1)
apps/web/src/components/DeleteResource.tsx (1)
DeleteResource(63-199)
apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-update-dialog.tsx (8)
packages/lib/src/webhook/webhook-events.ts (5)
WebhookEvents(36-40)WebhookEventType(42-42)EmailEvents(18-32)DomainEvents(9-14)ContactEvents(1-5)apps/web/src/server/service/webhook-service.ts (1)
updateWebhook(252-289)packages/ui/src/toaster.tsx (1)
toast(31-31)packages/ui/src/dialog.tsx (4)
Dialog(113-113)DialogContent(118-118)DialogHeader(119-119)DialogTitle(121-121)packages/ui/src/form.tsx (7)
Form(170-170)FormField(176-176)FormItem(171-171)FormLabel(172-172)FormControl(173-173)FormMessage(175-175)FormDescription(174-174)packages/ui/src/input.tsx (1)
Input(25-25)packages/ui/src/dropdown-menu.tsx (6)
DropdownMenu(185-185)DropdownMenuTrigger(186-186)DropdownMenuContent(187-187)DropdownMenuLabel(191-191)DropdownMenuCheckboxItem(189-189)DropdownMenuSeparator(192-192)packages/ui/src/button.tsx (1)
Button(80-80)
apps/web/src/server/api/routers/limits.ts (1)
apps/web/src/server/service/limit-service.ts (1)
LimitService(19-280)
🔇 Additional comments (26)
packages/ui/src/dropdown-menu.tsx (2)
29-33: Formatting-onlycn(...)updates look goodThe added trailing
className,arguments across these components are consistent with the existing pattern and Prettier style; no behavioral change and nothing concerning here.Also applies to: 49-52, 67-70, 85-89, 125-128, 149-153
95-115: Checkbox item layout change is reasonable and non-breakingReordering to render
{children}before the indicator and wrapping theItemIndicatorin anml-autoflex span to right-align the checkmark is a safe structural change and should improve visual alignment without affecting behavior or accessibility (the RadixItemIndicatorremains intact).apps/web/src/app/(dashboard)/dev-settings/layout.tsx (1)
3-3: LGTM!The H1 component import and the new Webhooks navigation button follow the established patterns in the codebase. The nav button correctly points to
/dev-settings/webhooks.Also applies to: 15-20
packages/lib/src/webhook/webhook-events.ts (3)
1-42: LGTM! Well-structured event type definitions.The use of
as constarrays with derived union types is a clean pattern that provides both runtime values and type safety. The spread operator to combine event arrays intoWebhookEventsis also well done.
44-58:SCHEDULEDstatus has no corresponding webhook event.
EmailStatusincludes"SCHEDULED"but there's no"email.scheduled"inEmailEvents. If scheduling is an internal-only state that shouldn't emit webhooks, this is fine. Otherwise, consider adding the event for consistency.
60-193: Comprehensive payload type definitions.The payload types are well-designed with appropriate base types and specialized extensions for bounce, failure, suppression, open, and click events. The generic
WebhookEvent<TType>pattern provides excellent type safety for consumers.apps/web/src/server/api/routers/limits.ts (1)
21-22: LGTM!The new webhook limit check follows the established pattern for other resource limits and integrates cleanly with the existing switch statement.
apps/web/src/app/(dashboard)/dev-settings/webhooks/page.tsx (1)
7-16: LGTM!Clean and straightforward page composition. The layout with header containing title and add button, followed by the list, is a good UX pattern.
apps/web/src/server/service/limit-service.ts (1)
104-132: LGTM!The
checkWebhookLimitmethod follows the established pattern used by other limit checks (checkDomainLimit,checkContactBookLimit,checkTeamMemberLimit). The implementation is consistent with cloud-mode gating, team caching, and the standard limit result structure.apps/web/src/lib/constants/plans.ts (1)
7-7: LGTM!The webhook limit additions are consistent with the existing plan structure. FREE plan getting 1 webhook and BASIC getting unlimited (-1) aligns with the PR objectives and follows the established pattern for other resources.
Also applies to: 21-21, 30-30, 38-38
apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsx (2)
30-43: LGTM on the test handler.Unlike the delete operation, using
mutate()here is appropriate since the "test" action just enqueues a webhook call and doesn't need to wait for completion before showing the success toast.
126-134: Dialog rendered inside TableCell.The
EditWebhookDialogis rendered inside theTableCell. While most dialog libraries portal to the document body, if this dialog doesn't portal correctly, it could cause layout issues within the table. Verify this renders correctly.apps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsx (1)
53-53: The Tailwind color class is correct.
text-red/80is valid. The shared Tailwind config definesredas a custom single-shade color (red: { DEFAULT: "hsl(var(--red))" }), so the opacity modifier/80applies directly to this DEFAULT value without requiring a shade number.apps/web/src/app/(dashboard)/dev-settings/webhooks/add-webhook.tsx (2)
163-169: Invisible description text for layout stability.The
text-transparentclass hides the description text while preserving layout space. This is a reasonable pattern for preventing layout shift, but consider using a fixed-height container orinvisibleclass for clarity of intent.
128-139: LGTM!The Dialog integration with limit checking on open and the trigger button setup are well-structured.
apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-update-dialog.tsx (3)
94-107: Form reset on dialog open is correctly implemented.The useEffect properly synchronizes form state when the dialog opens with potentially updated webhook data. Including
formin dependencies is safe sinceuseFormreturns a stable reference.
109-135: LGTM!The submit handler with event validation, mutation call, and success/error toast feedback is well-structured.
191-340: Event selection UI is well-implemented.The grouped checkbox dropdown with toggle-all and toggle-group functionality handles state transitions cleanly, including the edge case of transitioning from "all events" to specific selections.
apps/web/prisma/schema.prisma (3)
450-462: LGTM!The webhook status enums cover the essential lifecycle states.
AUTO_DISABLEDfor automatic disabling after repeated failures is a good pattern.
464-484: Webhook model is well-designed.Good inclusion of
consecutiveFailurescounter and timestamp tracking for failure/success. ThecreatedByUserIdwithonDelete: SetNullpreserves webhook records when users are deleted.
486-506: WebhookCall model appropriately captures delivery state.The composite index
[teamId, webhookId, status]aligns well with the query patterns inlistWebhookCalls. ThecreatedAt DESCindex supports efficient recent-first pagination.Note:
payloadandresponseTextare PrismaStringwhich maps to PostgreSQLTEXT(unbounded). The service already truncatesresponseTextto 4KB, andpayloadis controlled at emission time, so this should be fine. Based on learnings, Prisma is the correct choice for database access in apps/web.apps/web/src/server/service/webhook-service.ts (5)
20-28: LGTM!Constants are well-chosen and appropriately scoped. 10s timeout, 6 max attempts, 30 consecutive failures for auto-disable, and 4KB response capture limit are reasonable defaults.
45-85: Queue and worker initialization looks solid.The static initialization pattern ensures single queue/worker instances. Error logging on the worker and BullMQ's built-in retry with exponential backoff are correctly configured.
560-586: LGTM!The Redis locking implementation is correct - uses SET NX PX for atomic acquisition and a Lua script for safe owner-only release.
652-727: LGTM!The
postWebhookfunction properly handles timeout via AbortController, signature generation, and distinguishes between HTTP errors, timeouts, and fetch failures.
729-733: LGTM!The HMAC-SHA256 signature with timestamp prefix (
v1=) follows industry-standard webhook signing patterns (similar to Stripe).
| async function onConfirm(values: z.infer<typeof schema>) { | ||
| deleteWebhookMutation.mutate( | ||
| { id: webhook.id }, | ||
| { | ||
| onSuccess: async () => { | ||
| await utils.webhook.list.invalidate(); | ||
| toast.success("Webhook deleted"); | ||
| }, | ||
| onError: (error) => { | ||
| toast.error(error.message); | ||
| }, | ||
| }, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use mutateAsync instead of mutate for proper async handling.
The onConfirm function is declared as async but uses mutate() which is fire-and-forget. This means the function returns immediately without waiting for the mutation to complete, potentially causing the dialog to close prematurely or the isLoading state to not work correctly with the DeleteResource component.
async function onConfirm(values: z.infer<typeof schema>) {
- deleteWebhookMutation.mutate(
+ await deleteWebhookMutation.mutateAsync(
{ id: webhook.id },
- {
- onSuccess: async () => {
- await utils.webhook.list.invalidate();
- toast.success("Webhook deleted");
- },
- onError: (error) => {
- toast.error(error.message);
- },
- },
);
+ await utils.webhook.list.invalidate();
+ toast.success("Webhook deleted");
}Alternatively, wrap the mutation in a try-catch if you prefer inline error handling:
async function onConfirm(values: z.infer<typeof schema>) {
try {
await deleteWebhookMutation.mutateAsync({ id: webhook.id });
await utils.webhook.list.invalidate();
toast.success("Webhook deleted");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to delete webhook");
}
}🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/dev-settings/webhooks/delete-webhook.tsx around
lines 28 to 41, the async onConfirm currently calls deleteWebhookMutation.mutate
which is fire-and-forget; change it to await
deleteWebhookMutation.mutateAsync(...) so the function waits for the server
response, then await utils.webhook.list.invalidate() and call toast.success on
success; wrap the await in a try-catch and call toast.error with a safe message
(use error.message if error is an Error) in the catch so errors are handled and
isLoading/state behave correctly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/web/src/server/service/contact-service.ts (2)
24-58: Minor TOCTOU for event type determination.There's a small race window between
findUnique(line 24) andupsert(line 33) where another request could create the contact, resulting in the event type being incorrectly determined ascontact.createdinstead ofcontact.updated. For webhook event purposes, this is a minor edge case and likely acceptable, but worth noting.If strict event accuracy is needed, consider using a transaction or checking the upsert result's
createdAtvsupdatedAttimestamps to determine if it was newly created.
107-127: These functions appear to be unused and should either be removed or updated to emit webhook events. ThesubscribeContactfunction in contact-service is not imported anywhere in the codebase, andunsubscribeContactis likewise unused here (the campaign-service version is used instead). If keeping these simple variants for internal use, they should emitcontact.updatedwebhooks for consistency withupdateContactand other contact modification functions.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (9)
apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsx(1 hunks)apps/web/src/server/api/routers/contacts.ts(1 hunks)apps/web/src/server/public-api/api/contacts/add-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/delete-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/update-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/upsert-contact.ts(1 hunks)apps/web/src/server/service/contact-queue-service.ts(1 hunks)apps/web/src/server/service/contact-service.ts(4 hunks)apps/web/src/server/service/domain-service.ts(12 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/web/src/app/(dashboard)/dev-settings/webhooks/webhook-list.tsx
- apps/web/src/server/service/domain-service.ts
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/server/public-api/api/contacts/delete-contact.tsapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/server/public-api/api/contacts/add-contact.tsapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/server/api/routers/contacts.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/service/contact-queue-service.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/server/public-api/api/contacts/delete-contact.tsapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/server/public-api/api/contacts/add-contact.tsapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/server/api/routers/contacts.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/service/contact-queue-service.ts
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/server/public-api/api/contacts/delete-contact.tsapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/server/public-api/api/contacts/add-contact.tsapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/server/api/routers/contacts.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/service/contact-queue-service.ts
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/server/public-api/api/contacts/delete-contact.tsapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/server/public-api/api/contacts/add-contact.tsapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/server/api/routers/contacts.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/service/contact-queue-service.ts
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/server/public-api/api/contacts/delete-contact.tsapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/server/public-api/api/contacts/add-contact.tsapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/server/api/routers/contacts.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/service/contact-queue-service.ts
🧬 Code graph analysis (4)
apps/web/src/server/public-api/api/contacts/delete-contact.ts (1)
apps/web/src/server/service/contact-service.ts (1)
deleteContact(82-92)
apps/web/src/server/public-api/api/contacts/update-contact.ts (1)
apps/web/src/server/service/contact-service.ts (1)
updateContact(65-80)
apps/web/src/server/api/routers/contacts.ts (1)
apps/web/src/server/api/trpc.ts (1)
contactBookProcedure(204-222)
apps/web/src/server/service/contact-queue-service.ts (1)
apps/web/src/server/service/contact-service.ts (1)
addOrUpdateContact(19-63)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (7)
apps/web/src/server/service/contact-queue-service.ts (1)
99-108: LGTM!The
teamIdis correctly destructured from job data and passed toaddOrUpdateContact, aligning with the updated service signature. The optional nature ofteamIdensures backward compatibility with existing queued jobs.apps/web/src/server/public-api/api/contacts/delete-contact.ts (1)
46-46: LGTM!The
team.idis correctly passed todeleteContact, enabling webhook emission for contact deletion events.apps/web/src/server/public-api/api/contacts/upsert-contact.ts (1)
55-59: LGTM!The
team.idis correctly passed as the third argument toaddOrUpdateContact, enabling per-team webhook event emission during upsert operations.apps/web/src/server/public-api/api/contacts/update-contact.ts (1)
59-63: LGTM!The
team.idis correctly passed toupdateContact, enabling webhook event emission for contact update operations.apps/web/src/server/public-api/api/contacts/add-contact.ts (1)
55-59: LGTM!The
team.idis correctly passed toaddOrUpdateContact, enabling webhook event emission when contacts are created or updated via the public API.apps/web/src/server/service/contact-service.ts (2)
143-178: Good defensive implementation of webhook emission.The
emitContactEventhelper properly:
- Falls back to DB lookup when
teamIdis not provided- Gracefully handles missing team by logging a warning and skipping emission
- Wraps in try/catch to prevent webhook failures from breaking contact operations
This ensures webhook integration doesn't introduce reliability regressions.
129-141: LGTM!The
buildContactPayloadhelper cleanly maps the PrismaContactentity to the webhook payload type with proper null handling and ISO date formatting.
| .mutation(async ({ ctx: { team }, input }) => { | ||
| const { contactId, ...contact } = input; | ||
| return contactService.updateContact(contactId, contact); | ||
| return contactService.updateContact(contactId, contact, team.id); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Find the contact service file
fd -e ts -e js | grep -i 'contact.*service' | head -20Repository: usesend/useSend
Length of output: 210
🏁 Script executed:
# Also search for where contactService is imported from
rg -n 'contactService' apps/web/src/server/api/routers/contacts.ts | head -5Repository: usesend/useSend
Length of output: 326
🏁 Script executed:
# Search for the contactService definition/import
rg -n 'import.*contactService' apps/web/src/server/api/routers/contacts.tsRepository: usesend/useSend
Length of output: 128
🏁 Script executed:
# Read the contact-service.ts file to check updateContact and deleteContact implementations
cat apps/web/src/server/service/contact-service.tsRepository: usesend/useSend
Length of output: 4209
🏁 Script executed:
# Search for the contactBookProcedure middleware definition
rg -n 'contactBookProcedure' apps/web/src/server/api/routers/contacts.ts -B 5 -A 10Repository: usesend/useSend
Length of output: 3725
🏁 Script executed:
# Find where contactBookProcedure is defined
fd -e ts | xargs rg -l 'contactBookProcedure' | head -10Repository: usesend/useSend
Length of output: 134
🏁 Script executed:
# Read the TRPC middleware definition
cat apps/web/src/server/api/trpc.tsRepository: usesend/useSend
Length of output: 7484
🏁 Script executed:
# Check the Contact schema to see if there's any relationship validation
rg -A 20 'model Contact' apps/web/prisma/schema.prismaRepository: usesend/useSend
Length of output: 1137
Missing contact book scope validation creates authorization bypass.
The updateContact and deleteContact mutations use contactBookProcedure to validate the contact book exists, but neither mutation validates that the contact being modified actually belongs to that contact book. The contactBook is loaded into context but never used.
Because the service methods only receive contactId and team.id, they cannot validate contact ownership. This allows users to modify or delete any contact in their team by ID, regardless of which contact book they're accessing. The issue is inconsistent with addOrUpdateContact (which receives contactBookId) and represents a critical privilege escalation vulnerability.
Pass contactBook.id to the service methods, or add server-side validation in contact-service.updateContact and contact-service.deleteContact to verify the contact belongs to the expected contact book before executing the operation.
🤖 Prompt for AI Agents
In apps/web/src/server/api/routers/contacts.ts around lines 152-154, the
updateContact mutation calls contactService.updateContact(contactId, contact,
team.id) without validating the contact belongs to the contactBook in context,
allowing authorization bypass; change the call to include the contactBook id
(e.g. contactService.updateContact(contactId, contact, team.id, contactBook.id))
and do the same for deleteContact, and ensure the contact-service methods are
updated to accept the contactBookId and verify the contact belongs to that
contactBook before performing updates/deletes (or alternatively add an explicit
ownership check in the router using ctx.contactBook.id before calling the
service).
| .mutation(async ({ ctx: { team }, input }) => { | ||
| return contactService.deleteContact(input.contactId, team.id); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, find the contact service file
find . -type f -name "*contact*service*" | head -20Repository: usesend/useSend
Length of output: 216
🏁 Script executed:
# Search for deleteContact implementation
rg -n "deleteContact" --type=ts -A 15 | head -100Repository: usesend/useSend
Length of output: 9185
🏁 Script executed:
# Check the contacts router file structure
head -50 apps/web/src/server/api/routers/contacts.tsRepository: usesend/useSend
Length of output: 1516
🏁 Script executed:
# Look for the context/middleware that sets up contactBook
rg -n "contactBook" apps/web/src/server/api/routers/contacts.ts -B 2 -A 2Repository: usesend/useSend
Length of output: 2845
🏁 Script executed:
# Get the full contact-service.ts file to see deleteContact implementation
cat -n apps/web/src/server/service/contact-service.ts | head -150Repository: usesend/useSend
Length of output: 4540
🏁 Script executed:
# Get more context on deleteContact specifically
rg -A 20 "export async function deleteContact" apps/web/src/server/service/contact-service.tsRepository: usesend/useSend
Length of output: 608
🏁 Script executed:
# Check if contact has a teamId or contactBookId field in Prisma schema
rg -A 5 "model Contact" apps/web/prisma/schema.prismaRepository: usesend/useSend
Length of output: 432
🏁 Script executed:
# Get updateContact mutation implementation
rg -B 5 -A 15 "updateContact: contactBookProcedure" apps/web/src/server/api/routers/contacts.tsRepository: usesend/useSend
Length of output: 629
Pass contactBook.id to service or validate contact belongs to contact book before deletion.
The mutation uses contactBookProcedure middleware but only destructures {team}. The service layer then deletes the contact using only contactId without validating the contact belongs to the intended contact book. This creates a cross-book deletion vulnerability within the same team (same issue as updateContact).
Apply this fix:
- .mutation(async ({ ctx: { team }, input }) => {
+ .mutation(async ({ ctx: { contactBook, team }, input }) => {
- return contactService.deleteContact(input.contactId, team.id);
+ return contactService.deleteContact(input.contactId, team.id, contactBook.id);
}),Then update the deleteContact service to validate the contact belongs to the specified contact book before deletion.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In apps/web/src/server/api/routers/contacts.ts around lines 159-160, the
mutation uses contactBookProcedure but only extracts { team } and calls
contactService.deleteContact(input.contactId, team.id); modify the mutation to
also extract or pass contactBook.id (e.g., contactBook.id) into the service call
so the deletion request includes the contact book context, and then update
contactService.deleteContact to accept (contactId, teamId, contactBookId) and
validate that the contact's contactBookId matches the provided contactBookId
(and belongs to the team) before performing the delete; if validation fails,
throw an authorization/not-found error and do not delete.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (3)
apps/web/src/server/service/webhook-service.ts (3)
456-475: Consider clearingresponseTexton successful delivery.If a call previously failed (with
responseTextcaptured), then succeeds on retry, the old error response text may persist. SettingresponseText: nullin the success update ensures clean state.db.webhookCall.update({ where: { id: call.id }, data: { status: WebhookCallStatus.DELIVERED, attempt, responseStatus, responseTimeMs, lastError: null, nextAttemptAt: null, + responseText: null, }, }),
491-503: StaleconsecutiveFailuresvalue in auto-disable check.The check at line 499 uses
call.webhook.consecutiveFailures + 1, but this value was read before the atomic increment. Under concurrent failures, the local value may be stale, potentially delaying auto-disable.Read the updated value after the increment:
const updatedWebhook = await db.webhook.update({ where: { id: call.webhookId }, data: { consecutiveFailures: { increment: 1, }, lastFailureAt: new Date(), - status: - call.webhook.consecutiveFailures + 1 >= WEBHOOK_AUTO_DISABLE_THRESHOLD - ? WebhookStatus.AUTO_DISABLED - : call.webhook.status, }, }); + + if (updatedWebhook.consecutiveFailures >= WEBHOOK_AUTO_DISABLE_THRESHOLD) { + await db.webhook.update({ + where: { id: call.webhookId }, + data: { status: WebhookStatus.AUTO_DISABLED }, + }); + updatedWebhook.status = WebhookStatus.AUTO_DISABLED; + }
758-783: FlushTextDecoderafter stream ends.When using
stream: true, the decoder may hold partial multi-byte characters. Calldecoder.decode()(without arguments) after the loop to flush any remaining bytes.while (true) { const { done, value } = await reader.read(); if (done) { break; } // ... existing decoding logic } + + // Flush any remaining bytes from the decoder + const remaining = decoder.decode(); + if (remaining && !truncated) { + chunks += remaining; + } if (truncated) { return `${chunks}...<truncated>`; } return chunks;
🧹 Nitpick comments (6)
apps/web/src/server/service/ses-hook-parser.ts (1)
310-316: Consider tightening theeventDataparameter type.The parameter accepts
SesEvent | SesEvent[SesEventDataKey], but callers always pass the result ofgetEmailData()which returnsSesEvent[SesEventDataKey](never the fullSesEvent). Narrowing the type would improve clarity.function buildEmailWebhookPayload(params: { email: Email; status: EmailStatus; occurredAt: string; - eventData: SesEvent | SesEvent[SesEventDataKey]; + eventData: SesEvent[SesEventDataKey]; metadata?: Record<string, unknown>; }): EmailEventPayloadMap[EmailWebhookEventType] {apps/web/src/server/service/contact-service.ts (1)
24-31: Extra query to determine event type is reasonable but could be optimized.The
findUniquebeforeupsertadds a database round-trip. An alternative is to comparesavedContact.createdAtwithsavedContact.updatedAtto infer if it was created vs updated. However, this explicit approach is clearer and the overhead is minimal for single-contact operations.apps/web/src/server/service/webhook-service.ts (2)
100-104: Document that emptyeventTypessubscribes to all events.The
isEmpty: truecondition means webhooks with no specific event types receive all events. This semantic is reasonable but should be documented in the API/UI to avoid confusion.
433-444: Lock contention handling may cause tight retry loops.When the lock isn't acquired, the error is thrown immediately, relying on BullMQ's backoff. However, BullMQ's exponential backoff (starting at 5s) may be too aggressive for lock contention. The
nextAttemptAtset to 2s is reasonable, but the thrown error triggers BullMQ's longer backoff.Consider returning early without throwing to avoid BullMQ's backoff, or using a dedicated delay mechanism.
packages/lib/src/webhook/webhook-events.ts (2)
44-58:EmailStatusduplicates Prisma's enum.This type mirrors
@prisma/client'sEmailStatusenum. If the Prisma schema changes, this type must be updated manually. Consider importing from Prisma if this package can depend on the generated client, or add a comment noting the coupling.
60-72: Optional + nullable fields are explicit but redundant.Fields like
campaignId?: string | nullare both optional (?) and nullable (| null). This is verbose but harmless—consider simplifying to just| nullif the field is always present but sometimes null.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
apps/web/src/server/service/contact-service.ts(4 hunks)apps/web/src/server/service/domain-service.ts(12 hunks)apps/web/src/server/service/ses-hook-parser.ts(3 hunks)apps/web/src/server/service/webhook-service.ts(1 hunks)packages/lib/src/webhook/webhook-events.ts(1 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/server/service/domain-service.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/service/webhook-service.tspackages/lib/src/webhook/webhook-events.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/server/service/domain-service.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/service/webhook-service.tspackages/lib/src/webhook/webhook-events.ts
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/server/service/domain-service.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/service/webhook-service.ts
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/server/service/domain-service.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/service/webhook-service.ts
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/server/service/domain-service.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/service/webhook-service.tspackages/lib/src/webhook/webhook-events.ts
🧬 Code graph analysis (3)
apps/web/src/server/service/domain-service.ts (1)
packages/lib/src/webhook/webhook-events.ts (2)
DomainPayload(86-100)DomainWebhookEventType(16-16)
apps/web/src/server/service/ses-hook-parser.ts (3)
apps/web/src/server/service/webhook-service.ts (1)
WebhookService(84-369)packages/lib/src/webhook/webhook-events.ts (2)
EmailEventPayloadMap(151-165)EmailBasePayload(60-72)apps/web/src/types/aws-types.ts (4)
SesEvent(143-156)SesEventDataKey(132-141)SesBounce(30-50)SesClick(91-97)
apps/web/src/server/service/webhook-service.ts (7)
apps/web/src/server/queue/bullmq-context.ts (2)
TeamJob(5-5)createWorkerHandler(10-24)packages/lib/src/webhook/webhook-events.ts (2)
WebhookEventType(42-42)WebhookPayloadData(184-185)apps/web/src/server/queue/queue-constants.ts (2)
WEBHOOK_DISPATCH_QUEUE(6-6)DEFAULT_QUEUE_OPTIONS(9-14)apps/web/src/server/redis.ts (1)
getRedis(6-13)apps/web/src/server/db.ts (1)
db(20-20)apps/web/src/server/service/limit-service.ts (1)
LimitService(19-280)apps/web/src/server/public-api/api-error.ts (1)
UnsendApiError(62-75)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (16)
apps/web/src/server/service/domain-service.ts (4)
10-16: LGTM on webhook imports.The imports are correctly structured, bringing in the necessary types and service for webhook emission.
90-106: LGTM onbuildDomainPayloadhelper.The payload builder correctly maps all
Domainfields to theDomainPayloadtype, with proper ISO string conversion for dates.
387-396: LGTM onemitDomainEventhelper with proper error isolation.The try-catch ensures webhook failures don't break domain operations. The structured logging includes relevant context for debugging.
306-312: Emit event only when status actually changes.The conditional check
previousStatus !== domainWithDns.statuscorrectly prevents spurious webhook emissions during verification polling when the status hasn't changed.apps/web/src/server/service/ses-hook-parser.ts (3)
279-302: LGTM on webhook emission with proper error isolation.The try-catch ensures SES hook processing continues even if webhook emission fails. The error logging includes relevant context.
397-428: Default return of"email.queued"for unknown status is a safe fallback.Since
getEmailStatus()filters to known statuses before this function is called, the default case is unreachable in practice. The fallback is reasonable.
372-395: LGTM on bounce subtype normalization.The validation against a known list with a safe default of
"General"ensures the payload always contains a valid subtype.apps/web/src/server/service/contact-service.ts (3)
143-177: LGTM onemitContactEventwith teamId resolution fallback.The fallback query to resolve
teamIdfromcontactBookhandles cases where the caller doesn't have the team context. The warning log for missingteamIdaids debugging.
94-105: Bulk contacts don't emit individual webhook events.This is appropriate—emitting webhooks for potentially thousands of contacts would be problematic. Consider documenting this behavior or adding a summary webhook event (e.g.,
contact.bulk_imported) in the future if bulk operation notifications are needed.
129-141: LGTM onbuildContactPayloadhelper.The payload correctly maps Contact fields with proper date ISO conversion and type casting for properties.
apps/web/src/server/service/webhook-service.ts (3)
42-82: LGTM onWebhookQueueServiceinitialization.The static initialization pattern with error handler and logging is appropriate. The worker is configured with reasonable concurrency.
119-132: Sequential webhook call creation is correct for ordering guarantees.Creating calls in sequence ensures consistent ordering if multiple webhooks are subscribed. Parallelizing could be a future optimization if needed.
718-722: LGTM on HMAC signature generation.The
v1={signature}format with{timestamp}.{body}input is a standard webhook signing pattern, protecting against replay attacks.packages/lib/src/webhook/webhook-events.ts (3)
1-42: LGTM on event type definitions.The
as constassertions with derived union types provide both runtime arrays and compile-time type safety. The combinedWebhookEventsarray is useful for validation.
151-182: LGTM on payload maps.The event-to-payload mapping provides strong typing for webhook consumers. The intersection type for
WebhookEventPayloadMapcorrectly combines all domain-specific maps.
187-196: LGTM on genericWebhookEventtype.The generic type with constrained
TTypeensures type-safe event construction and consumption. TheWebhookEventDataunion is useful for discriminated union handling.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
♻️ Duplicate comments (4)
apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
26-39: UsemutateAsyncfor proper async handling inonConfirm.The
onConfirmfunction is declaredasyncbut usesmutate()which doesn't return a promise. This means the function returns immediately without waiting for the mutation to complete. UsemutateAsyncwith try-catch for proper async behavior.apps/web/src/server/service/webhook-service.ts (3)
739-807: FlushTextDecoderat end of stream to avoid dropping partial multi-byte characters.In
captureResponseText, the streaming branch decodes chunks withdecoder.decode(value, { stream: true })but never flushes the decoder whendone === true. For multi-byte encodings (e.g. UTF‑8), this can truncate a character at the boundary.You can flush once after the loop (when not already truncated) and reuse the same limit logic:
while (true) { const { done, value } = await reader.read(); if (done) { break; } @@ } } - - if (truncated) { - return `${chunks}...<truncated>`; - } - - return chunks; + if (!truncated) { + const decoded = decoder.decode(); + if (decoded) { + received += decoded.length; + if (received > WEBHOOK_RESPONSE_TEXT_LIMIT) { + const sliceRemaining = + WEBHOOK_RESPONSE_TEXT_LIMIT - (received - decoded.length); + chunks += decoded.slice(0, Math.max(0, sliceRemaining)); + truncated = true; + } else { + chunks += decoded; + } + } + } + + if (truncated) { + return `${chunks}...<truncated>`; + } + + return chunks;
471-490: ClearresponseTexton successful delivery to avoid stale error bodies.When a call is marked
DELIVERED,lastErrorandnextAttemptAtare cleared, butresponseTextis left as-is. After a retry succeeds, the UI could still show the previous failure body, which is misleading.You can null out
responseTextalongsidelastError:await db.$transaction([ db.webhookCall.update({ where: { id: call.id }, data: { status: WebhookCallStatus.DELIVERED, attempt, responseStatus, responseTimeMs, lastError: null, nextAttemptAt: null, + responseText: null, }, }),
491-558: Failure-path updates should be transactional and auto-disable should use the post-increment value.Two related issues here:
webhookandwebhookCallupdates are separate writes. If one succeeds and the other fails, counters and call state can diverge.- Auto-disable uses
call.webhook.consecutiveFailures + 1, which is based on the stale value loaded before the increment; under concurrent failures this can delay auto-disable.You can fix both by wrapping the updates in a single transaction and basing the threshold check on the updated
consecutiveFailures:- const updatedWebhook = await db.webhook.update({ - where: { id: call.webhookId }, - data: { - consecutiveFailures: { - increment: 1, - }, - lastFailureAt: new Date(), - status: - call.webhook.consecutiveFailures + 1 >= WEBHOOK_AUTO_DISABLE_THRESHOLD - ? WebhookStatus.AUTO_DISABLED - : call.webhook.status, - }, - }); - - await db.webhookCall.update({ - where: { id: call.id }, - data: { - status: - attempt >= WEBHOOK_MAX_ATTEMPTS - ? WebhookCallStatus.FAILED - : WebhookCallStatus.PENDING, - attempt, - nextAttemptAt, - lastError: errorMessage, - responseStatus: responseStatus ?? undefined, - responseTimeMs: responseTimeMs ?? undefined, - responseText: responseText ?? undefined, - }, - }); - - const statusLabel = - updatedWebhook.status === WebhookStatus.AUTO_DISABLED - ? "auto-disabled" - : "failed"; + const [updatedWebhook] = await db.$transaction([ + db.webhook.update({ + where: { id: call.webhookId }, + data: { + consecutiveFailures: { + increment: 1, + }, + lastFailureAt: new Date(), + }, + }), + db.webhookCall.update({ + where: { id: call.id }, + data: { + status: + attempt >= WEBHOOK_MAX_ATTEMPTS + ? WebhookCallStatus.FAILED + : WebhookCallStatus.PENDING, + attempt, + nextAttemptAt, + lastError: errorMessage, + responseStatus: responseStatus ?? undefined, + responseTimeMs: responseTimeMs ?? undefined, + responseText: responseText ?? undefined, + }, + }), + ]); + + let finalWebhook = updatedWebhook; + if ( + updatedWebhook.consecutiveFailures >= WEBHOOK_AUTO_DISABLE_THRESHOLD && + updatedWebhook.status !== WebhookStatus.AUTO_DISABLED + ) { + finalWebhook = await db.webhook.update({ + where: { id: call.webhookId }, + data: { + status: WebhookStatus.AUTO_DISABLED, + }, + }); + } + + const statusLabel = + finalWebhook.status === WebhookStatus.AUTO_DISABLED + ? "auto-disabled" + : "failed"; @@ - if (updatedWebhook.status === WebhookStatus.AUTO_DISABLED) { + if (finalWebhook.status === WebhookStatus.AUTO_DISABLED) { return; }This keeps the webhook and call in sync and makes the auto-disable decision against the actual incremented count under concurrency.
🧹 Nitpick comments (17)
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
228-250: Consider usingWebhookStatusenum for type safety.The
statusparameter usesstringtype, but importing and using theWebhookStatusenum from@prisma/clientwould provide better type safety and autocomplete, consistent with howWebhookCallStatusBadgeusesWebhookCallStatus.+import { WebhookStatus } from "@prisma/client"; -function WebhookStatusBadge({ status }: { status: string }) { +function WebhookStatusBadge({ status }: { status: WebhookStatus }) { let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; let label = status; - if (status === "ACTIVE") { + if (status === WebhookStatus.ACTIVE) { badgeColor = "bg-green/15 text-green border border-green/20"; label = "Active"; - } else if (status === "PAUSED") { + } else if (status === WebhookStatus.PAUSED) { badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; label = "Paused"; - } else if (status === "AUTO_DISABLED") { + } else if (status === WebhookStatus.AUTO_DISABLED) { badgeColor = "bg-red/15 text-red border border-red/20"; label = "Auto disabled"; }apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx (1)
40-43: Handle potential clipboard API errors.
navigator.clipboard.writeTextreturns a Promise and can fail in certain contexts (e.g., insecure origins, permission denied). Consider handling the error to provide appropriate user feedback.- const handleCopySecret = () => { - navigator.clipboard.writeText(webhook.secret); - toast.success("Secret copied to clipboard"); + const handleCopySecret = async () => { + try { + await navigator.clipboard.writeText(webhook.secret); + toast.success("Secret copied to clipboard"); + } catch { + toast.error("Failed to copy to clipboard"); + } };apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx (3)
20-28: Distinguish between loading and error states.The current check
if (!call)doesn't distinguish between loading and error states. Consider checkingcallQuery.isLoadingandcallQuery.isErrorseparately to provide appropriate feedback (e.g., show an error message if the query failed).+ if (callQuery.isLoading) { + return ( + <Card className="h-full"> + <CardContent className="p-6 flex items-center justify-center h-full"> + <p className="text-muted-foreground text-sm">Loading call details...</p> + </CardContent> + </Card> + ); + } - if (!call) { + if (callQuery.isError || !call) { return ( <Card className="h-full"> <CardContent className="p-6 flex items-center justify-center h-full"> - <p className="text-muted-foreground text-sm">Loading call details...</p> + <p className="text-muted-foreground text-sm"> + {callQuery.isError ? "Failed to load call details" : "Call not found"} + </p> </CardContent> </Card> ); }
57-57: Use enum for status comparison.For consistency with the
WebhookCallStatusBadgecomponent and better type safety, useWebhookCallStatus.FAILEDinstead of the string literal.+import { WebhookCallStatus } from "@prisma/client"; - {call.status === "FAILED" && ( + {call.status === WebhookCallStatus.FAILED && (
145-157: Consider parsing response body as JSON for consistency.The request payload is parsed as JSON for pretty-printing (lines 46-51), but the response body is displayed raw. For consistency and better readability when the response is JSON, consider applying the same parsing logic.
+ let parsedResponse: unknown; + if (call.responseText) { + try { + parsedResponse = JSON.parse(call.responseText); + } catch { + parsedResponse = call.responseText; + } + } {call.responseText && ( <> <Separator /> <div className="flex flex-col gap-3"> <h4 className="font-medium text-sm">Response Body</h4> <div className="bg-muted/30 border rounded-lg p-4 overflow-auto max-h-[300px]"> <pre className="text-xs font-mono whitespace-pre-wrap break-words"> - {call.responseText} + {typeof parsedResponse === 'string' + ? parsedResponse + : JSON.stringify(parsedResponse, null, 2)} </pre> </div> </div> </> )}apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (2)
128-133: Simplify the redundant condition inonOpenChange.The condition
nextOpen !== openis unnecessary sinceonOpenChangeis only called when the state actually changes.<Dialog open={open} - onOpenChange={(nextOpen) => - nextOpen !== open ? onOpenChange(nextOpen) : null - } + onOpenChange={onOpenChange} >
167-316: Consider extracting the event selection logic into a reusable component.The event selection dropdown with
handleToggleAll,handleToggleGroup, andhandleToggleEventlogic is duplicated inwebhook-update-dialog.tsx. Consider extracting this into a sharedEventTypeSelectorcomponent to reduce duplication and improve maintainability.apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx (1)
88-104: Consider handling the error state.The component handles loading and empty states but doesn't display feedback when the query fails. Users won't know if something went wrong.
<TableBody> {callsQuery.isLoading ? ( <TableRow className="h-32 hover:bg-transparent"> <TableCell colSpan={5} className="py-4 text-center"> <Spinner className="mx-auto h-6 w-6" innerSvgClass="stroke-primary" /> </TableCell> </TableRow> + ) : callsQuery.isError ? ( + <TableRow className="h-32 hover:bg-transparent"> + <TableCell colSpan={5} className="py-4 text-center"> + <p className="text-destructive text-sm"> + Failed to load webhook calls + </p> + </TableCell> + </TableRow> ) : calls.length === 0 ? (apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (3)
16-17: Remove unused imports.
FormDescriptionis imported but not used in this component.import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@usesend/ui/src/form";
92-104: Potential infinite loop risk withformin dependency array.Including
formin the dependency array may cause unnecessary re-renders since the form object reference can change. Theform.resetcall should be stable.useEffect(() => { if (open) { const hasAllEvents = (webhook.eventTypes as WebhookEventType[]).length === 0; form.reset({ url: webhook.url, eventTypes: hasAllEvents ? [] : (webhook.eventTypes as WebhookEventType[]), }); setAllEventsSelected(hasAllEvents); } - }, [open, webhook, form]); + }, [open, webhook, form.reset]);Alternatively, if ESLint complains, you can disable the warning for this line since
form.resetfrom react-hook-form is stable.
272-298: Dropdown menu structure differs from add-webhook component.The edit dialog uses
DropdownMenuSeparatorafter each group while the add dialog uses a different structure with nested divs and spacing classes. While both are functional, consider unifying the UI patterns for consistency, ideally by extracting the shared event selector component mentioned earlier.apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx (3)
128-150: Consider using theWebhookStatusenum for type safety.The status comparison uses string literals. Using the imported
WebhookStatusenum would provide type safety and catch typos at compile time.+import { WebhookStatus } from "@prisma/client"; + function WebhookStatusBadge({ status }: { status: string }) { +function WebhookStatusBadge({ status }: { status: WebhookStatus }) { let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; let label = status; - if (status === "ACTIVE") { + if (status === WebhookStatus.ACTIVE) { badgeColor = "bg-green/15 text-green border border-green/20"; label = "Active"; - } else if (status === "PAUSED") { + } else if (status === WebhookStatus.PAUSED) { badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; label = "Paused"; - } else if (status === "AUTO_DISABLED") { + } else if (status === WebhookStatus.AUTO_DISABLED) { badgeColor = "bg-red/15 text-red border border-red/20"; label = "Auto disabled"; }Note:
WebhookStatusis already available from@prisma/client(imported astype Webhookon line 27, but the enum isn't imported).
194-210: Consider using theWebhookStatusenum inhandleToggleStatus.Similar to the badge component, using the enum would improve type safety.
const handleToggleStatus = (currentStatus: string) => { - const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE"; + const newStatus = currentStatus === WebhookStatus.ACTIVE + ? WebhookStatus.PAUSED + : WebhookStatus.ACTIVE; setStatusMutation.mutate( { id: webhookId, status: newStatus },
301-307: Conditional rendering of EditWebhookDialog prevents animation on close.Rendering the dialog only when
isEditDialogOpenis true means the dialog unmounts immediately when closed, preventing exit animations. The dialog component already handles visibility via theopenprop.- {isEditDialogOpen && ( - <EditWebhookDialog - webhook={webhook} - open={isEditDialogOpen} - onOpenChange={setIsEditDialogOpen} - /> - )} + <EditWebhookDialog + webhook={webhook} + open={isEditDialogOpen} + onOpenChange={setIsEditDialogOpen} + />apps/web/src/server/service/webhook-service.ts (3)
135-194: UseUnsendApiErrorforretryCall/testWebhooknot-found cases for consistency.
retryCallandtestWebhookthrow plainErrorwhen the call/webhook is not found, while the rest of the service usesUnsendApiErrorwith typed codes. This will produce inconsistent behavior at the API boundary.Consider aligning these with the existing pattern:
public static async retryCall(params: { callId: string; teamId: number }) { @@ - if (!call) { - throw new Error("Webhook call not found"); - } + if (!call) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Webhook call not found", + }); + } @@ public static async testWebhook(params: { @@ - if (!webhook) { - throw new Error("Webhook not found"); - } + if (!webhook) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Webhook not found", + }); + }
501-533: Consider always overwritingresponseStatus/responseTimeMs/responseTexton failures.On failure, you only set these fields when they’re non‑null (
?? undefined). For failures like timeouts wherestatusCodeisnull, the previous status/text from an earlier attempt will remain, which can be confusing when inspecting the last failure.If the intent is “these fields describe the last attempt,” it’s safer to always overwrite them (including with
null):- lastError: errorMessage, - responseStatus: responseStatus ?? undefined, - responseTimeMs: responseTimeMs ?? undefined, - responseText: responseText ?? undefined, + lastError: errorMessage, + responseStatus, + responseTimeMs, + responseText,This assumes the Prisma schema allows
nullfor these columns; if not, you can normalize to sentinel values instead.
442-459: Remove stale TODO comment above the implemented delivery logic.The comment still says “TODO: perform signed HTTP POST with backoff tracking and update status/metrics,” but the code below now fully implements signed delivery, backoff, and status updates. Keeping this TODO is misleading for future readers.
You can safely delete those two comment lines.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (13)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx(1 hunks)apps/web/src/components/AppSideBar.tsx(2 hunks)apps/web/src/server/api/routers/webhook.ts(1 hunks)apps/web/src/server/service/webhook-service.ts(1 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/components/AppSideBar.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/service/webhook-service.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/components/AppSideBar.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/service/webhook-service.ts
**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
React components must use PascalCase naming convention (e.g., AppSideBar.tsx)
Files:
apps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/components/AppSideBar.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/components/AppSideBar.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/service/webhook-service.ts
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/components/AppSideBar.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/service/webhook-service.ts
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/components/AppSideBar.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/service/webhook-service.ts
🧠 Learnings (1)
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx} : Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Applied to files:
apps/web/src/server/api/routers/webhook.ts
🧬 Code graph analysis (4)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx (1)
apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx (1)
WebhookCallStatusBadge(3-41)
apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
apps/web/src/components/DeleteResource.tsx (1)
DeleteResource(63-199)
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (2)
packages/lib/src/webhook/webhook-events.ts (5)
WebhookEvents(36-40)WebhookEventType(42-42)ContactEvents(1-5)DomainEvents(9-14)EmailEvents(18-32)apps/web/src/store/upgradeModalStore.ts (1)
useUpgradeModalStore(13-20)
apps/web/src/server/api/routers/webhook.ts (3)
packages/lib/src/webhook/webhook-events.ts (1)
WebhookEvents(36-40)apps/web/src/server/api/trpc.ts (2)
createTRPCRouter(82-82)teamProcedure(127-152)apps/web/src/server/service/webhook-service.ts (1)
WebhookService(84-384)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (18)
apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx (1)
1-41: LGTM!The component correctly handles all
WebhookCallStatusenum values with appropriate color coding. The switch-case structure is clear and type-safe.apps/web/src/components/AppSideBar.tsx (1)
101-105: LGTM!The new Webhooks navigation item follows the existing pattern and is correctly placed in the Settings section.
apps/web/src/app/(dashboard)/webhooks/page.tsx (1)
1-17: LGTM!Clean page composition following existing patterns in the codebase.
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx (1)
18-38: Consider potential data incompleteness for high-volume webhooks.The query fetches only 50 calls, but the UI displays "Last 7 Days Activity" stats. For high-volume webhooks with more than 50 calls in 7 days, the counts may be incomplete. Consider adding a note in the UI or fetching aggregated counts from the backend if accurate stats are important.
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (2)
1-65: LGTM! Schema and event group definitions are well-structured.The Zod schema correctly validates the URL and event types using the shared
WebhookEventsconstant from@usesend/lib. TheeventGroupsarray provides a clean structure for the UI grouping.
66-81: LGTM! Component initialization is correct.State management and form setup follow React best practices. The
limitsQueryintegration enables proper plan-based restrictions.apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx (2)
35-43: LGTM! Query setup with filter is correct.The status filter correctly passes
undefinedwhen "ALL" is selected, and the query includes proper pagination with a limit.
106-136: LGTM! Table row rendering is well-implemented.The row selection state, hover effects, and data display are correctly implemented. Using
formatDistanceToNowfor relative times improves readability.apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (2)
37-38: Remove unused imports.
DropdownMenuLabelandDropdownMenuSeparatorare imported but the add-webhook component uses a different dropdown structure. Verify these are actually needed; looking at the render, they are used (lines 260-271, 282, 297), so this is fine.
114-130: LGTM! Mutation handling is correct.The update mutation properly invalidates the webhook list cache, shows appropriate toast messages, and closes the dialog on success.
apps/web/src/server/api/routers/webhook.ts (5)
1-7: LGTM! Imports are correctly structured.The router properly imports
WebhookEventsfrom@usesend/libfor the event type enum, addressing the previous review comment about the missing import.
9-21: LGTM! List and getById endpoints are correctly implemented.Both endpoints properly use
ctx.team.idfor team scoping and delegate toWebhookService.
23-41: LGTM! Create endpoint has proper validation.The input schema validates URL format and event types. The endpoint correctly passes team and user context to the service. The TOCTOU race condition mentioned in past reviews is acknowledged as low-risk given the user-initiated nature and low limits.
43-64: LGTM! Update endpoint handles optional fields correctly.The schema allows partial updates with optional fields and properly supports both
rotateSecretand directsecretinput.
99-135: LGTM! Call-related endpoints are well-structured.The
listCallsendpoint properly supports optional filtering by webhookId and status with pagination. ThegetCallandretryCallendpoints correctly use team scoping.apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx (3)
29-126: LGTM! WebhookDetailActions is well-implemented.The action menu correctly handles all webhook operations with proper disabled states during pending mutations. The AUTO_DISABLED status correctly prevents toggle actions.
173-177: useEffect dependency array is correct but may cause unwanted re-selection.If
callsQuery.datachanges (e.g., after invalidation), andselectedCallIdis already set to a valid ID, the condition!selectedCallIdprevents re-selection. However, if the previously selected call no longer exists in the new data, the user will see stale details until they manually select another row.Consider verifying the selected call still exists after data refresh:
useEffect(() => { - if (!selectedCallId && callsQuery.data?.items.length) { + const items = callsQuery.data?.items ?? []; + const selectedExists = selectedCallId && items.some(c => c.id === selectedCallId); + if (!selectedExists && items.length) { - setSelectedCallId(callsQuery.data.items[0]!.id); + setSelectedCallId(items[0]!.id); } }, [callsQuery.data, selectedCallId]);
227-241: LGTM! Loading and not-found states are handled.The component properly renders loading feedback while the query is in progress and shows a clear message when the webhook is not found.
| <TableRow className="h-32"> | ||
| <TableCell colSpan={6} className="py-4 text-center"> | ||
| <Spinner | ||
| className="mx-auto h-6 w-6" | ||
| innerSvgClass="stroke-primary" | ||
| /> | ||
| </TableCell> | ||
| </TableRow> | ||
| ) : webhooks.length === 0 ? ( | ||
| <TableRow className="h-32"> | ||
| <TableCell colSpan={6} className="py-4 text-center"> | ||
| <p>No webhooks configured</p> | ||
| </TableCell> | ||
| </TableRow> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Incorrect colSpan value.
The table has 5 columns (URL, Status, Last success, Last failure, Actions), but colSpan={6} is used for the loading and empty states. This should be colSpan={5}.
<TableRow className="h-32">
- <TableCell colSpan={6} className="py-4 text-center">
+ <TableCell colSpan={5} className="py-4 text-center">
<Spinner
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : webhooks.length === 0 ? (
<TableRow className="h-32">
- <TableCell colSpan={6} className="py-4 text-center">
+ <TableCell colSpan={5} className="py-4 text-center">
<p>No webhooks configured</p>
</TableCell>
</TableRow>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <TableRow className="h-32"> | |
| <TableCell colSpan={6} className="py-4 text-center"> | |
| <Spinner | |
| className="mx-auto h-6 w-6" | |
| innerSvgClass="stroke-primary" | |
| /> | |
| </TableCell> | |
| </TableRow> | |
| ) : webhooks.length === 0 ? ( | |
| <TableRow className="h-32"> | |
| <TableCell colSpan={6} className="py-4 text-center"> | |
| <p>No webhooks configured</p> | |
| </TableCell> | |
| </TableRow> | |
| <TableRow className="h-32"> | |
| <TableCell colSpan={5} className="py-4 text-center"> | |
| <Spinner | |
| className="mx-auto h-6 w-6" | |
| innerSvgClass="stroke-primary" | |
| /> | |
| </TableCell> | |
| </TableRow> | |
| ) : webhooks.length === 0 ? ( | |
| <TableRow className="h-32"> | |
| <TableCell colSpan={5} className="py-4 text-center"> | |
| <p>No webhooks configured</p> | |
| </TableCell> | |
| </TableRow> |
🤖 Prompt for AI Agents
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx around lines 88 to 101:
the loading and empty-state TableCell use colSpan={6} while the table only has 5
columns (URL, Status, Last success, Last failure, Actions). Change both colSpan
values from 6 to 5 so the spinner and "No webhooks configured" message properly
span the full table width.
| test: teamProcedure | ||
| .input(z.object({ id: z.string() })) | ||
| .mutation(async ({ ctx, input }) => { | ||
| return WebhookService.testWebhook({ | ||
| webhookId: input.id, | ||
| teamId: ctx.team.id, | ||
| }); | ||
| }), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Search for testWebhook implementation in the webhook service
find apps/web/src -name "*webhook*service*" -o -name "*service*webhook*" 2>/dev/null | head -20Repository: usesend/useSend
Length of output: 105
🏁 Script executed:
# Find the actual service file
rg -l "testWebhook" apps/web/srcRepository: usesend/useSend
Length of output: 262
🏁 Script executed:
# Get the webhook-service.ts file to see testWebhook implementation
wc -l apps/web/src/server/service/webhook-service.tsRepository: usesend/useSend
Length of output: 109
🏁 Script executed:
# Read the webhook-service.ts file
cat -n apps/web/src/server/service/webhook-service.tsRepository: usesend/useSend
Length of output: 25632
🏁 Script executed:
# Check the webhook router to see how testWebhook errors are handled
sed -n '85,100p' apps/web/src/server/api/routers/webhook.tsRepository: usesend/useSend
Length of output: 391
🏁 Script executed:
# Check if other methods in WebhookService use UnsendApiError for consistency
rg "throw new UnsendApiError|throw new Error" apps/web/src/server/service/webhook-service.ts -B 2Repository: usesend/useSend
Length of output: 666
Use UnsendApiError for consistency and validate webhook status upfront.
The testWebhook method at line 171 throws a generic Error instead of UnsendApiError with code "NOT_FOUND", inconsistent with other methods like getWebhook, updateWebhook, and deleteWebhook. Additionally, the method does not validate webhook status (ACTIVE vs DELETED/AUTO_DISABLED) before creating the test call. While deleted webhooks are eventually discarded during async processing (line 419-432 in processWebhookCall), the test call is still created and processed unnecessarily. Update to throw UnsendApiError for not-found and check webhook status before proceeding.
🤖 Prompt for AI Agents
In apps/web/src/server/api/routers/webhook.ts around lines 90 to 97, the
testWebhook mutation currently throws a generic Error when the webhook isn't
found and does not check the webhook's status before creating a test call;
change the not-found branch to throw an UnsendApiError with code "NOT_FOUND"
(matching get/update/delete) and, before creating the test call, validate that
webhook.status === "ACTIVE" (if not, throw UnsendApiError with code "NOT_FOUND"
as well) so deleted or auto_disabled webhooks are rejected upfront and no test
call is created.
0b6b037 to
6185cc4
Compare
6185cc4 to
a05d70f
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/src/server/public-api/api/contacts/upsert-contact.ts (1)
8-21: MissingcontactIdpath parameter validation and usage.The route path includes
{contactId}but theparamsschema only validatescontactBookId. ThecontactIdis never extracted or passed toaddOrUpdateContact. For an upsert endpoint, the contact identifier from the path should likely be used to identify which contact to update.params: z.object({ contactBookId: z .string() .min(3) .openapi({ param: { name: "contactBookId", in: "path", }, example: "cuiwqdj74rygf74", }), + contactId: z + .string() + .min(1) + .openapi({ + param: { + name: "contactId", + in: "path", + }, + example: "contact123", + }), }),Then ensure the
contactIdis passed to the service layer if needed for the upsert logic.
♻️ Duplicate comments (4)
apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
26-39: Async/await mismatch withmutate()instead ofmutateAsync().The
onConfirmfunction is declaredasyncbut usesmutate()which is fire-and-forget. TheDeleteResourcecomponent'shandleSubmitawaitsonConfirm, expecting it to complete before proceeding. This can cause the dialog to close before the mutation completes.Use
mutateAsyncfor proper async handling:async function onConfirm(values: z.infer<typeof schema>) { - deleteWebhookMutation.mutate( - { id: webhook.id }, - { - onSuccess: async () => { - await utils.webhook.list.invalidate(); - toast.success("Webhook deleted"); - }, - onError: (error) => { - toast.error(error.message); - }, - }, - ); + try { + await deleteWebhookMutation.mutateAsync({ id: webhook.id }); + await utils.webhook.list.invalidate(); + toast.success("Webhook deleted"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to delete webhook"); + } }apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
89-101: IncorrectcolSpanvalue.The table has 5 columns (URL, Status, Last success, Last failure, Actions), but
colSpan={6}is used. This should becolSpan={5}.<TableRow className="h-32"> - <TableCell colSpan={6} className="py-4 text-center"> + <TableCell colSpan={5} className="py-4 text-center"> <Spinner className="mx-auto h-6 w-6" innerSvgClass="stroke-primary" /> </TableCell> </TableRow> ) : webhooks.length === 0 ? ( <TableRow className="h-32"> - <TableCell colSpan={6} className="py-4 text-center"> + <TableCell colSpan={5} className="py-4 text-center"> <p>No webhooks configured</p> </TableCell> </TableRow>apps/web/src/server/service/webhook-service.ts (2)
491-535: Make failure updates transactional and use the incremented failure count for auto-disableThe failure path updates the webhook and the call in two separate operations, and bases the auto-disable decision on
call.webhook.consecutiveFailures + 1:const updatedWebhook = await db.webhook.update({ where: { id: call.webhookId }, data: { consecutiveFailures: { increment: 1 }, lastFailureAt: new Date(), status: call.webhook.consecutiveFailures + 1 >= WEBHOOK_AUTO_DISABLE_THRESHOLD ? WebhookStatus.AUTO_DISABLED : call.webhook.status, }, }); await db.webhookCall.update({ ... });Under concurrency,
call.webhook.consecutiveFailuresis stale (loaded before this update), so the threshold check can be off by one and delay auto-disable. Also, if either update fails, the webhook’s counters and the call record can diverge.Wrapping both updates in a single transaction and basing the threshold on the returned
consecutiveFailuresavoids both issues, e.g.:const [updatedWebhook] = await db.$transaction([ db.webhook.update({ where: { id: call.webhookId }, data: { consecutiveFailures: { increment: 1 }, lastFailureAt: new Date(), }, }), db.webhookCall.update({ where: { id: call.id }, data: { status: attempt >= WEBHOOK_MAX_ATTEMPTS ? WebhookCallStatus.FAILED : WebhookCallStatus.PENDING, attempt, nextAttemptAt, lastError: errorMessage, responseStatus: responseStatus ?? undefined, responseTimeMs: responseTimeMs ?? undefined, responseText: responseText ?? undefined, }, }), ]); if (updatedWebhook.consecutiveFailures >= WEBHOOK_AUTO_DISABLE_THRESHOLD && updatedWebhook.status !== WebhookStatus.AUTO_DISABLED) { await db.webhook.update({ where: { id: updatedWebhook.id }, data: { status: WebhookStatus.AUTO_DISABLED }, }); }This keeps the call and webhook in sync and ensures the auto-disable decision uses the actual incremented value.
739-807: FlushTextDecoderat end of stream to avoid dropping trailing multi-byte charsIn
captureResponseText, the streaming branch decodes chunks withdecoder.decode(value, { stream: true }), but whendoneistrueyou returnchunks(orchunks + "...<truncated>") without a finaldecoder.decode()call. For some multi-byte encodings this can drop the last character if it spans chunk boundaries.After the
whileloop, add a final flush before returning when not truncated:// after the loop, before truncated check chunks += decoder.decode(); // flush remaining bytes if (truncated) { return `${chunks}...<truncated>`; } return chunks;This keeps behavior the same for ASCII while correctly handling multi-byte text.
🧹 Nitpick comments (13)
apps/web/src/server/service/limit-service.ts (1)
104-132: checkWebhookLimit matches existing limit patterns; consider counting semantics and DRY helperThe implementation is consistent with the other limit checks:
- Cloud-only gating via
env.NEXT_PUBLIC_IS_CLOUD.- Uses
TeamService.getTeamCached+getActivePlan(team)andPLAN_LIMITS[plan].webhooks.- Returns the standard
{ isLimitReached, limit, reason? }shape withreason: LimitReason.WEBHOOKonly when exceeded.Two non-blocking points to consider:
- Counting semantics –
db.webhook.count({ where: { teamId } })will count all webhooks for the team. If you have a concept of disabled/deleted webhooks and only want active ones to count against the plan limit, you may want to add an appropriate status filter here. Please double-check this matches product expectations.- Duplication across limit checks –
checkDomainLimit,checkContactBookLimit,checkTeamMemberLimit, andcheckWebhookLimitshare nearly identical structure. A small internal helper likecheckEntityLimit({ teamId, entity, limitKey, reason })could reduce repetition and make future changes (e.g., new gating rules) easier, though this is purely a maintainability improvement.apps/web/src/server/service/domain-service.ts (1)
10-16: Consider using package export path instead of direct source path.The import
@usesend/lib/src/webhook/webhook-eventsdirectly references the internal source structure. If the shared library re-exports these types from its main entry point, prefer using the cleaner package path for consistency with the import on line 5 (@usesend/lib).-import { - type DomainPayload, - type DomainWebhookEventType, -} from "@usesend/lib/src/webhook/webhook-events"; +import { + type DomainPayload, + type DomainWebhookEventType, +} from "@usesend/lib";apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx (2)
40-43: Add error handling for clipboard API.
navigator.clipboard.writeTextcan fail in insecure contexts or if the user denies permission. Consider wrapping in try-catch to handle failures gracefully.const handleCopySecret = () => { - navigator.clipboard.writeText(webhook.secret); - toast.success("Secret copied to clipboard"); + navigator.clipboard.writeText(webhook.secret).then( + () => toast.success("Secret copied to clipboard"), + () => toast.error("Failed to copy secret") + ); };
81-92: Consider adding aria-labels for accessibility.The icon-only buttons lack accessible labels. Screen readers won't be able to convey the button's purpose.
<Button variant="ghost" size="icon" onClick={() => setShowSecret(!showSecret)} className="h-5 w-5 text-muted-foreground hover:text-foreground" + aria-label={showSecret ? "Hide secret" : "Show secret"} >apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (2)
38-69: Remove unnecessaryasynckeyword from fire-and-forget handlers.Both
handleTestandhandleToggleStatusare declaredasyncbut don'tawaitanything. Themutate()call is fire-and-forget with callbacks. Theasynckeyword is misleading here.- async function handleTest(webhookId: string) { + function handleTest(webhookId: string) { testWebhook.mutate( { id: webhookId }, { - onSuccess: async () => { - await utils.webhook.listCalls.invalidate(); + onSuccess: () => { + void utils.webhook.listCalls.invalidate(); toast.success("Test webhook enqueued"); }, ... }, ); } - async function handleToggleStatus(webhookId: string, currentStatus: string) { + function handleToggleStatus(webhookId: string, currentStatus: string) { ... }
228-250: UseWebhookStatusenum for type safety.
WebhookStatusBadgeusesstringtype and literal comparisons. For consistency withWebhookCallStatusBadge(which uses the Prisma enum) and better type safety, useWebhookStatusfrom@prisma/client.+import { WebhookStatus } from "@prisma/client"; -function WebhookStatusBadge({ status }: { status: string }) { +function WebhookStatusBadge({ status }: { status: WebhookStatus }) { let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; let label = status; - if (status === "ACTIVE") { + if (status === WebhookStatus.ACTIVE) { badgeColor = "bg-green/15 text-green border border-green/20"; label = "Active"; - } else if (status === "PAUSED") { + } else if (status === WebhookStatus.PAUSED) { badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; label = "Paused"; - } else if (status === "AUTO_DISABLED") { + } else if (status === WebhookStatus.AUTO_DISABLED) { badgeColor = "bg-red/15 text-red border border-red/20"; label = "Auto disabled"; }apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx (2)
107-114: Consider usingcn()for conditional class composition.The template literal with ternary works but can become harder to maintain. The
cn()utility (commonly available via@usesend/ui) provides cleaner conditional class handling.+import { cn } from "@usesend/ui/src/utils"; + <TableRow key={call.id} - className={`cursor-pointer transition-colors ${ - selectedCallId === call.id - ? "bg-accent text-accent-foreground" - : "hover:bg-muted/50" - }`} + className={cn( + "cursor-pointer transition-colors", + selectedCallId === call.id + ? "bg-accent text-accent-foreground" + : "hover:bg-muted/50" + )} onClick={() => onSelectCall(call.id)} >
39-43: Consider adding pagination for webhook calls.The query limits results to 50 items without pagination controls. For webhooks with high traffic, users won't be able to access older delivery logs. Consider adding infinite scroll or pagination if the
listCallsendpoint supports cursor-based pagination.apps/web/src/server/service/ses-hook-parser.ts (1)
443-456: Consider defining proper types instead ofas anycasts.The
as anycasts for complaint and open data reduce type safety. If SES type definitions in~/types/aws-typesare incomplete, consider extending them to cover these event shapes.apps/web/prisma/schema.prisma (1)
491-498: Consider monitoring payload and response storage growth.The
payloadandresponseTextfields are unbounded strings. For high-volume webhooks, this could lead to significant database growth. The 30-day cleanup job helps, but consider:
- Truncating
responseTextto a reasonable limit (e.g., first 10KB)- Monitoring table size over time
apps/web/src/server/service/contact-service.ts (1)
24-61: Consider optimizing the extra query for determining create vs update.The additional
findUniquequery (lines 24-31) before the upsert adds latency to every contact operation. Alternatives to consider:
- Use Prisma's
$transactionwith conditional logic- Use a raw query with
INSERT ... ON CONFLICT ... RETURNINGand check if it was an insert- Accept always emitting
contact.updatedfor upserts (simpler but less precise)If precise event differentiation is important, a raw query approach could reduce to a single DB round-trip:
// Alternative using raw query (PostgreSQL) const result = await db.$queryRaw` INSERT INTO "Contact" (...) VALUES (...) ON CONFLICT ("contactBookId", "email") DO UPDATE SET ... RETURNING *, (xmax = 0) AS was_inserted `;apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx (1)
128-141: NarrowWebhookStatusBadgeprop type for better safety
WebhookStatusBadgetakesstatus: stringbut only handles"ACTIVE" | "PAUSED" | "AUTO_DISABLED". To avoid silent fallthrough if new statuses are added (e.g."DELETED"), consider:function WebhookStatusBadge({ status }: { status: Webhook["status"] }) { // ... }This keeps the badge in sync with the Prisma enum and surfaces missing cases at compile time.
packages/lib/src/webhook/webhook-events.ts (1)
1-42: Typed event surface looks solid; consider a dedicated exported array for runtime validationThe split between
ContactEvents,DomainEvents,EmailEvents, and the combinedWebhookEventsplus theWebhookEventPayloadMaptyping is clear and type-safe. If any consumers (like the TRPC router) need a runtime array for zod enums or UI listing, it can be useful to expose an explicit alias such as:export const WEBHOOK_EVENT_TYPES = WebhookEvents;so callers don’t depend on an internal name. If the router is already using
WebhookEventsdirectly, feel free to ignore this.Also applies to: 151-196
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (42)
apps/web/package.json(1 hunks)apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql(1 hunks)apps/web/prisma/schema.prisma(3 hunks)apps/web/src/app/(dashboard)/dev-settings/layout.tsx(2 hunks)apps/web/src/app/(dashboard)/emails/email-details.tsx(2 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx(1 hunks)apps/web/src/components/AppSideBar.tsx(2 hunks)apps/web/src/lib/constants/plans.ts(3 hunks)apps/web/src/server/api/root.ts(2 hunks)apps/web/src/server/api/routers/contacts.ts(1 hunks)apps/web/src/server/api/routers/email.ts(2 hunks)apps/web/src/server/api/routers/limits.ts(2 hunks)apps/web/src/server/api/routers/webhook.ts(1 hunks)apps/web/src/server/jobs/webhook-cleanup-job.ts(1 hunks)apps/web/src/server/public-api/api/contacts/add-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/delete-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/update-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/upsert-contact.ts(1 hunks)apps/web/src/server/queue/queue-constants.ts(1 hunks)apps/web/src/server/service/contact-queue-service.ts(1 hunks)apps/web/src/server/service/contact-service.ts(4 hunks)apps/web/src/server/service/domain-service.ts(12 hunks)apps/web/src/server/service/limit-service.ts(1 hunks)apps/web/src/server/service/ses-hook-parser.ts(3 hunks)apps/web/src/server/service/webhook-service.ts(1 hunks)apps/web/tailwind.config.ts(1 hunks)packages/lib/.eslintrc.cjs(1 hunks)packages/lib/package.json(1 hunks)packages/lib/src/index.ts(1 hunks)packages/lib/src/webhook/webhook-events.ts(1 hunks)packages/lib/tsconfig.json(1 hunks)packages/lib/tsconfig.lint.json(1 hunks)packages/ui/src/dropdown-menu.tsx(7 hunks)
🚧 Files skipped from review as they are similar to previous changes (21)
- apps/web/src/server/jobs/webhook-cleanup-job.ts
- apps/web/src/app/(dashboard)/emails/email-details.tsx
- apps/web/src/server/queue/queue-constants.ts
- apps/web/src/lib/constants/plans.ts
- packages/lib/package.json
- apps/web/src/server/service/contact-queue-service.ts
- apps/web/src/app/(dashboard)/dev-settings/layout.tsx
- apps/web/src/server/public-api/api/contacts/add-contact.ts
- apps/web/src/server/public-api/api/contacts/update-contact.ts
- apps/web/src/server/api/routers/contacts.ts
- packages/lib/.eslintrc.cjs
- apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx
- apps/web/package.json
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx
- packages/ui/src/dropdown-menu.tsx
- packages/lib/tsconfig.lint.json
- apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx
- apps/web/tailwind.config.ts
- packages/lib/tsconfig.json
- apps/web/src/components/AppSideBar.tsx
- apps/web/src/server/api/root.ts
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/server/api/routers/webhook.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/api/routers/email.tsapps/web/src/server/api/routers/limits.tsapps/web/src/server/service/contact-service.tspackages/lib/src/index.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/server/service/webhook-service.tsapps/web/src/server/public-api/api/contacts/delete-contact.tspackages/lib/src/webhook/webhook-events.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/server/api/routers/webhook.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/api/routers/email.tsapps/web/src/server/api/routers/limits.tsapps/web/src/server/service/contact-service.tspackages/lib/src/index.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/server/service/webhook-service.tsapps/web/src/server/public-api/api/contacts/delete-contact.tspackages/lib/src/webhook/webhook-events.ts
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/server/api/routers/webhook.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/api/routers/email.tsapps/web/src/server/api/routers/limits.tsapps/web/src/server/service/contact-service.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/server/service/webhook-service.tsapps/web/src/server/public-api/api/contacts/delete-contact.ts
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/server/api/routers/webhook.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/api/routers/email.tsapps/web/src/server/api/routers/limits.tsapps/web/src/server/service/contact-service.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/server/service/webhook-service.tsapps/web/src/server/public-api/api/contacts/delete-contact.ts
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/server/api/routers/webhook.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/api/routers/email.tsapps/web/src/server/api/routers/limits.tsapps/web/src/server/service/contact-service.tspackages/lib/src/index.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/server/service/webhook-service.tsapps/web/src/server/public-api/api/contacts/delete-contact.tspackages/lib/src/webhook/webhook-events.ts
**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
React components must use PascalCase naming convention (e.g., AppSideBar.tsx)
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx
🧠 Learnings (1)
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx} : Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Applied to files:
apps/web/src/server/api/routers/webhook.ts
🧬 Code graph analysis (11)
apps/web/src/server/api/routers/webhook.ts (3)
packages/lib/src/webhook/webhook-events.ts (1)
WebhookEvents(36-40)apps/web/src/server/api/trpc.ts (2)
createTRPCRouter(82-82)teamProcedure(127-152)apps/web/src/server/service/webhook-service.ts (1)
WebhookService(84-384)
apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
apps/web/src/components/DeleteResource.tsx (1)
DeleteResource(63-199)
apps/web/src/server/service/limit-service.ts (3)
apps/web/src/env.js (2)
env(5-141)env(5-141)apps/web/src/server/db.ts (1)
db(20-20)apps/web/src/lib/constants/plans.ts (1)
PLAN_LIMITS(13-40)
apps/web/src/app/(dashboard)/webhooks/page.tsx (2)
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (1)
AddWebhook(66-333)apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
WebhookList(28-161)
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (7)
apps/web/src/server/service/webhook-service.ts (1)
testWebhook(162-194)packages/ui/src/toaster.tsx (1)
toast(31-31)packages/ui/src/table.tsx (6)
Table(109-109)TableHeader(110-110)TableRow(114-114)TableHead(113-113)TableBody(111-111)TableCell(115-115)apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (1)
EditWebhookDialog(66-323)packages/ui/src/popover.tsx (1)
Popover(36-36)packages/ui/src/button.tsx (1)
Button(80-80)apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
DeleteWebhook(11-61)
apps/web/src/server/service/domain-service.ts (1)
packages/lib/src/webhook/webhook-events.ts (2)
DomainPayload(86-100)DomainWebhookEventType(16-16)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx (6)
apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
DeleteWebhook(11-61)apps/web/src/server/service/webhook-service.ts (1)
testWebhook(162-194)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx (1)
WebhookInfo(12-141)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx (1)
WebhookCallsTable(26-143)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx (1)
WebhookCallDetails(13-161)apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (1)
EditWebhookDialog(66-323)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx (5)
packages/ui/src/card.tsx (4)
Card(74-74)CardHeader(75-75)CardTitle(77-77)CardContent(79-79)packages/ui/src/select.tsx (5)
Select(150-150)SelectTrigger(153-153)SelectValue(152-152)SelectContent(154-154)SelectItem(156-156)packages/ui/src/table.tsx (6)
Table(109-109)TableHeader(110-110)TableRow(114-114)TableHead(113-113)TableBody(111-111)TableCell(115-115)packages/ui/src/spinner.tsx (1)
Spinner(4-51)apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx (1)
WebhookCallStatusBadge(3-41)
apps/web/src/server/service/ses-hook-parser.ts (2)
apps/web/src/server/service/webhook-service.ts (1)
WebhookService(84-384)packages/lib/src/webhook/webhook-events.ts (4)
EmailEventPayloadMap(151-165)EmailStatus(44-58)EmailWebhookEventType(34-34)EmailBasePayload(60-72)
apps/web/src/server/service/webhook-service.ts (6)
apps/web/src/server/queue/bullmq-context.ts (2)
TeamJob(5-5)createWorkerHandler(10-24)packages/lib/src/webhook/webhook-events.ts (2)
WebhookEventType(42-42)WebhookPayloadData(184-185)apps/web/src/server/queue/queue-constants.ts (2)
WEBHOOK_DISPATCH_QUEUE(6-6)DEFAULT_QUEUE_OPTIONS(9-14)apps/web/src/server/redis.ts (1)
getRedis(6-13)apps/web/src/server/db.ts (1)
db(20-20)apps/web/src/server/public-api/api-error.ts (1)
UnsendApiError(62-75)
apps/web/src/server/public-api/api/contacts/delete-contact.ts (1)
apps/web/src/server/service/contact-service.ts (1)
deleteContact(82-92)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (23)
apps/web/src/server/api/routers/limits.ts (1)
11-22: Webhook limit wiring in router looks correct and consistentThe new
LimitReason.WEBHOOKcase cleanly mirrors the existing limit checks and correctly delegates toLimitService.checkWebhookLimit(ctx.team.id), keeping the input schema and exhaustive switch pattern intact. No issues from a typing or control-flow standpoint.apps/web/src/server/service/domain-service.ts (2)
90-106: LGTM!The
buildDomainPayloadfunction correctly maps all fields defined in theDomainPayloadtype, with proper date serialization.
387-396: LGTM!The
emitDomainEventhelper correctly implements fire-and-forget semantics with proper error logging, ensuring webhook failures don't impact domain operations.apps/web/src/server/public-api/api/contacts/delete-contact.ts (1)
39-49: LGTM!The change correctly passes
team.idtodeleteContact, enabling team-scoped webhook event emission. This aligns with the updated service signature and matches the pattern used in other contact endpoints.apps/web/src/server/api/routers/email.ts (2)
5-5: LGTM!The import consolidation to
@usesend/libaligns with the new shared library structure introduced in this PR, promoting code reuse across packages.
204-219: LGTM!Formatting changes only; no functional impact.
apps/web/src/app/(dashboard)/webhooks/page.tsx (1)
1-17: LGTM!Clean page structure with proper component composition. The layout follows established patterns with a header containing the title and action button, followed by the list component.
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx (1)
119-135: The Tailwind color classestext-green,text-red, andtext-yelloware valid custom colors defined in the project's shared Tailwind configuration (packages/tailwind-config/tailwind.config.ts). These are intentionally configured as semantic color tokens using CSS variables and do not require changes.apps/web/src/server/public-api/api/contacts/upsert-contact.ts (1)
55-59: LGTM on theteamIdparameter addition.The addition of
team.idas the third parameter aligns with the webhook integration changes that require team context for event emission.apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx (1)
1-41: LGTM!Clean implementation with proper type safety from the Prisma enum and consistent styling across all status variants.
apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
41-60: LGTM on component structure.The
DeleteResourceintegration is well-configured with appropriate schema, loading state, and trigger button styling.apps/web/src/server/api/routers/webhook.ts (2)
9-135: Well-structured TRPC router with clean service delegation.The router follows the established pattern with:
- Proper team-scoped authorization via
teamProcedure- Comprehensive Zod input validation
- Clean delegation to
WebhookServicemethods
4-4: The suggested import path change would break the code;WebhookEventsis not exported from@usesend/lib.The current import from
@usesend/lib/src/webhook/webhook-eventsis necessary becauseWebhookEventsis not re-exported from the package entry point (packages/lib/src/index.ts). The direct source path import is the only working option in the current codebase structure.If this coupling to internal package paths is a concern, the proper fix is to export
WebhookEventsfrom@usesend/lib's entry point, not to change this import.Likely an incorrect or invalid review comment.
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (2)
163-226: LGTM onWebhookActionscomponent.Well-structured popover menu with proper state handling for different webhook statuses. The auto-disabled webhooks correctly disable the toggle button.
103-155: LGTM on row rendering and navigation.Good use of
stopPropagationto prevent row navigation when interacting with the actions menu. The edit dialog state management is clean.packages/lib/src/index.ts (1)
1-22: LGTM! Well-designed utility exports.The
invariant,assertUnreachable, andisDefinedutilities are idiomatic TypeScript patterns that provide good runtime/type-safety guarantees. The consolidated SES error re-exports simplify imports for consumers.apps/web/src/server/service/ses-hook-parser.ts (1)
279-302: Good resilience pattern for webhook emission.Wrapping the webhook emission in a try/catch ensures that failures in the webhook subsystem don't impact the core SES hook processing flow. The error logging includes relevant context (emailId, mailStatus).
apps/web/prisma/schema.prisma (1)
464-506: Well-structured webhook schema with appropriate indexes and relations.The schema design includes:
- Proper lifecycle tracking (
consecutiveFailures,lastFailureAt,lastSuccessAt) for automatic disabling- Appropriate cascade deletes for cleanup
- Useful composite index
(teamId, webhookId, status)for filtered queriesSetNulloncreatedByUserIdpreserves webhook if creator is deletedapps/web/src/server/service/contact-service.ts (2)
143-177: Solid defensive implementation for webhook emission.Good patterns here:
- Fallback teamId resolution when not provided
- Warning log for missing teamId (helps debugging)
- Try/catch prevents webhook failures from breaking contact operations
94-105: No changes needed. The function correctly queues contacts for processing viaContactQueueService.addBulkContactJobs, and the queue worker callsaddOrUpdateContactfor each contact, which emitscontact.createdorcontact.updatedwebhook events as appropriate. The webhook emission is properly implemented in the processing pipeline.apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx (1)
3-3: Clarify routeparamstyping and use of Reactuse()This
page.tsxis a route entry, butparamsis typed as aPromiseand unwrapped viause(params). In the app router, Next normally passesparamsas a plain object, so this pattern will only be correct if a parent is intentionally passing a promise here. If this is the route component itself, consider changing the signature to:export default function WebhookDetailPage({ params, }: { params: { webhookId: string }; }) { const { webhookId } = params; // ... }to avoid relying on
use()for something that is not actually a thenable and to align with Next’s standard route typing.Also applies to: 152-158
apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql (1)
1-66: Webhook schema and indexes look consistent with service usageEnums, tables, and indexes line up with how
WebhookServicequeries and maintains webhooks and calls (team scoping, status, createdAt, failure counters). Foreign keys and cascades also match expected lifecycle semantics. No blocking issues from a schema perspective.apps/web/src/server/service/webhook-service.ts (1)
709-727: The concern about DOMException availability is incorrect for the target environmentNode.js 18+ (the version this project targets) includes DOMException as a global object since v17.0.0. When fetch operations are aborted, they properly reject with a
DOMExceptioninstance withname === "AbortError". The current code usinginstanceof DOMException && error.name === "AbortError"is safe and correct.While checking
error.name === "AbortError"alone (as suggested) would also work and provides slightly broader compatibility across edge cases, the original approach is not problematic and doesn't need to be changed. The review comment's premise—that DOMException "may not be defined" in Node.js—is incorrect for the supported runtime.Likely an incorrect or invalid review comment.
| const handleToggleStatus = (currentStatus: string) => { | ||
| const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE"; | ||
| setStatusMutation.mutate( | ||
| { id: webhookId, status: newStatus }, | ||
| { | ||
| onSuccess: async () => { | ||
| await utils.webhook.getById.invalidate(); | ||
| toast.success( | ||
| `Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`, | ||
| ); | ||
| }, | ||
| onError: (error) => { | ||
| toast.error(error.message); | ||
| }, | ||
| }, | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tighten status typing in handleToggleStatus and consider AUTO_DISABLED semantics
handleToggleStatus currently accepts currentStatus: string, which makes newStatus inferred as string as well. Given that setStatusMutation expects a strongly-typed enum (WebhookStatus via TRPC / Prisma), this will cause a TypeScript mismatch and loses exhaustiveness:
const handleToggleStatus = (currentStatus: Webhook["status"]) => {
const newStatus: Webhook["status"] =
currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE";
setStatusMutation.mutate(
{ id: webhookId, status: newStatus },
// ...
);
};Also, the toggle button is disabled when webhook.status === "AUTO_DISABLED", which means there’s no way from this UI to re-activate an auto-disabled webhook even though the backend accepts ACTIVE. If the product should allow manual re-enabling, you may want to allow toggling from AUTO_DISABLED back to ACTIVE (or expose a separate “Resume” for that state).
Also applies to: 267-276
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx around lines
194-210 (and similarly 267-276), tighten the typing of handleToggleStatus to
accept Webhook["status"] and declare newStatus as Webhook["status"] so the value
passed into setStatusMutation matches the backend enum; update the toggle logic
to treat "AUTO_DISABLED" explicitly (either allow toggling "AUTO_DISABLED" ->
"ACTIVE" if manual re-enable is desired or keep it disabled and expose a
separate Resume action) and ensure the mutation call uses the strongly-typed
newStatus and the UI enable/disable logic reflects the chosen semantics.
| const sevenDaysAgo = new Date(); | ||
| sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); | ||
|
|
||
| const callsQuery = api.webhook.listCalls.useQuery({ | ||
| webhookId: webhook.id, | ||
| limit: 50, | ||
| }); | ||
|
|
||
| const calls = callsQuery.data?.items ?? []; | ||
| const last7DaysCalls = calls.filter( | ||
| (call) => new Date(call.createdAt) >= sevenDaysAgo, | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
7-day activity stats may be inaccurate when more than 50 calls exist.
The component fetches only the last 50 calls and then filters for the last 7 days client-side. If a webhook has more than 50 calls in 7 days, the displayed stats will be incomplete. Consider either:
- Increasing the limit
- Adding a server-side filter for date range
- Computing aggregates on the server
const callsQuery = api.webhook.listCalls.useQuery({
webhookId: webhook.id,
- limit: 50,
+ limit: 200,
});Alternatively, if the API supports it, add a date filter parameter to the query.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const sevenDaysAgo = new Date(); | |
| sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); | |
| const callsQuery = api.webhook.listCalls.useQuery({ | |
| webhookId: webhook.id, | |
| limit: 50, | |
| }); | |
| const calls = callsQuery.data?.items ?? []; | |
| const last7DaysCalls = calls.filter( | |
| (call) => new Date(call.createdAt) >= sevenDaysAgo, | |
| ); | |
| const sevenDaysAgo = new Date(); | |
| sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); | |
| const callsQuery = api.webhook.listCalls.useQuery({ | |
| webhookId: webhook.id, | |
| limit: 200, | |
| }); | |
| const calls = callsQuery.data?.items ?? []; | |
| const last7DaysCalls = calls.filter( | |
| (call) => new Date(call.createdAt) >= sevenDaysAgo, | |
| ); |
| function emailStatusToEvent(status: EmailStatus): EmailWebhookEventType { | ||
| switch (status) { | ||
| case EmailStatus.QUEUED: | ||
| return "email.queued"; | ||
| case EmailStatus.SENT: | ||
| return "email.sent"; | ||
| case EmailStatus.DELIVERY_DELAYED: | ||
| return "email.delivery_delayed"; | ||
| case EmailStatus.DELIVERED: | ||
| return "email.delivered"; | ||
| case EmailStatus.BOUNCED: | ||
| return "email.bounced"; | ||
| case EmailStatus.REJECTED: | ||
| return "email.rejected"; | ||
| case EmailStatus.RENDERING_FAILURE: | ||
| return "email.rendering_failure"; | ||
| case EmailStatus.COMPLAINED: | ||
| return "email.complained"; | ||
| case EmailStatus.FAILED: | ||
| return "email.failed"; | ||
| case EmailStatus.CANCELLED: | ||
| return "email.cancelled"; | ||
| case EmailStatus.SUPPRESSED: | ||
| return "email.suppressed"; | ||
| case EmailStatus.OPENED: | ||
| return "email.opened"; | ||
| case EmailStatus.CLICKED: | ||
| return "email.clicked"; | ||
| default: | ||
| return "email.queued"; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Default case may emit incorrect event type for unhandled statuses.
The default case returns "email.queued", but EmailStatus.SCHEDULED is not explicitly handled and would fall through to this default. This could result in emitting email.queued events when a scheduled status is encountered, which may be semantically incorrect.
Consider explicitly handling SCHEDULED or logging a warning for unexpected statuses:
case EmailStatus.CLICKED:
return "email.clicked";
+ case EmailStatus.SCHEDULED:
+ // Scheduled emails don't trigger webhook events yet
+ return "email.queued"; // or throw/log if this shouldn't happen
default:
+ logger.warn({ status }, "Unexpected email status for webhook event");
return "email.queued";
}Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In apps/web/src/server/service/ses-hook-parser.ts around lines 397 to 428, the
switch's default returns "email.queued" which mislabels unhandled statuses (e.g.
EmailStatus.SCHEDULED) — explicitly add a case for EmailStatus.SCHEDULED
returning "email.scheduled" (or the correct event string your domain uses), and
change the default to either throw or log a warning and return a safe fallback
(rather than silently mapping unknown statuses to "email.queued"); ensure you
update tests/type definitions if any rely on the previous behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (2)
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
89-98: IncorrectcolSpanvalue.The table has 5 columns but
colSpan={6}is used for loading and empty states.- <TableCell colSpan={6} className="py-4 text-center"> + <TableCell colSpan={5} className="py-4 text-center">Apply to both lines 89 and 98.
apps/web/src/server/service/ses-hook-parser.ts (1)
397-428: Default case still maps unhandled statuses toemail.queued.This was flagged in a previous review.
EmailStatus.SCHEDULEDis not explicitly handled and would fall through to the default, emittingemail.queuedwhich is semantically incorrect.
🧹 Nitpick comments (17)
apps/web/src/server/api/routers/webhook.ts (1)
4-4: Prefer importing from the package root instead of deep path.The import uses a deep path
@usesend/lib/src/webhook/webhook-eventswhich bypasses the package's public API surface. This couples the consumer to the internal file structure.-import { WebhookEvents } from "@usesend/lib/src/webhook/webhook-events"; +import { WebhookEvents } from "@usesend/lib";Ensure
WebhookEventsis re-exported from the package's main entry point.apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx (2)
20-28: Add error state handling.The loading state check uses
!callwhich doesn't distinguish between loading, error, and initial states. If the query fails, users will see "Loading call details..." indefinitely.+ if (callQuery.isLoading) { + return ( + <Card className="h-full"> + <CardContent className="p-6 flex items-center justify-center h-full"> + <p className="text-muted-foreground text-sm">Loading call details...</p> + </CardContent> + </Card> + ); + } + + if (callQuery.isError || !call) { + return ( + <Card className="h-full"> + <CardContent className="p-6 flex items-center justify-center h-full"> + <p className="text-muted-foreground text-sm"> + {callQuery.isError ? "Failed to load call details" : "Call not found"} + </p> + </CardContent> + </Card> + ); + } - if (!call) { - return ( - <Card className="h-full"> - <CardContent className="p-6 flex items-center justify-center h-full"> - <p className="text-muted-foreground text-sm">Loading call details...</p> - </CardContent> - </Card> - ); - }
57-57: UseWebhookCallStatusenum for type safety.The status comparison uses a string literal instead of the Prisma enum, which could lead to silent failures if the enum value changes.
+import { WebhookCallStatus } from "@prisma/client"; // ... in the component - {call.status === "FAILED" && ( + {call.status === WebhookCallStatus.FAILED && (apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (2)
53-69: Consider usingWebhookStatusenum for type safety.Status comparisons use string literals throughout. Using the Prisma enum would provide compile-time safety.
+import { WebhookStatus } from "@prisma/client"; // ... in handleToggleStatus - const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE"; + const newStatus = currentStatus === WebhookStatus.ACTIVE + ? WebhookStatus.PAUSED + : WebhookStatus.ACTIVE;
199-220: Improve UX for AUTO_DISABLED webhooks.When a webhook is
AUTO_DISABLED, the button shows "Pause" (sinceisPausedis false) but is disabled. This is confusing. Consider showing "Resume" or adding a tooltip explaining why it's disabled.<Button variant="ghost" size="sm" className="justify-start rounded-lg hover:bg-accent" onClick={() => { onToggleStatus(); setOpen(false); }} disabled={isToggling || isAutoDisabled} + title={isAutoDisabled ? "Re-enable this webhook to resume" : undefined} > - {isPaused ? ( + {isPaused || isAutoDisabled ? ( <> <Play className="mr-2 h-4 w-4" /> Resume </> ) : (apps/web/src/server/service/domain-service.ts (2)
10-16: Prefer importing from the package root.Similar to the webhook router, the import uses a deep path to the internal file structure.
-import { - type DomainPayload, - type DomainWebhookEventType, -} from "@usesend/lib/src/webhook/webhook-events"; +import { + type DomainPayload, + type DomainWebhookEventType, +} from "@usesend/lib";
306-314: Consider fire-and-forget for webhook emission to reduce latency.The
await emitDomainEvent(...)delays the API response while the webhook is being queued. Since the helper already handles errors internally, you could remove theawaitto make it fire-and-forget, reducing user-perceived latency.if (previousStatus !== domainWithDns.status) { const eventType: DomainWebhookEventType = domainWithDns.status === DomainStatus.SUCCESS ? "domain.verified" : "domain.updated"; - await emitDomainEvent(domainWithDns, eventType); + void emitDomainEvent(domainWithDns, eventType); }Note: This trades guaranteed ordering for latency. If emission ordering is critical, keep the
await.apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (1)
128-134: Redundant condition inonOpenChangehandler.The check
nextOpen !== openon line 132 is unnecessary sinceonOpenChangeis already only called when the open state needs to change.<Dialog open={open} - onOpenChange={(nextOpen) => - nextOpen !== open ? onOpenChange(nextOpen) : null - } + onOpenChange={onOpenChange} >apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx (1)
39-45: Consider adding error state handling.The component handles loading and empty states but doesn't display anything when
callsQuery.isErroris true. Users won't see feedback if the API call fails.const calls = callsQuery.data?.items ?? []; + if (callsQuery.isError) { + return ( + <Card className="h-full flex flex-col"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 py-4"> + <CardTitle className="text-base font-medium">Delivery Logs</CardTitle> + </CardHeader> + <CardContent className="flex-1 flex items-center justify-center"> + <p className="text-destructive text-sm">Failed to load webhook calls</p> + </CardContent> + </Card> + ); + } + return (apps/web/src/server/service/ses-hook-parser.ts (2)
310-316: Type annotation foreventDatamay be too broad.The parameter type
SesEvent | SesEvent[SesEventDataKey]suggests the fullSesEventobject could be passed, but based on howbuildEmailWebhookPayloadis called (line 293), onlymailData(the extracted event data) is passed. Consider narrowing the type toSesEvent[SesEventDataKey]or using a union of the specific event data types.
443-456: Consider using proper type guards instead ofas anycasts.The
(mailData as any)?.complaint ?? mailDatapattern (lines 444, 451) bypasses type safety. If the SES event types don't capture all variants, consider extending the type definitions or using a type guard function.apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (3)
57-64: Event groups ordering is inconsistent with AddWebhook component.This component orders groups as Email → Domain → Contact, while
add-webhook.tsxorders them as Contact → Domain → Email. Consider aligning the order for a consistent user experience.Based on
add-webhook.tsxlines 57-64:const eventGroups: { label: string; events: readonly WebhookEventType[]; }[] = [ - { label: "Email events", events: EmailEvents }, - { label: "Domain events", events: DomainEvents }, { label: "Contact events", events: ContactEvents }, + { label: "Domain events", events: DomainEvents }, + { label: "Email events", events: EmailEvents }, ];
92-104: Consider removingformfrom useEffect dependencies.Including
formin the dependency array may cause unintended re-executions. The effect's purpose is to sync form state whenopenorwebhookchanges. Sinceform.resetis stable (from react-hook-form), you can safely omitformfrom the dependencies.useEffect(() => { if (open) { const hasAllEvents = (webhook.eventTypes as WebhookEventType[]).length === 0; form.reset({ url: webhook.url, eventTypes: hasAllEvents ? [] : (webhook.eventTypes as WebhookEventType[]), }); setAllEventsSelected(hasAllEvents); } - }, [open, webhook, form]); + }, [open, webhook]); // eslint-disable-line react-hooks/exhaustive-deps
161-307: Event selection UI duplicates AddWebhook logic.The event selection dropdown with group toggles (lines 161-306) is nearly identical to
add-webhook.tsx. Consider extracting this into a sharedWebhookEventsSelectcomponent to reduce duplication and ensure consistent behavior.apps/web/src/server/service/contact-service.ts (2)
24-62: Extra database query to determine event type could be optimized.The
findUniqueon lines 24-31 is used solely to determine if the contact exists (for settingcontact.createdvscontact.updated). Since Prisma'supsertdoesn't expose whether it created or updated, this is a reasonable approach. However, if this function is called frequently, consider using a transaction with conditional insert/update to reduce queries.
149-156: Prefer async/await over mixed.then()chains.The teamId resolution mixes
awaitwith.then(). Pure async/await would be more readable:const resolvedTeamId = teamId ?? - (await db.contactBook - .findUnique({ - where: { id: contact.contactBookId }, - select: { teamId: true }, - }) - .then((contactBook) => contactBook?.teamId)); + (await db.contactBook.findUnique({ + where: { id: contact.contactBookId }, + select: { teamId: true }, + }))?.teamId;packages/lib/src/webhook/webhook-events.ts (1)
151-196: Optionally deriveWebhookEventPayloadMapfromWebhookEventTypeto keep things in syncRight now
WebhookEventPayloadMapis a straight intersection of the three maps, which is fine. If you want a bit more future-proofing when adding new events, you could instead derive it fromWebhookEventTypeso the compiler forces you to supply a payload type for every event key.For example:
type BaseEventPayloadMaps = & EmailEventPayloadMap & DomainEventPayloadMap & ContactEventPayloadMap; export type WebhookEventPayloadMap = { [K in WebhookEventType]: BaseEventPayloadMaps[K]; };This keeps the ergonomics but guarantees that
WebhookEventTypeand the payload map cannot silently drift apart.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (42)
apps/web/package.json(1 hunks)apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql(1 hunks)apps/web/prisma/schema.prisma(3 hunks)apps/web/src/app/(dashboard)/dev-settings/layout.tsx(2 hunks)apps/web/src/app/(dashboard)/emails/email-details.tsx(2 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx(1 hunks)apps/web/src/components/AppSideBar.tsx(2 hunks)apps/web/src/lib/constants/plans.ts(3 hunks)apps/web/src/server/api/root.ts(2 hunks)apps/web/src/server/api/routers/contacts.ts(1 hunks)apps/web/src/server/api/routers/email.ts(2 hunks)apps/web/src/server/api/routers/limits.ts(2 hunks)apps/web/src/server/api/routers/webhook.ts(1 hunks)apps/web/src/server/jobs/webhook-cleanup-job.ts(1 hunks)apps/web/src/server/public-api/api/contacts/add-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/delete-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/update-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/upsert-contact.ts(1 hunks)apps/web/src/server/queue/queue-constants.ts(1 hunks)apps/web/src/server/service/contact-queue-service.ts(1 hunks)apps/web/src/server/service/contact-service.ts(4 hunks)apps/web/src/server/service/domain-service.ts(12 hunks)apps/web/src/server/service/limit-service.ts(1 hunks)apps/web/src/server/service/ses-hook-parser.ts(3 hunks)apps/web/src/server/service/webhook-service.ts(1 hunks)apps/web/tailwind.config.ts(1 hunks)packages/lib/.eslintrc.cjs(1 hunks)packages/lib/package.json(1 hunks)packages/lib/src/index.ts(1 hunks)packages/lib/src/webhook/webhook-events.ts(1 hunks)packages/lib/tsconfig.json(1 hunks)packages/lib/tsconfig.lint.json(1 hunks)packages/ui/src/dropdown-menu.tsx(7 hunks)
🚧 Files skipped from review as they are similar to previous changes (22)
- apps/web/src/components/AppSideBar.tsx
- packages/lib/tsconfig.lint.json
- apps/web/src/server/queue/queue-constants.ts
- apps/web/package.json
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx
- packages/lib/tsconfig.json
- apps/web/src/server/api/routers/limits.ts
- packages/lib/src/index.ts
- apps/web/src/lib/constants/plans.ts
- apps/web/src/server/jobs/webhook-cleanup-job.ts
- apps/web/src/app/(dashboard)/emails/email-details.tsx
- apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx
- apps/web/src/server/public-api/api/contacts/add-contact.ts
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx
- apps/web/src/server/public-api/api/contacts/delete-contact.ts
- packages/ui/src/dropdown-menu.tsx
- apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql
- apps/web/src/app/(dashboard)/dev-settings/layout.tsx
- apps/web/src/server/api/routers/contacts.ts
- packages/lib/package.json
- apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx
- apps/web/src/server/service/webhook-service.ts
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/server/api/routers/email.tsapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/api/root.tsapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/tailwind.config.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/server/service/contact-queue-service.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxpackages/lib/src/webhook/webhook-events.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/server/api/routers/email.tsapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/api/root.tsapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/tailwind.config.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/server/service/contact-queue-service.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxpackages/lib/src/webhook/webhook-events.ts
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/server/api/routers/email.tsapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/api/root.tsapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/tailwind.config.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/server/service/contact-queue-service.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsx
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/server/api/routers/email.tsapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/api/root.tsapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/tailwind.config.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/server/service/contact-queue-service.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsx
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/server/api/routers/email.tsapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/api/root.tsapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/tailwind.config.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/server/service/contact-queue-service.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxpackages/lib/src/webhook/webhook-events.ts
**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
React components must use PascalCase naming convention (e.g., AppSideBar.tsx)
Files:
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsx
🧠 Learnings (7)
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx} : Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Applied to files:
apps/web/src/server/api/routers/webhook.tsapps/web/src/server/api/root.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Applied to files:
apps/web/tailwind.config.tspackages/lib/.eslintrc.cjs
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx,js,jsx} : Use alias `~/` for src imports in apps/web (e.g., `import { x } from "~/utils/x"`)
Applied to files:
apps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Run ESLint via usesend/eslint-config and ensure no warnings remain before submitting PRs
Applied to files:
apps/web/tailwind.config.tspackages/lib/.eslintrc.cjs
📚 Learning: 2025-11-28T21:13:56.758Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: .cursor/rules/general.mdc:0-0
Timestamp: 2025-11-28T21:13:56.758Z
Learning: Applies to **/*.{tsx,ts,jsx,js} : Include all required imports and ensure proper naming of key components in React/NextJS code
Applied to files:
apps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,md} : Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Applied to files:
apps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Never use dynamic imports; always import on the top level
Applied to files:
apps/web/tailwind.config.ts
🧬 Code graph analysis (12)
apps/web/src/server/service/limit-service.ts (4)
apps/web/src/env.js (2)
env(5-141)env(5-141)apps/web/src/server/service/team-service.ts (1)
TeamService(18-568)apps/web/src/server/db.ts (1)
db(20-20)apps/web/src/lib/constants/plans.ts (1)
PLAN_LIMITS(13-40)
apps/web/src/server/public-api/api/contacts/update-contact.ts (1)
apps/web/src/server/service/contact-service.ts (1)
updateContact(65-80)
apps/web/src/app/(dashboard)/webhooks/page.tsx (2)
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (1)
AddWebhook(66-333)apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
WebhookList(28-161)
apps/web/src/server/api/routers/webhook.ts (3)
packages/lib/src/webhook/webhook-events.ts (1)
WebhookEvents(36-40)apps/web/src/server/api/trpc.ts (2)
createTRPCRouter(82-82)teamProcedure(127-152)apps/web/src/server/service/webhook-service.ts (1)
WebhookService(84-384)
apps/web/src/server/service/contact-service.ts (4)
apps/web/src/server/db.ts (1)
db(20-20)packages/lib/src/webhook/webhook-events.ts (2)
ContactWebhookEventType(7-7)ContactPayload(74-84)apps/web/src/server/logger/log.ts (1)
logger(31-63)apps/web/src/server/service/webhook-service.ts (1)
WebhookService(84-384)
apps/web/src/server/api/root.ts (1)
apps/web/src/server/api/routers/webhook.ts (1)
webhookRouter(9-135)
apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (2)
packages/lib/src/webhook/webhook-events.ts (5)
WebhookEvents(36-40)WebhookEventType(42-42)EmailEvents(18-32)DomainEvents(9-14)ContactEvents(1-5)apps/web/src/server/service/webhook-service.ts (1)
updateWebhook(256-293)
apps/web/src/server/service/domain-service.ts (2)
packages/lib/src/webhook/webhook-events.ts (2)
DomainPayload(86-100)DomainWebhookEventType(16-16)apps/web/src/server/service/webhook-service.ts (1)
WebhookService(84-384)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx (5)
packages/ui/src/card.tsx (4)
Card(74-74)CardHeader(75-75)CardTitle(77-77)CardContent(79-79)packages/ui/src/select.tsx (5)
Select(150-150)SelectTrigger(153-153)SelectValue(152-152)SelectContent(154-154)SelectItem(156-156)packages/ui/src/table.tsx (5)
Table(109-109)TableHeader(110-110)TableHead(113-113)TableBody(111-111)TableCell(115-115)packages/ui/src/spinner.tsx (1)
Spinner(4-51)apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx (1)
WebhookCallStatusBadge(3-41)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx (5)
packages/ui/src/card.tsx (4)
Card(74-74)CardContent(79-79)CardHeader(75-75)CardTitle(77-77)packages/ui/src/toaster.tsx (1)
toast(31-31)packages/ui/src/button.tsx (1)
Button(80-80)apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx (1)
WebhookCallStatusBadge(3-41)packages/ui/src/separator.tsx (1)
Separator(31-31)
apps/web/src/server/service/contact-queue-service.ts (1)
apps/web/src/server/service/contact-service.ts (1)
addOrUpdateContact(19-63)
apps/web/src/server/service/ses-hook-parser.ts (1)
packages/lib/src/webhook/webhook-events.ts (3)
EmailEventPayloadMap(151-165)EmailStatus(44-58)EmailBasePayload(60-72)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (26)
packages/lib/.eslintrc.cjs (1)
1-10: LGTM!The ESLint configuration is properly set up for the new
@usesend/libpackage, extending the shared library config and correctly pointing to the TypeScript lint configuration.apps/web/tailwind.config.ts (1)
10-10: LGTM!The content pattern for
@usesend/libfollows the same structure as other workspace packages, ensuring Tailwind correctly scans the library for utility classes.apps/web/src/server/api/routers/email.ts (2)
5-5: LGTM!Consolidating
BOUNCE_ERROR_MESSAGESinto the shared@usesend/libpackage improves code reuse and maintainability.
204-219: LGTM!The multi-line formatting improves readability without changing the logic.
apps/web/src/server/public-api/api/contacts/upsert-contact.ts (1)
55-59: LGTM!The additional
team.idparameter correctly aligns with the updatedaddOrUpdateContactsignature, enabling team-scoped webhook event emission.apps/web/src/server/public-api/api/contacts/update-contact.ts (1)
59-63: LGTM!The additional
team.idparameter correctly propagates team context to the contact service, consistent with the webhook integration changes.apps/web/src/server/service/contact-queue-service.ts (1)
100-108: LGTM!The queue job properly extracts and propagates
teamIdtoaddOrUpdateContact, ensuring webhook events have the correct team context.apps/web/src/app/(dashboard)/webhooks/page.tsx (1)
1-17: LGTM!The webhook dashboard page is cleanly structured, composing the
AddWebhookandWebhookListcomponents with proper Next.js conventions.apps/web/src/server/service/limit-service.ts (1)
104-132: LGTM!The
checkWebhookLimitmethod follows the established pattern of other limit checks in this service, correctly implementing cloud-mode gating, team-based plan limits, and proper return structure.apps/web/src/server/api/routers/webhook.ts (1)
9-135: Well-structured webhook router implementation.The router correctly uses
teamProcedurefor team-scoped access, applies appropriate Zod validation schemas, and delegates business logic toWebhookService. The endpoint organization follows TRPC conventions.apps/web/src/server/api/root.ts (1)
17-17: LGTM!The webhook router is correctly imported and wired into the app router following the established pattern.
Also applies to: 40-40
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx (1)
30-44: LGTM!The retry handler correctly invalidates both
listCallsandgetCallcaches and provides appropriate user feedback via toasts.apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
28-51: LGTM!Component setup with TRPC hooks, router integration, and mutation handlers follow established patterns. The
handleTestfunction correctly invalidates the calls list after enqueueing a test.apps/web/src/server/service/domain-service.ts (3)
90-106: LGTM!The
buildDomainPayloadhelper correctly maps all domain fields to the webhook payload type, properly converting dates to ISO strings.
387-396: LGTM!The
emitDomainEventhelper correctly wraps webhook emission with error handling, ensuring failures don't interrupt domain operations while still logging for observability.
228-233: LGTM!Webhook emission is correctly placed after the domain is persisted, ensuring the webhook payload reflects the actual database state.
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (2)
66-126: Component logic looks good.The form validation, limit checks, and event selection handling are well-implemented. The defensive double-check for limits (on dialog open and on submit) ensures robustness against race conditions where limits might change between opening the dialog and submitting.
167-316: Event selection logic is well-designed.The multi-select dropdown with group toggles and "all events" mode handles edge cases correctly. When deselecting an individual event while in "all events" mode, it properly transitions to explicit event selection with all-but-one events selected.
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx (1)
106-137: Table rows are accessible and well-structured.The clickable rows with visual feedback and proper state handling look good. The conditional rendering for optional fields (responseStatus, responseTimeMs) with fallback values is appropriate.
apps/web/src/server/service/ses-hook-parser.ts (1)
279-302: Webhook emission error handling is appropriately defensive.The try-catch ensures that webhook emission failures don't interrupt the core SES hook processing flow. Errors are logged with context (emailId, mailStatus) for debugging.
apps/web/prisma/schema.prisma (2)
486-506: Schema design for WebhookCall looks solid.The model appropriately captures:
- Retry mechanics (
attempt,nextAttemptAt)- Response diagnostics (
responseStatus,responseTimeMs,responseText)- Efficient indexing for status-filtered and chronological queries
One consideration:
responseTextcould potentially store large responses. You may want to truncate this in the service layer to prevent database bloat.
450-462: Webhook status enums are well-defined.The
AUTO_DISABLEDstatus for webhooks that fail repeatedly is a good reliability pattern. TheDISCARDEDstatus for calls provides visibility into dropped deliveries.apps/web/src/server/service/contact-service.ts (2)
143-176: Webhook emission is properly isolated from core operations.The error handling ensures that webhook failures don't disrupt contact CRUD operations. The fallback teamId resolution from contactBook is a good defensive pattern for cases where teamId isn't passed.
107-127: Existing subscribe/unsubscribe functions don't emit webhook events.The
unsubscribeContactandsubscribeContactfunctions (lines 107-127) don't emitcontact.updatedevents. If subscription status changes should trigger webhooks, consider adding webhook emission here as well.Is this intentional? If subscription changes are triggered by user actions (not automated bounces/complaints), they might warrant a
contact.updatedwebhook event for downstream systems.packages/lib/src/webhook/webhook-events.ts (2)
1-42: Event constants and unions look consistent and well-scoped
ContactEvents,DomainEvents,EmailEvents, and the combinedWebhookEventsplus their derived*WebhookEventTypeunions are clean, readable, and consistent. The string literals in the arrays match the intent and give you a solid runtime + type-level source of truth.
44-149: Payload shapes are coherent and aligned with event semanticsThe payload types (
EmailBasePayload,ContactPayload,DomainPayloadand the specialized email payloads) are internally consistent, use nullable/optional fields sensibly, and map well to the SES-style concepts (bounce, suppression, open, click, etc.). No correctness issues stand out here.
| model Webhook { | ||
| id String @id @default(cuid()) | ||
| teamId Int | ||
| url String | ||
| description String? | ||
| secret String | ||
| status WebhookStatus @default(ACTIVE) | ||
| eventTypes String[] | ||
| apiVersion String? | ||
| consecutiveFailures Int @default(0) | ||
| lastFailureAt DateTime? | ||
| lastSuccessAt DateTime? | ||
| createdByUserId Int? | ||
| createdAt DateTime @default(now()) | ||
| updatedAt DateTime @updatedAt | ||
| team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) | ||
| calls WebhookCall[] | ||
| createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull) | ||
| @@index([teamId]) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
webhook secret storage best practices database encryption
💡 Result:
Concise best-practice checklist for storing webhook secrets and encrypting them in a database:
- Never store secrets in plaintext; store only encrypted ciphertext. [1]
- Use envelope encryption (DEK encrypted by a KEK held in a managed KMS/HSM) rather than rolling your own key-wrapping. Generate DEKs locally, use AES-256-GCM, wrap DEKs with a centrally-managed KEK. [2]
- Prefer a secrets manager or KMS (HashiCorp Vault, AWS KMS/Secrets Manager, GCP KMS, HSM-backed keys) to hold KEKs and perform decrypt operations — do not hardcode keys in app code. [3][2]
- Minimize stored secrets: avoid persisting webhook secrets unless necessary; if you must, store only hashes or one-way verifier material for verification (e.g., HMAC) when possible. [1]
- Enforce least privilege: restrict who/what can call KMS decrypt (use service identities/roles), and restrict DB access to only required services. [3][4]
- Rotate secrets and keys regularly and immediately after suspected compromise; rotate DEKs per-write or per-period and rotate KEKs on a schedule. [2][3]
- Protect secrets in transit and in use: require TLS/mTLS for webhook delivery and for service-to-service calls; avoid logging secrets or returning them in error messages. [4][1]
- Protect secrets in host memory: limit lifetime in memory, use secure libraries, and consider using a transit/encrypt-only service so plaintext is not exposed to application storage. [2][6]
- Audit and monitor access: log KMS decrypt calls, DB access, and alert on anomalous access patterns. Maintain key and secret access policies and periodic reviews. [3][4]
- Secure CI/CD and backups: ensure secrets are not checked into repos or build logs, encrypt backups and snapshots, and ensure etcd/cluster state (e.g., Kubernetes secrets) is encrypted at rest. [5][4]
Sources (selected): OWASP Cryptographic Storage & Key Management cheat sheets; Google Cloud KMS envelope-encryption docs; AWS KMS/encryption best practices; OWASP CI/CD Secrets guidance. [1][2][3][4][5]
References:
[1] OWASP Cryptographic Storage Cheat Sheet. (cheatsheetseries.owasp.org)
[2] Google Cloud KMS — Envelope encryption guidance. (docs.cloud.google.com)
[3] AWS KMS / Encryption best practices. (docs.aws.amazon.com)
[4] OWASP Key Management & CI/CD Secrets guidance. (cheatsheetseries.owasp.org)
[5] Secrets-management guidance (examples & recommendations). (opsmoon.com)
If you want, I can produce a short implementation sketch (code + DB schema + key usage flow) for your tech stack (Postgres, MySQL, Mongo, etc.).
Citations:
- 1: https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html?utm_source=openai
- 2: https://docs.cloud.google.com/kms/docs/envelope-encryption?utm_source=openai
- 3: https://docs.aws.amazon.com/prescriptive-guidance/latest/aws-kms-best-practices/data-protection-encryption.html?utm_source=openai
- 4: https://cheatsheetseries.owasp.org/cheatsheets/Key_Management_Cheat_Sheet.html?utm_source=openai
- 5: https://opsmoon.com/blog/secrets-management-best-practices-3?utm_source=openai
🏁 Script executed:
# Check if the codebase has encryption utilities or existing patterns
find . -type f \( -name "*.ts" -o -name "*.js" -o -name "*.prisma" \) | head -20Repository: usesend/useSend
Length of output: 850
🏁 Script executed:
# Search for encryption-related code in the repository
rg -i "encrypt|crypto|secret" --type ts --type js -l | head -20Repository: usesend/useSend
Length of output: 916
🏁 Script executed:
# Check the crypto utility file
cat -n apps/web/src/server/crypto.tsRepository: usesend/useSend
Length of output: 775
🏁 Script executed:
# Check webhook service to see if secrets are encrypted
head -100 apps/web/src/server/service/webhook-service.tsRepository: usesend/useSend
Length of output: 2736
🏁 Script executed:
# Check webhook router for secret handling
head -150 apps/web/src/server/api/routers/webhook.tsRepository: usesend/useSend
Length of output: 3741
🏁 Script executed:
# Get the full webhook service file to see secret handling
wc -l apps/web/src/server/service/webhook-service.tsRepository: usesend/useSend
Length of output: 109
🏁 Script executed:
# View the createWebhook method specifically
sed -n '100,250p' apps/web/src/server/service/webhook-service.tsRepository: usesend/useSend
Length of output: 3608
🏁 Script executed:
# Search for secret usage in webhook service
rg "secret" apps/web/src/server/service/webhook-service.ts -A 3 -B 1Repository: usesend/useSend
Length of output: 1576
Encrypt webhook secrets before storage.
Webhook secrets are currently stored in plaintext in the database and used for HMAC signing. This violates OWASP and cloud security best practices. Implement application-layer encryption (with proper key management via KMS or a secrets manager like Vault) rather than hashing, since the secret must be decrypted for signing webhook payloads.
🤖 Prompt for AI Agents
In apps/web/prisma/schema.prisma around lines 464–484, the Webhook model
currently stores the secret in plaintext; replace that with fields for encrypted
storage and key metadata (e.g., encryptedSecret String (or Bytes/base64),
encryptionKeyId String, and optional encryptionIv String) and remove or
deprecate the plaintext secret field; update the Prisma schema and DB migration
accordingly, implement application-layer encryption/decryption using your KMS or
Vault (encrypt before creating/updating webhook, decrypt only in memory when
signing payloads), ensure key version metadata is stored (encryptionKeyId) to
support key rotation, never log plaintext secrets, and update any codepaths,
tests and access controls that read/write the secret to use the new encrypted
fields and KMS-driven operations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (1)
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
89-89: IncorrectcolSpanvalue.The table has 5 columns but
colSpan={6}is used. This should becolSpan={5}.Also applies to: 98-98
🧹 Nitpick comments (4)
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (4)
30-30: UnusedhandleTestfunction andtestWebhookmutation.The
testWebhookmutation (line 30) andhandleTestfunction (lines 38-51) are defined but never called anywhere in the component. This appears to be dead code or an incomplete implementation.Either wire this up to a UI action (e.g., a "Test" button in
WebhookActions) or remove the unused code.Also applies to: 38-51
140-141:isTogglingdisables all rows during any mutation.
setStatusMutation.isPendingis a global state—when toggling one webhook's status, the toggle button for every row is disabled. Consider tracking which specific webhook is being mutated to only disable that row's button.- const [editingId, setEditingId] = useState<string | null>(null); + const [editingId, setEditingId] = useState<string | null>(null); + const [togglingId, setTogglingId] = useState<string | null>(null); async function handleToggleStatus(webhookId: string, currentStatus: string) { const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE"; + setTogglingId(webhookId); setStatusMutation.mutate( { id: webhookId, status: newStatus }, { onSuccess: async () => { await utils.webhook.list.invalidate(); toast.success( `Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`, ); + setTogglingId(null); }, onError: (error) => { toast.error(error.message); + setTogglingId(null); }, }, ); }Then pass
isToggling={togglingId === webhook.id}toWebhookActions.Also applies to: 207-207
199-220: Consider showing "Resume" forAUTO_DISABLEDwebhooks.When a webhook is
AUTO_DISABLED, the toggle button shows "Pause" (sinceisPausedis false) but is disabled. Showing "Resume" with a tooltip explaining the auto-disabled state would be clearer UX.- const isPaused = webhook.status === "PAUSED"; - const isAutoDisabled = webhook.status === "AUTO_DISABLED"; + const isPausedOrDisabled = webhook.status === "PAUSED" || webhook.status === "AUTO_DISABLED"; + const isAutoDisabled = webhook.status === "AUTO_DISABLED"; ... - {isPaused ? ( + {isPausedOrDisabled ? (
228-250: LGTM with optional type improvement.The badge logic is correct. For better type safety, consider using the
WebhookStatusenum from Prisma instead ofstring:+import { WebhookStatus } from "@prisma/client"; ... -function WebhookStatusBadge({ status }: { status: string }) { +function WebhookStatusBadge({ status }: { status: WebhookStatus }) {
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
apps/marketing/next-env.d.ts(1 hunks)apps/web/next-env.d.ts(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx(1 hunks)docker/dev/compose.yml(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/next-env.d.tsapps/marketing/next-env.d.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/next-env.d.tsapps/marketing/next-env.d.ts
**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
React components must use PascalCase naming convention (e.g., AppSideBar.tsx)
Files:
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/next-env.d.ts
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/next-env.d.ts
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/next-env.d.tsapps/marketing/next-env.d.ts
🧠 Learnings (3)
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Applied to files:
apps/web/next-env.d.tsapps/marketing/next-env.d.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx,js,jsx} : Use alias `~/` for src imports in apps/web (e.g., `import { x } from "~/utils/x"`)
Applied to files:
apps/web/next-env.d.tsapps/marketing/next-env.d.ts
📚 Learning: 2025-11-28T21:13:56.758Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: .cursor/rules/general.mdc:0-0
Timestamp: 2025-11-28T21:13:56.758Z
Learning: Applies to **/*.{tsx,ts,jsx,js} : Include all required imports and ensure proper naming of key components in React/NextJS code
Applied to files:
apps/web/next-env.d.tsapps/marketing/next-env.d.ts
🧬 Code graph analysis (1)
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (4)
apps/web/src/server/service/webhook-service.ts (1)
testWebhook(162-194)packages/ui/src/spinner.tsx (1)
Spinner(4-51)apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (1)
EditWebhookDialog(66-323)apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
DeleteWebhook(11-61)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (3)
docker/dev/compose.yml (1)
28-28: The image registry update has been verified and is safe. Both the new imageusesend/local-ses-sns:latestand the old imageunsend/local-ses-sns:latestexist and are publicly available. No other references to the old image exist in the codebase, and the new image includes the expected webhook support for the SES/SNS emulation.apps/web/next-env.d.ts (1)
3-3: Typed routes reference looks good; confirm generation is enabledAdding
/// <reference path="./.next/types/routes.d.ts" />is consistent with enabling typed routes in Next 15 and will expose route typings globally. The change itself is fine, but it relies on Next generating that file under.next/types.Please double‑check that:
typedRoutes(or the relevant Next config flag) is enabled so.next/types/routes.d.tsis always generated in dev/build; and- your workflow doesn’t run a bare
tscbefore.nextexists, otherwise you may see “file not found” TS errors.If both are true, this addition is safe and desirable.
apps/marketing/next-env.d.ts (1)
3-3: Remove the typed routes reference from next-env.d.ts.The file references
./.next/types/routes.d.ts, but this file does not exist, and thetypedRoutesconfiguration flag is not enabled inapps/marketing/next.config.ts. Since the file explicitly warns against manual editing and this reference is incomplete/non-functional, remove line 3 to restore the auto-generated state.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (1)
apps/web/src/server/service/webhook-service.ts (1)
780-806: Flush TextDecoder after stream completion.When the stream ends, any remaining bytes in the decoder's internal buffer (partial multi-byte characters) may be discarded. Call
decoder.decode()with no arguments after the loop to flush remaining content.This issue was already flagged in a previous review. The fix is to add a final flush:
while (true) { const { done, value } = await reader.read(); if (done) { + // Flush any remaining bytes in the decoder + const remaining = decoder.decode(); + if (remaining && !truncated) { + chunks += remaining; + } break; } // ... }
🧹 Nitpick comments (3)
apps/web/src/server/service/webhook-service.ts (2)
21-21: Remove unused import.The
logimport from"console"is not used anywhere in this file.-import { log } from "console";
141-143: Consider usingUnsendApiErrorfor consistency.Other methods like
getWebhookanddeleteWebhookthrowUnsendApiErrorfor not-found cases, butretryCallandtestWebhookthrow genericError. This inconsistency may lead to different error handling behavior in API routes.if (!call) { - throw new Error("Webhook call not found"); + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Webhook call not found", + }); }if (!webhook) { - throw new Error("Webhook not found"); + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Webhook not found", + }); }Also applies to: 171-173
packages/sdk/src/webhooks.ts (1)
118-137: Consider optimizing double body conversion.
constructEventcallsverifyInternalwhich converts body viatoUtf8String, then converts it again on line 128. While functionally correct, this duplicates work for large payloads.A minor optimization would be to have
verifyInternalreturn the converted string:- private verifyInternal(body: RawBody, options: {...}): void { + private verifyInternal(body: RawBody, options: {...}): string { // ... existing validation ... const bodyString = toUtf8String(body); // ... signature check ... + return bodyString; } constructEvent(body: RawBody, options: {...}): WebhookEventData { - this.verifyInternal(body, options); - const bodyString = toUtf8String(body); + const bodyString = this.verifyInternal(body, options); // ... JSON.parse ... }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (6)
apps/web/src/server/service/webhook-service.ts(1 hunks)packages/sdk/README.md(1 hunks)packages/sdk/index.ts(1 hunks)packages/sdk/package.json(1 hunks)packages/sdk/src/usesend.ts(2 hunks)packages/sdk/src/webhooks.ts(1 hunks)
✅ Files skipped from review due to trivial changes (1)
- packages/sdk/README.md
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
packages/sdk/src/usesend.tspackages/sdk/index.tspackages/sdk/src/webhooks.tsapps/web/src/server/service/webhook-service.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
packages/sdk/src/usesend.tspackages/sdk/index.tspackages/sdk/src/webhooks.tsapps/web/src/server/service/webhook-service.ts
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
packages/sdk/src/usesend.tspackages/sdk/index.tspackages/sdk/src/webhooks.tsapps/web/src/server/service/webhook-service.ts
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/server/service/webhook-service.ts
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/server/service/webhook-service.ts
🧬 Code graph analysis (1)
apps/web/src/server/service/webhook-service.ts (7)
apps/web/src/server/queue/bullmq-context.ts (2)
TeamJob(5-5)createWorkerHandler(10-24)packages/lib/src/webhook/webhook-events.ts (2)
WebhookEventType(42-42)WebhookPayloadData(184-185)apps/web/src/server/queue/queue-constants.ts (2)
WEBHOOK_DISPATCH_QUEUE(6-6)DEFAULT_QUEUE_OPTIONS(9-14)apps/web/src/server/redis.ts (1)
getRedis(6-13)apps/web/src/server/db.ts (1)
db(20-20)apps/web/src/server/public-api/api-error.ts (1)
UnsendApiError(62-75)apps/web/src/server/service/limit-service.ts (1)
LimitService(19-280)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (12)
apps/web/src/server/service/webhook-service.ts (5)
43-83: LGTM! Queue service initialization is well-structured.The
WebhookQueueServicecorrectly initializes the BullMQ queue and worker with appropriate concurrency settings, error handling, and job options. Static initialization block ensures proper setup on module load.
86-134: LGTM! Event emission logic is well-designed.The
emitmethod correctly handles both specific event type subscriptions and catch-all webhooks (emptyeventTypesarray). Early return when no active webhooks prevents unnecessary work.
197-385: LGTM! CRUD operations are well-implemented.The webhook management methods properly enforce team ownership, handle limit checks via
LimitService, and implement cursor-based pagination correctly. The secret rotation logic inupdateWebhookis cleanly handled.
740-744: LGTM! HMAC signing is correctly implemented.The signature format
v1=${hmac.digest("hex")}usingtimestamp.bodyas the signed content matches the SDK's verification logic inpackages/sdk/src/webhooks.ts.
579-596: LGTM! Lock release uses correct atomic pattern.The Lua script ensures only the lock owner can release it, preventing accidental release of locks held by other workers. Error handling is appropriate for cleanup operations.
packages/sdk/package.json (1)
28-28: LGTM! Workspace dependency correctly added.The
@usesend/libworkspace dependency enables the SDK to import shared webhook event types, maintaining consistency between the server-side webhook service and SDK verification logic.packages/sdk/src/usesend.ts (1)
174-196: LGTM! Webhook integration follows idiomatic SDK patterns.The
webhooks(secret)method provides a clean, Stripe-inspired API. The JSDoc documentation with code examples is excellent for developer experience. Creating a newWebhooksinstance per call is appropriate since the secret may vary.packages/sdk/index.ts (1)
4-17: LGTM! Public API exports are well-organized.The separation of runtime exports (classes, constants) from type-only exports maintains a clean public API surface. All necessary utilities for webhook verification are exposed.
packages/sdk/src/webhooks.ts (4)
27-35: LGTM! Custom error class provides actionable error codes.The
WebhookVerificationErrorwith specific codes likeMISSING_SIGNATURE,TIMESTAMP_OUT_OF_RANGE, etc., enables consumers to handle different failure modes appropriately.
139-198: LGTM! Verification logic is secure and robust.The implementation correctly:
- Validates required headers with specific error codes
- Uses timing-safe comparison to prevent timing attacks
- Checks timestamp tolerance to prevent replay attacks
- Matches the server-side signature format (
v1=prefix,timestamp.bodyHMAC)
232-261: LGTM! Header extraction handles multiple formats.The
getHeaderfunction correctly handles:
- Web API
Headersobjects (Next.js App Router, browser)- Plain record objects (Express, Node.js)
- Case-insensitive matching
- Array-valued headers (returns first value)
263-272: LGTM! Timing-safe comparison correctly implemented.The length check before
timingSafeEqualis necessary because Node'stimingSafeEqualthrows on length mismatch. While the length check can leak length information, this is acceptable here since valid signatures always have a fixed known length (v1=+ 64 hex characters).
| const lockAcquired = await acquireLock(redis, lockKey, lockValue); | ||
| if (!lockAcquired) { | ||
| await db.webhookCall.update({ | ||
| where: { id: call.id }, | ||
| data: { | ||
| nextAttemptAt: new Date(Date.now() + WEBHOOK_LOCK_RETRY_DELAY_MS), | ||
| status: WebhookCallStatus.PENDING, | ||
| }, | ||
| }); | ||
| // Let BullMQ handle retry timing; this records observability. | ||
| throw new Error("Webhook lock not acquired"); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lock contention handling may cause excessive retries.
When the lock isn't acquired, the call is reset to PENDING and an error is thrown to trigger BullMQ retry. However, the nextAttemptAt is set but BullMQ controls retry timing via its own backoff, which may cause a mismatch between the recorded nextAttemptAt and actual retry time.
Consider either:
- Letting BullMQ handle retries without updating
nextAttemptAt, or - Using a custom delay via BullMQ's job options instead of throwing
if (!lockAcquired) {
- await db.webhookCall.update({
- where: { id: call.id },
- data: {
- nextAttemptAt: new Date(Date.now() + WEBHOOK_LOCK_RETRY_DELAY_MS),
- status: WebhookCallStatus.PENDING,
- },
- });
- // Let BullMQ handle retry timing; this records observability.
- throw new Error("Webhook lock not acquired");
+ // Re-enqueue with delay instead of throwing
+ await WebhookQueueService.enqueueCall(call.id, call.teamId);
+ return;
}Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In apps/web/src/server/service/webhook-service.ts around lines 449 to 460, when
lock acquisition fails the handler writes nextAttemptAt then throws to let
BullMQ retry, causing a mismatch between recorded nextAttemptAt and BullMQ's
backoff; remove the nextAttemptAt update and only set status to PENDING (or skip
the DB update entirely) so BullMQ controls retry timing consistently, then throw
the error as before — alternatively, if you want deterministic retry timing from
the DB, stop throwing and instead re-enqueue the job with a BullMQ delay using
job options rather than relying on exceptions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (3)
apps/web/src/components/code-display.tsx (1)
67-75: Consider using the existing CodeBlock component's approach.While the XSS risk is minimal here (Shiki generates trusted HTML), the codebase already has a
CodeBlockcomponent inpackages/ui/src/code-block.tsxthat usescodeToHast+toJsxRuntime, which avoidsdangerouslySetInnerHTMLentirely. This creates code duplication and inconsistency.Consider refactoring to match the CodeBlock pattern, or better yet, extend CodeBlock to support client-side usage if async/client-side highlighting is needed.
// Example approach similar to packages/ui/src/code-block.tsx const out = await codeToHast(code, { lang: language, themes: { dark: "catppuccin-mocha", light: "catppuccin-latte", }, }); return toJsxRuntime(out, { Fragment, jsx, jsxs, // ... custom components }) as React.JSX.Element;apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx (1)
1-36: Consider consolidating code display components.This component uses
CodeBlockfrom@usesend/ui, whileapps/web/src/components/code-display.tsxintroduces a new client-sideCodeDisplaycomponent. Both serve similar purposes but use different rendering approaches (server vs. client, toJsxRuntime vs. dangerouslySetInnerHTML).While there may be valid reasons for both (server vs. client contexts), consider:
- Documenting when to use which component
- Or refactoring to a unified approach that works in both contexts
This will reduce confusion and potential inconsistencies.
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx (1)
49-54: Minor: Redundant parse/stringify cycle.The payload is parsed to
parsedPayload(lines 49-54), then immediately stringified again for display (line 142). While this ensures consistent pretty-printing, you could simplify by passingcall.payloaddirectly toCodeDisplayand letting it handle formatting, or skip the intermediate parsing if the goal is just display.- let parsedPayload: unknown; - try { - parsedPayload = JSON.parse(call.payload); - } catch { - parsedPayload = call.payload; - } ... <CodeDisplay - code={JSON.stringify(parsedPayload, null, 2)} + code={call.payload} language="json" />Note: Only apply this if
call.payloadis already properly formatted. The current approach guarantees consistent formatting.Also applies to: 141-144
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (4)
apps/web/package.json(2 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx(1 hunks)apps/web/src/components/code-display.tsx(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/package.json
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/components/code-display.tsx
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/components/code-display.tsx
**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
React components must use PascalCase naming convention (e.g., AppSideBar.tsx)
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/components/code-display.tsx
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/components/code-display.tsx
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/components/code-display.tsx
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/components/code-display.tsx
🧠 Learnings (1)
📚 Learning: 2025-11-28T21:13:56.758Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: .cursor/rules/general.mdc:0-0
Timestamp: 2025-11-28T21:13:56.758Z
Learning: Applies to **/*.{tsx,ts,jsx,js} : Include all required imports and ensure proper naming of key components in React/NextJS code
Applied to files:
apps/web/src/components/code-display.tsx
🧬 Code graph analysis (2)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx (1)
packages/ui/src/code-block.tsx (1)
CodeBlock(13-36)
apps/web/src/components/code-display.tsx (1)
packages/email-editor/src/renderer.tsx (1)
code(740-756)
🪛 ast-grep (0.40.0)
apps/web/src/components/code-display.tsx
[warning] 71-71: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.1.2)
apps/web/src/components/code-display.tsx
[error] 72-72: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (5)
apps/web/src/components/code-display.tsx (2)
1-11: LGTM: Well-defined interface and imports.The component is properly marked as a client component, and the TypeScript interface is clean with sensible defaults.
22-52: LGTM: Proper async handling with cleanup.The isMounted pattern correctly prevents state updates after unmount, and error handling ensures the loading state is updated in all paths.
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx (1)
9-25: LGTM: Proper JSON handling.The try-catch pattern for JSON parsing with fallback to the original payload is appropriate. Pretty-printing with
JSON.stringify(parsed, null, 2)provides good readability.apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx (2)
14-47: LGTM: Proper TRPC usage with cache invalidation.The retry handler correctly invalidates both the list and detail queries, and uses toast notifications for user feedback. The async cache invalidation before showing the success toast is a nice touch.
56-158: LGTM: Well-structured UI with proper conditional rendering.The component layout is clean, with appropriate conditional rendering for optional fields and good use of semantic HTML. The retry button visibility logic (only for FAILED status) aligns well with the expected UX.
b7e6d9c to
15b26e3
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 8
♻️ Duplicate comments (7)
apps/web/prisma/schema.prisma (1)
464-484: Webhook secret storage security concern (previously raised).The plaintext secret storage was flagged in a previous review. While the schema structure, relations, and indexes are well-designed, consider implementing encryption for the
secretfield in a follow-up task for production deployments.apps/web/src/server/jobs/webhook-cleanup-job.ts (1)
36-47: The job file exists but is never loaded – import it in instrumentation.ts.This issue was previously flagged. The webhook cleanup job won't run because it's never imported. Add the import to
apps/web/src/instrumentation.tsalongside the existing job imports:await import("~/server/jobs/webhook-cleanup-job");apps/web/src/server/api/routers/contacts.ts (1)
141-161: Still missing “contact belongs to contactBook” enforcement (auth bypass).These mutations use
contactBookProcedurebut don’t usectx.contactBook.idwhen updating/deleting, so they don’t enforce book ownership.- updateContact: contactBookProcedure + updateContact: contactBookProcedure .input(/* ... */) - .mutation(async ({ ctx: { team }, input }) => { + .mutation(async ({ ctx: { team, contactBook }, input }) => { const { contactId, ...contact } = input; - return contactService.updateContact(contactId, contact, team.id); + return contactService.updateContact(contactId, contact, team.id, contactBook.id); }), deleteContact: contactBookProcedure .input(z.object({ contactId: z.string() })) - .mutation(async ({ ctx: { team }, input }) => { - return contactService.deleteContact(input.contactId, team.id); + .mutation(async ({ ctx: { team, contactBook }, input }) => { + return contactService.deleteContact(input.contactId, team.id, contactBook.id); }),Service must also verify the contact’s
contactBookId(and team) before mutating/deleting.apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx (1)
18-26: 7‑day activity stats can be wrong due tolimit: 50+ client-side filtering.If a webhook has >50 calls in the last 7 days, counts will be underreported. Prefer a server-side date filter/aggregate, or increase the limit.
apps/web/src/server/service/ses-hook-parser.ts (1)
397-428: Avoid mapping unhandled statuses toemail.queued(e.g., SCHEDULED).At minimum, add an explicit
EmailStatus.SCHEDULEDcase (or log+throw/log+fallback) so unexpected statuses don’t silently becomeemail.queued.apps/web/src/server/service/webhook-service.ts (2)
512-524: StaleconsecutiveFailuresvalue in auto-disable check.The auto-disable threshold check on line 520 uses
call.webhook.consecutiveFailures + 1, butconsecutiveFailuresis incremented atomically in the same update. Under concurrent failures, the local value read earlier may be stale, potentially delaying auto-disable beyond the intended threshold.Use the updated value returned from the database to check the threshold:
const updatedWebhook = await db.webhook.update({ where: { id: call.webhookId }, data: { consecutiveFailures: { increment: 1, }, lastFailureAt: new Date(), - status: - call.webhook.consecutiveFailures + 1 >= WEBHOOK_AUTO_DISABLE_THRESHOLD - ? WebhookStatus.AUTO_DISABLED - : call.webhook.status, }, }); + + // Check threshold using the updated value + if (updatedWebhook.consecutiveFailures >= WEBHOOK_AUTO_DISABLE_THRESHOLD) { + await db.webhook.update({ + where: { id: call.webhookId }, + data: { status: WebhookStatus.AUTO_DISABLED }, + }); + updatedWebhook.status = WebhookStatus.AUTO_DISABLED; + }
780-805: Flush the TextDecoder after the stream ends.When using
decoder.decode(value, { stream: true }), the decoder may buffer incomplete multi-byte UTF-8 sequences. After the loop completes (whendoneis true), calldecoder.decode()without arguments to flush any remaining buffered bytes.while (true) { const { done, value } = await reader.read(); if (done) { + // Flush any remaining buffered bytes + const remaining = decoder.decode(); + if (remaining && !truncated) { + chunks += remaining; + } break; } // ... rest of loop }
🧹 Nitpick comments (4)
apps/web/src/app/(dashboard)/dev-settings/layout.tsx (1)
8-12: Consider renaming function for clarity.The function is named
ApiKeysPagebut serves as a layout for multiple Developer Settings pages (API Keys, Webhooks, SMTP). Consider renaming it toDevSettingsLayoutor similar to better reflect its purpose.-export default function ApiKeysPage({ +export default function DevSettingsLayout({ children, }: { children: React.ReactNode;apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (2)
92-104: Removeformfrom useEffect dependencies.The
formobject returned byuseFormis a stable reference and doesn't need to be in the dependency array. This could cause unnecessary effect re-runs.Apply this diff:
useEffect(() => { if (open) { const hasAllEvents = (webhook.eventTypes as WebhookEventType[]).length === 0; form.reset({ url: webhook.url, eventTypes: hasAllEvents ? [] : (webhook.eventTypes as WebhookEventType[]), }); setAllEventsSelected(hasAllEvents); } - }, [open, webhook, form]); + }, [open, webhook]);
106-131: Consider consolidating validation logic.The component uses both zod validation and manual validation (lines 109-112). This dual approach can be confusing. Consider either:
- Using a custom zod refinement that accounts for
allEventsSelected, or- Disabling zod validation for
eventTypesand handling it entirely inhandleSubmitThis would make the validation flow clearer and prevent confusion about when/where validation occurs.
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (1)
128-134: SimplifyonOpenChange: avoid returningnullfrom an event handler.- <Dialog - open={open} - onOpenChange={(nextOpen) => - nextOpen !== open ? onOpenChange(nextOpen) : null - } - > + <Dialog open={open} onOpenChange={onOpenChange}>
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (52)
apps/marketing/next-env.d.ts(1 hunks)apps/web/next-env.d.ts(1 hunks)apps/web/package.json(2 hunks)apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql(1 hunks)apps/web/prisma/schema.prisma(3 hunks)apps/web/src/app/(dashboard)/dev-settings/layout.tsx(2 hunks)apps/web/src/app/(dashboard)/emails/email-details.tsx(2 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx(1 hunks)apps/web/src/components/AppSideBar.tsx(2 hunks)apps/web/src/components/code-display.tsx(1 hunks)apps/web/src/lib/constants/plans.ts(3 hunks)apps/web/src/server/api/root.ts(2 hunks)apps/web/src/server/api/routers/contacts.ts(1 hunks)apps/web/src/server/api/routers/email.ts(2 hunks)apps/web/src/server/api/routers/limits.ts(2 hunks)apps/web/src/server/api/routers/webhook.ts(1 hunks)apps/web/src/server/jobs/webhook-cleanup-job.ts(1 hunks)apps/web/src/server/public-api/api/contacts/add-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/delete-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/update-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/upsert-contact.ts(1 hunks)apps/web/src/server/queue/queue-constants.ts(1 hunks)apps/web/src/server/service/contact-queue-service.ts(1 hunks)apps/web/src/server/service/contact-service.ts(4 hunks)apps/web/src/server/service/domain-service.ts(12 hunks)apps/web/src/server/service/limit-service.ts(1 hunks)apps/web/src/server/service/ses-hook-parser.ts(3 hunks)apps/web/src/server/service/webhook-service.ts(1 hunks)apps/web/tailwind.config.ts(1 hunks)docker/dev/compose.yml(1 hunks)packages/lib/.eslintrc.cjs(1 hunks)packages/lib/package.json(1 hunks)packages/lib/src/index.ts(1 hunks)packages/lib/src/webhook/webhook-events.ts(1 hunks)packages/lib/tsconfig.json(1 hunks)packages/lib/tsconfig.lint.json(1 hunks)packages/sdk/README.md(1 hunks)packages/sdk/index.ts(1 hunks)packages/sdk/package.json(1 hunks)packages/sdk/src/usesend.ts(2 hunks)packages/sdk/src/webhooks.ts(1 hunks)packages/ui/src/dropdown-menu.tsx(7 hunks)
🚧 Files skipped from review as they are similar to previous changes (26)
- apps/web/src/components/AppSideBar.tsx
- packages/sdk/src/usesend.ts
- apps/web/src/server/queue/queue-constants.ts
- packages/lib/tsconfig.lint.json
- apps/web/src/app/(dashboard)/emails/email-details.tsx
- packages/lib/package.json
- packages/lib/src/index.ts
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx
- apps/web/src/server/api/root.ts
- apps/web/src/server/api/routers/webhook.ts
- packages/lib/.eslintrc.cjs
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx
- apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx
- apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx
- apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx
- apps/web/src/server/public-api/api/contacts/delete-contact.ts
- packages/sdk/index.ts
- packages/sdk/README.md
- packages/sdk/package.json
- packages/lib/tsconfig.json
- apps/web/package.json
- apps/web/src/server/service/contact-queue-service.ts
- packages/ui/src/dropdown-menu.tsx
- apps/web/src/server/public-api/api/contacts/add-contact.ts
- docker/dev/compose.yml
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/contact-service.tsapps/web/src/server/api/routers/limits.tspackages/sdk/src/webhooks.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/contacts.tsapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/server/api/routers/email.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/webhook-service.tsapps/marketing/next-env.d.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/tailwind.config.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/next-env.d.tsapps/web/src/lib/constants/plans.tspackages/lib/src/webhook/webhook-events.tsapps/web/src/components/code-display.tsx
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/contact-service.tsapps/web/src/server/api/routers/limits.tspackages/sdk/src/webhooks.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/contacts.tsapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/server/api/routers/email.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/webhook-service.tsapps/marketing/next-env.d.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/tailwind.config.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/next-env.d.tsapps/web/src/lib/constants/plans.tspackages/lib/src/webhook/webhook-events.tsapps/web/src/components/code-display.tsx
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/contact-service.tsapps/web/src/server/api/routers/limits.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/contacts.tsapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/server/api/routers/email.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/webhook-service.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/tailwind.config.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/next-env.d.tsapps/web/src/lib/constants/plans.tsapps/web/src/components/code-display.tsx
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/contact-service.tsapps/web/src/server/api/routers/limits.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/contacts.tsapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/server/api/routers/email.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/webhook-service.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/tailwind.config.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/next-env.d.tsapps/web/src/lib/constants/plans.tsapps/web/src/components/code-display.tsx
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/server/service/limit-service.tsapps/web/src/server/jobs/webhook-cleanup-job.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/contact-service.tsapps/web/src/server/api/routers/limits.tspackages/sdk/src/webhooks.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/contacts.tsapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/server/api/routers/email.tsapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/service/webhook-service.tsapps/marketing/next-env.d.tsapps/web/src/server/service/domain-service.tsapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/tailwind.config.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/next-env.d.tsapps/web/src/lib/constants/plans.tspackages/lib/src/webhook/webhook-events.tsapps/web/src/components/code-display.tsx
**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
React components must use PascalCase naming convention (e.g., AppSideBar.tsx)
Files:
apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/components/code-display.tsx
🧠 Learnings (6)
📚 Learning: 2025-11-28T21:13:56.758Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: .cursor/rules/general.mdc:0-0
Timestamp: 2025-11-28T21:13:56.758Z
Learning: Applies to **/*.{tsx,ts,jsx,js} : Include all required imports and ensure proper naming of key components in React/NextJS code
Applied to files:
apps/marketing/next-env.d.tsapps/web/tailwind.config.tsapps/web/next-env.d.tsapps/web/src/components/code-display.tsx
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Applied to files:
apps/marketing/next-env.d.tsapps/web/tailwind.config.tsapps/web/next-env.d.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx,js,jsx} : Use alias `~/` for src imports in apps/web (e.g., `import { x } from "~/utils/x"`)
Applied to files:
apps/web/tailwind.config.tsapps/web/next-env.d.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Run ESLint via usesend/eslint-config and ensure no warnings remain before submitting PRs
Applied to files:
apps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,md} : Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Applied to files:
apps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Never use dynamic imports; always import on the top level
Applied to files:
apps/web/tailwind.config.ts
🧬 Code graph analysis (13)
apps/web/src/server/service/limit-service.ts (3)
apps/web/src/env.js (2)
env(5-141)env(5-141)apps/web/src/server/service/team-service.ts (1)
TeamService(18-568)apps/web/src/lib/constants/plans.ts (1)
PLAN_LIMITS(13-40)
apps/web/src/server/jobs/webhook-cleanup-job.ts (4)
apps/web/src/server/queue/queue-constants.ts (2)
WEBHOOK_CLEANUP_QUEUE(7-7)DEFAULT_QUEUE_OPTIONS(9-14)apps/web/src/server/redis.ts (1)
getRedis(6-13)apps/web/src/server/db.ts (1)
db(20-20)apps/web/src/server/logger/log.ts (1)
logger(31-63)
apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (1)
apps/web/src/server/service/webhook-service.ts (1)
updateWebhook(257-294)
apps/web/src/server/service/contact-service.ts (4)
apps/web/src/server/db.ts (1)
db(20-20)packages/lib/src/webhook/webhook-events.ts (2)
ContactWebhookEventType(7-7)ContactPayload(74-84)apps/web/src/server/logger/log.ts (1)
logger(31-63)apps/web/src/server/service/webhook-service.ts (1)
WebhookService(85-385)
apps/web/src/server/api/routers/limits.ts (1)
apps/web/src/server/service/limit-service.ts (1)
LimitService(19-280)
apps/web/src/app/(dashboard)/webhooks/page.tsx (2)
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (1)
AddWebhook(66-333)apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
WebhookList(28-161)
apps/web/src/server/api/routers/contacts.ts (1)
apps/web/src/server/api/trpc.ts (1)
contactBookProcedure(204-222)
apps/web/src/app/(dashboard)/dev-settings/layout.tsx (1)
apps/web/src/app/(dashboard)/dev-settings/settings-nav-button.tsx (1)
SettingsNavButton(7-39)
apps/web/src/server/service/ses-hook-parser.ts (3)
apps/web/src/server/service/webhook-service.ts (1)
WebhookService(85-385)packages/lib/src/webhook/webhook-events.ts (3)
EmailEventPayloadMap(151-165)EmailStatus(44-58)EmailBasePayload(60-72)apps/web/src/types/aws-types.ts (4)
SesEvent(143-156)SesEventDataKey(132-141)SesBounce(30-50)SesClick(91-97)
apps/web/src/server/service/domain-service.ts (3)
packages/lib/src/webhook/webhook-events.ts (2)
DomainPayload(86-100)DomainWebhookEventType(16-16)apps/web/src/server/db.ts (1)
db(20-20)apps/web/src/server/service/webhook-service.ts (1)
WebhookService(85-385)
apps/web/src/server/public-api/api/contacts/update-contact.ts (1)
apps/web/src/server/service/contact-service.ts (1)
updateContact(65-80)
packages/lib/src/webhook/webhook-events.ts (2)
packages/sdk/index.ts (4)
WebhookEventType(16-16)WebhookEventPayloadMap(15-15)WebhookEvent(13-13)WebhookEventData(14-14)packages/sdk/src/webhooks.ts (4)
WebhookEventType(278-278)WebhookEventPayloadMap(277-277)WebhookEvent(275-275)WebhookEventData(276-276)
apps/web/src/components/code-display.tsx (1)
packages/email-editor/src/renderer.tsx (1)
code(740-756)
🪛 ast-grep (0.40.0)
apps/web/src/components/code-display.tsx
[warning] 71-71: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.1.2)
apps/web/src/components/code-display.tsx
[error] 72-72: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (36)
apps/web/next-env.d.ts (1)
3-3: LGTM! Typed routes enabled.This reference is auto-generated by Next.js 15 to enable type-safe routing. It provides TypeScript definitions for all routes in your application, which aligns well with the PR's goal of adding strong typing for the webhook system.
apps/web/tailwind.config.ts (1)
10-10: Remove@usesend/libfrom the Tailwind content configuration.The package contains only utility functions (
invariant,assertUnreachable,isDefined) and constants (SES error messages). It has no Tailwind classes or UI components. Including it in the content glob adds unnecessary scanning overhead without benefit.Likely an incorrect or invalid review comment.
apps/web/src/app/(dashboard)/dev-settings/layout.tsx (3)
3-3: LGTM!The import of the H1 component from the UI library is correct and necessary for the themed heading on line 15.
15-15: Good improvement for consistency.Replacing the plain HTML heading with the themed H1 component from the UI library ensures consistent styling across the application.
18-20: LGTM!The new Webhooks navigation item is correctly implemented following the existing SettingsNavButton pattern. The placement between API Keys and SMTP is logical and the href path aligns with the webhook feature introduced in this PR.
apps/marketing/next-env.d.ts (1)
3-3: This is a standard auto-generated Next.js file—accept as-is.The triple-slash reference to
.next/types/routes.d.tsis automatically generated by Next.js and included innext-env.d.tsby default. The file header explicitly states "This file should not be edited," and yourtsconfig.jsonalready includes.next/types/**/*.tsin compilation. No configuration changes are needed.apps/web/src/server/api/routers/email.ts (2)
204-209: LGTM! Explicit return shape improves consistency.The explicit return of undefined bounce fields for non-bounced emails makes the return shape consistent with the bounced case at line 221, improving predictability for consumers.
5-5: The import change from local constants to @usesend/lib is correct.The centralized package properly exports BOUNCE_ERROR_MESSAGES with the required nested structure (Permanent, Transient, Undetermined), and all usage patterns at lines 59, 73, and 76 are compatible with the exported structure. The package dependency is configured correctly in the workspace.
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx (2)
49-54: JSON parsing fallback is well-handled.The try/catch pattern appropriately falls back to the raw payload string when JSON parsing fails, which is a good defensive approach for handling various payload formats.
33-47: Retry implementation is well-structured.The retry handler correctly invalidates both
listCallsandgetCallqueries withawait, ensures proper cache updates, and provides clear user feedback through toasts. Error handling appropriately displays the error message.apps/web/src/components/code-display.tsx (1)
67-75: Safe usage of dangerouslySetInnerHTML with Shiki.Static analysis correctly flags
dangerouslySetInnerHTML, but this is safe here. Shiki'scodeToHtmlgenerates pre-escaped, syntax-highlighted HTML containing only styled spans and text nodes—no executable scripts. This is the standard pattern for using Shiki in React.apps/web/prisma/schema.prisma (1)
450-506: LGTM – Webhook schema is well-structured.The webhook subsystem schema is well-designed:
- Appropriate enums for status tracking
- Good use of indexes on query patterns (teamId, status, createdAt)
- Proper cascade delete behavior
- Comprehensive fields for retry logic and observability
apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (1)
1-322: LGTM – Complex event selection UI is well-implemented.The webhook editing dialog handles complex multi-select logic (all events, per-group, per-event) correctly. The state management and UI updates are appropriate for the requirements.
apps/web/src/app/(dashboard)/webhooks/page.tsx (1)
1-17: LGTM – Clean page composition.The webhooks page follows good composition patterns and properly delegates to child components.
apps/web/src/lib/constants/plans.ts (1)
7-7: LGTM – Webhook limits follow existing patterns.The webhook limit configuration is consistent with other resource limits in the codebase. The values (1 for FREE, unlimited for BASIC) are reasonable for this feature.
Also applies to: 21-21, 30-30, 38-38
apps/web/src/server/api/routers/limits.ts (1)
21-22: LGTM – Webhook limit check follows existing patterns.The webhook limit case is properly integrated into the limits router, consistent with other limit types.
apps/web/src/server/public-api/api/contacts/upsert-contact.ts (1)
55-59: LGTM – Team context properly passed to service layer.The addition of
team.idas the third parameter aligns with the webhook integration requirements and ensures proper team scoping for webhook events.apps/web/src/server/public-api/api/contacts/update-contact.ts (1)
59-63: LGTM – Team context properly threaded through.The
team.idparameter addition is consistent with the webhook integration pattern applied across contact APIs.apps/web/src/server/service/limit-service.ts (1)
104-132: LimitService.checkWebhookLimit matches existing limit-check pattern.apps/web/src/server/service/domain-service.ts (1)
90-106: Domain webhook emission helpers look sound (payload mapping + non-blocking emit).Also applies to: 387-396
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (1)
44-53: This concern is not valid.WebhookEventsis properly defined as a readonly const array (as const), which is the standard pattern for Zod'sz.enum(). No tuple casting or modification is needed—z.enum(WebhookEvents)works correctly as written.Likely an incorrect or invalid review comment.
packages/sdk/src/webhooks.ts (1)
37-205: No action needed. The SDK webhook verification implementation correctly matches the server-side signing. Timestamp is in milliseconds since epoch, signature format isv1=+ HMAC-SHA256(secret,${timestamp}.${body}), and all headers are properly named. Implementation is verified as correct.apps/web/src/server/service/webhook-service.ts (8)
43-83: WebhookQueueService implementation looks solid.The queue and worker setup with BullMQ is well-structured:
- Proper concurrency control with
WEBHOOK_DISPATCH_CONCURRENCY- Exponential backoff configuration for retries
- Error logging on worker errors
- Static initialization block for startup logging
86-134:emitmethod is well-designed.The implementation correctly:
- Filters for active webhooks matching the event type or with empty eventTypes (catch-all)
- Creates PENDING webhook call records before enqueueing
- Uses early return for no matching webhooks
136-161:retryCallproperly resets all retry-related fields.Good that it clears
attempt,nextAttemptAt,lastError,responseStatus,responseTimeMs, andresponseTextto ensure a clean retry state.
197-199: Secret generation uses secure randomness.Using
randomBytes(32)provides 256 bits of entropy, which is cryptographically secure for HMAC signing secrets.
277-281: Clarify secret update precedence.When
rotateSecretisfalsebutparams.secretis provided, the new secret is used. However, if bothrotateSecretistrueandparams.secretis provided,rotateSecrettakes precedence. This behavior may be intentional but could be surprising.Consider documenting this precedence or explicitly handling the conflict:
const secret = params.rotateSecret === true ? WebhookService.generateSecret() : params.secret; // undefined means keep existing
740-744: HMAC signing implementation is correct.The
signBodyfunction properly constructs the signature using${timestamp}.${body}format and outputs a versioned signaturev1=..., which allows for future algorithm upgrades.
662-738:postWebhookproperly handles timeouts and errors.Good implementation:
- Uses
AbortControllerfor request timeout- Clears timeout in
finallyblock- Distinguishes between HTTP errors, timeout errors, and other fetch errors
- Captures response metadata for debugging
598-602: Backoff calculation is sound.The exponential backoff with 30% jitter helps prevent thundering herd problems when multiple webhooks fail simultaneously.
packages/lib/src/webhook/webhook-events.ts (6)
1-42: Well-structured event type definitions.The pattern of defining event arrays as
constand deriving types from them is excellent — it provides both runtime access (for validation) and compile-time type safety. The combinedWebhookEventsarray properly aggregates all categories.
60-72:EmailBasePayloadstructure is comprehensive.The payload includes all essential fields for email tracking. Good use of optional fields with
| nullfor values that may be absent.
74-84:ContactPayloadproperly exposes contact data.Note that
151-165: Email event payload mapping is complete.All email events from
EmailEventsare properly mapped to their respective payload types. The specialized payloads (EmailBouncedPayload,EmailFailedPayload, etc.) correctly extendEmailBasePayload.
180-196: Type utility design enables type-safe event handling.The
WebhookPayloadData<TType>,WebhookEvent<TType>, andWebhookEventDatatypes work together to provide:
- Compile-time payload inference based on event type
- A discriminated union for runtime type narrowing
This pattern aligns well with the SDK exports noted in the relevant code snippets.
102-116: Add missing"UnsubscribedRecipient"subtype for permanent bounces.The
EmailBouncedPayload.bounce.subTypeunion is incomplete. According to AWS SES documentation, permanent bounces include"UnsubscribedRecipient"as a subtype, which is not currently listed.
| if (!call) { | ||
| return ( | ||
| <Card className="h-full"> | ||
| <CardContent className="p-6 flex items-center justify-center h-full"> | ||
| <p className="text-muted-foreground text-sm"> | ||
| Loading call details... | ||
| </p> | ||
| </CardContent> | ||
| </Card> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle query error state.
The component only checks if call is falsy but doesn't distinguish between loading and error states. If callQuery fails, users will see "Loading call details..." indefinitely.
Add error handling:
const call = callQuery.data;
- if (!call) {
+ if (callQuery.isLoading) {
return (
<Card className="h-full">
<CardContent className="p-6 flex items-center justify-center h-full">
<p className="text-muted-foreground text-sm">
Loading call details...
</p>
</CardContent>
</Card>
);
}
+
+ if (callQuery.isError) {
+ return (
+ <Card className="h-full">
+ <CardContent className="p-6 flex items-center justify-center h-full">
+ <p className="text-red-500 text-sm">
+ Failed to load call details: {callQuery.error.message}
+ </p>
+ </CardContent>
+ </Card>
+ );
+ }
+
+ if (!call) {
+ return null;
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (!call) { | |
| return ( | |
| <Card className="h-full"> | |
| <CardContent className="p-6 flex items-center justify-center h-full"> | |
| <p className="text-muted-foreground text-sm"> | |
| Loading call details... | |
| </p> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |
| if (callQuery.isLoading) { | |
| return ( | |
| <Card className="h-full"> | |
| <CardContent className="p-6 flex items-center justify-center h-full"> | |
| <p className="text-muted-foreground text-sm"> | |
| Loading call details... | |
| </p> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |
| if (callQuery.isError) { | |
| return ( | |
| <Card className="h-full"> | |
| <CardContent className="p-6 flex items-center justify-center h-full"> | |
| <p className="text-red-500 text-sm"> | |
| Failed to load call details: {callQuery.error.message} | |
| </p> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |
| if (!call) { | |
| return null; | |
| } |
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx
around lines 21 to 31, the component returns a loading UI whenever `call` is
falsy and never handles a failed `callQuery`; update the render logic to
distinguish loading, error, and empty states by checking `callQuery.isLoading`
to show the loading UI, `callQuery.isError` (or `callQuery.error`) to render a
user-facing error message (and optionally a retry button that calls
`callQuery.refetch`), and only render the current UI when `call` exists; ensure
the error UI provides clear text and a way to recover.
| <Card className="h-full flex flex-col overflow-hidden"> | ||
| <CardHeader className="flex flex-row items-center justify-between space-y-0 py-4 border-b bg-muted/20"> | ||
| <CardTitle className="text-base font-medium">Call Details</CardTitle> | ||
| {call.status === "FAILED" && ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Use enum constant instead of string literal.
The status comparison uses the string literal "FAILED" instead of the type-safe enum constant WebhookCallStatus.FAILED. This is inconsistent with the rest of the codebase (e.g., WebhookCallStatusBadge uses enum constants) and reduces refactoring safety.
- {call.status === "FAILED" && (
+ {call.status === WebhookCallStatus.FAILED && (📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {call.status === "FAILED" && ( | |
| {call.status === WebhookCallStatus.FAILED && ( |
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx
around line 60, replace the string literal comparison call.status === "FAILED"
with the type-safe enum constant WebhookCallStatus.FAILED; if WebhookCallStatus
is not already imported in this file, add an import from its module (where enums
are defined) and use the enum constant for the comparison to match other
components and preserve refactor safety.
| switch (status) { | ||
| case WebhookCallStatus.DELIVERED: | ||
| badgeColor = "bg-green/15 text-green border border-green/20"; | ||
| label = "Delivered"; | ||
| break; | ||
| case WebhookCallStatus.FAILED: | ||
| badgeColor = "bg-red/15 text-red border border-red/20"; | ||
| label = "Failed"; | ||
| break; | ||
| case WebhookCallStatus.PENDING: | ||
| badgeColor = "bg-yellow/20 text-yellow border border-yellow/10"; | ||
| label = "Pending"; | ||
| break; | ||
| case WebhookCallStatus.IN_PROGRESS: | ||
| badgeColor = "bg-blue/15 text-blue border border-blue/20"; | ||
| label = "In Progress"; | ||
| break; | ||
| case WebhookCallStatus.DISCARDED: | ||
| badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; | ||
| label = "Discarded"; | ||
| break; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Add a default case for exhaustiveness and future-proofing.
The switch statement covers all current WebhookCallStatus enum values but lacks a default case. If a new status is added to the enum, TypeScript won't catch the missing handler and the component will silently fall back to the default gray styling.
Add a default case with an exhaustive check:
case WebhookCallStatus.DISCARDED:
badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
label = "Discarded";
break;
+ default:
+ // Exhaustiveness check
+ const _exhaustive: never = status;
+ console.warn(`Unhandled webhook call status: ${status}`);
+ break;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| switch (status) { | |
| case WebhookCallStatus.DELIVERED: | |
| badgeColor = "bg-green/15 text-green border border-green/20"; | |
| label = "Delivered"; | |
| break; | |
| case WebhookCallStatus.FAILED: | |
| badgeColor = "bg-red/15 text-red border border-red/20"; | |
| label = "Failed"; | |
| break; | |
| case WebhookCallStatus.PENDING: | |
| badgeColor = "bg-yellow/20 text-yellow border border-yellow/10"; | |
| label = "Pending"; | |
| break; | |
| case WebhookCallStatus.IN_PROGRESS: | |
| badgeColor = "bg-blue/15 text-blue border border-blue/20"; | |
| label = "In Progress"; | |
| break; | |
| case WebhookCallStatus.DISCARDED: | |
| badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; | |
| label = "Discarded"; | |
| break; | |
| } | |
| switch (status) { | |
| case WebhookCallStatus.DELIVERED: | |
| badgeColor = "bg-green/15 text-green border border-green/20"; | |
| label = "Delivered"; | |
| break; | |
| case WebhookCallStatus.FAILED: | |
| badgeColor = "bg-red/15 text-red border border-red/20"; | |
| label = "Failed"; | |
| break; | |
| case WebhookCallStatus.PENDING: | |
| badgeColor = "bg-yellow/20 text-yellow border border-yellow/10"; | |
| label = "Pending"; | |
| break; | |
| case WebhookCallStatus.IN_PROGRESS: | |
| badgeColor = "bg-blue/15 text-blue border border-blue/20"; | |
| label = "In Progress"; | |
| break; | |
| case WebhookCallStatus.DISCARDED: | |
| badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; | |
| label = "Discarded"; | |
| break; | |
| default: | |
| // Exhaustiveness check | |
| const _exhaustive: never = status; | |
| console.warn(`Unhandled webhook call status: ${status}`); | |
| break; | |
| } |
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx around
lines 11 to 32, the switch over WebhookCallStatus lacks a default branch which
can cause silent fallback if a new enum value is added; add a default case that
performs an exhaustive type check (assign the status to a never-typed variable
or call an assertUnreachable helper) so TypeScript will error for unhandled enum
members, and set a safe fallback badgeColor/label (e.g., neutral gray and
"Unknown") to avoid rendering gaps at runtime.
| } catch (error) { | ||
| console.error("Failed to highlight code:", error); | ||
| if (isMounted) { | ||
| setIsLoading(false); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add fallback to plain code display on error.
When highlighting fails, html remains an empty string, causing the final render to display an empty bordered box. Users should see the plain code instead.
Apply this diff to show plain code on error:
} catch (error) {
console.error("Failed to highlight code:", error);
if (isMounted) {
+ setHtml("");
setIsLoading(false);
}
}Then update the final render to check for empty html:
- if (isLoading) {
+ if (isLoading || !html) {
return (
<div className="rounded-lg overflow-hidden border bg-muted/50">| export async function updateContact( | ||
| contactId: string, | ||
| contact: Partial<ContactInput> | ||
| contact: Partial<ContactInput>, | ||
| teamId?: number, | ||
| ) { | ||
| return db.contact.update({ | ||
| const updatedContact = await db.contact.update({ | ||
| where: { | ||
| id: contactId, | ||
| }, | ||
| data: contact, | ||
| }); | ||
|
|
||
| await emitContactEvent(updatedContact, "contact.updated", teamId); | ||
|
|
||
| return updatedContact; | ||
| } | ||
|
|
||
| export async function deleteContact(contactId: string) { | ||
| return db.contact.delete({ | ||
| export async function deleteContact(contactId: string, teamId?: number) { | ||
| const deletedContact = await db.contact.delete({ | ||
| where: { | ||
| id: contactId, | ||
| }, | ||
| }); | ||
|
|
||
| await emitContactEvent(deletedContact, "contact.deleted", teamId); | ||
|
|
||
| return deletedContact; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
updateContact/deleteContact are not scoped by team/contactBook (cross-tenant mutation risk).
Even when teamId is provided, the DB mutation is where: { id: contactId } only. Please enforce ownership before updating/deleting (and ideally require contactBookId as well).
One straightforward pattern:
-export async function updateContact(
- contactId: string,
- contact: Partial<ContactInput>,
- teamId?: number,
-) {
- const updatedContact = await db.contact.update({
- where: {
- id: contactId,
- },
- data: contact,
- });
+export async function updateContact(
+ contactId: string,
+ contact: Partial<ContactInput>,
+ teamId: number,
+ contactBookId: string,
+) {
+ const existing = await db.contact.findFirst({
+ where: {
+ id: contactId,
+ contactBookId,
+ contactBook: { teamId },
+ },
+ });
+ if (!existing) throw new Error("Contact not found");
+
+ const updatedContact = await db.contact.update({
+ where: { id: contactId },
+ data: contact,
+ });
- await emitContactEvent(updatedContact, "contact.updated", teamId);
+ await emitContactEvent(updatedContact, "contact.updated", teamId);
return updatedContact;
}Apply the same idea to deleteContact.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export async function updateContact( | |
| contactId: string, | |
| contact: Partial<ContactInput> | |
| contact: Partial<ContactInput>, | |
| teamId?: number, | |
| ) { | |
| return db.contact.update({ | |
| const updatedContact = await db.contact.update({ | |
| where: { | |
| id: contactId, | |
| }, | |
| data: contact, | |
| }); | |
| await emitContactEvent(updatedContact, "contact.updated", teamId); | |
| return updatedContact; | |
| } | |
| export async function deleteContact(contactId: string) { | |
| return db.contact.delete({ | |
| export async function deleteContact(contactId: string, teamId?: number) { | |
| const deletedContact = await db.contact.delete({ | |
| where: { | |
| id: contactId, | |
| }, | |
| }); | |
| await emitContactEvent(deletedContact, "contact.deleted", teamId); | |
| return deletedContact; | |
| } | |
| export async function updateContact( | |
| contactId: string, | |
| contact: Partial<ContactInput>, | |
| teamId: number, | |
| contactBookId: string, | |
| ) { | |
| const existing = await db.contact.findFirst({ | |
| where: { | |
| id: contactId, | |
| contactBookId, | |
| contactBook: { teamId }, | |
| }, | |
| }); | |
| if (!existing) throw new Error("Contact not found"); | |
| const updatedContact = await db.contact.update({ | |
| where: { id: contactId }, | |
| data: contact, | |
| }); | |
| await emitContactEvent(updatedContact, "contact.updated", teamId); | |
| return updatedContact; | |
| } | |
| export async function deleteContact(contactId: string, teamId?: number) { | |
| const deletedContact = await db.contact.delete({ | |
| where: { | |
| id: contactId, | |
| }, | |
| }); | |
| await emitContactEvent(deletedContact, "contact.deleted", teamId); | |
| return deletedContact; | |
| } |
🤖 Prompt for AI Agents
In apps/web/src/server/service/contact-service.ts around lines 65 to 92, the
updateContact and deleteContact mutations only filter by contactId which allows
cross-tenant changes; modify both functions to enforce ownership by including
teamId and/or contactBookId in the Prisma where clause (e.g. require
contactBookId as an explicit parameter or lookup the contact first and verify
contact.teamId/contact.contactBookId matches the provided teamId/contactBookId),
then perform the update/delete only when the ownership check passes (throw a
not-found/permission error if it doesn’t); apply the same pattern to
deleteContact and ensure emitContactEvent is only called after a successful,
scoped mutation.
| const basePayload: EmailBasePayload = { | ||
| id: email.id, | ||
| status, | ||
| from: email.from, | ||
| to: email.to, | ||
| occurredAt, | ||
| campaignId: email.campaignId ?? undefined, | ||
| contactId: email.contactId ?? undefined, | ||
| domainId: email.domainId ?? null, | ||
| subject: email.subject, | ||
| metadata, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Normalize to to an array to match webhook payload expectations.
This file already treats email.to as possibly non-array (see suppression logic). Webhook payload should consistently send string[].
const basePayload: EmailBasePayload = {
id: email.id,
status,
from: email.from,
- to: email.to,
+ to: Array.isArray(email.to) ? email.to : [email.to],
occurredAt,
campaignId: email.campaignId ?? undefined,
contactId: email.contactId ?? undefined,
domainId: email.domainId ?? null,
subject: email.subject,
metadata,
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const basePayload: EmailBasePayload = { | |
| id: email.id, | |
| status, | |
| from: email.from, | |
| to: email.to, | |
| occurredAt, | |
| campaignId: email.campaignId ?? undefined, | |
| contactId: email.contactId ?? undefined, | |
| domainId: email.domainId ?? null, | |
| subject: email.subject, | |
| metadata, | |
| }; | |
| const basePayload: EmailBasePayload = { | |
| id: email.id, | |
| status, | |
| from: email.from, | |
| to: Array.isArray(email.to) ? email.to : [email.to], | |
| occurredAt, | |
| campaignId: email.campaignId ?? undefined, | |
| contactId: email.contactId ?? undefined, | |
| domainId: email.domainId ?? null, | |
| subject: email.subject, | |
| metadata, | |
| }; |
🤖 Prompt for AI Agents
In apps/web/src/server/service/ses-hook-parser.ts around lines 319 to 330, the
basePayload currently assigns `to` directly from `email.to` which may be a
string or undefined; change it so `to` is always a string[] for the webhook
payload by normalizing: if `email.to` is an array use it, if it's a non-empty
string wrap it in an array, and if it's null/undefined use an empty array;
update the assignment and types accordingly so downstream consumers always
receive `string[]`.
| import { logger } from "../logger/log"; | ||
| import { LimitService } from "./limit-service"; | ||
| import { UnsendApiError } from "../public-api/api-error"; | ||
| import { log } from "console"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Remove unused import.
The log import from "console" is never used in this file.
-import { log } from "console";📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import { log } from "console"; |
🤖 Prompt for AI Agents
In apps/web/src/server/service/webhook-service.ts around line 21, the import
`import { log } from "console";` is unused; remove this unused import line from
the file to clean up imports and avoid linter errors.
15b26e3 to
88ec52d
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
apps/web/src/server/public-api/api/contacts/delete-contact.ts (1)
40-48: Public API delete must enforce contact ownership (contactBook/team) at DB layer
CallinggetContactBook(...)without using its id, plus a service delete-by-id, leaves deletion unscoped.Suggested shape (requires updating
deleteContactservice to accept and enforcecontactBookId):app.openapi(route, async (c) => { const team = c.var.team; - await getContactBook(c, team.id); + const contactBook = await getContactBook(c, team.id); const contactId = c.req.param("contactId"); - await deleteContact(contactId, team.id); + await deleteContact(contactId, team.id, contactBook.id); return c.json({ success: true }); });apps/web/package.json (1)
41-76: Moveshikihighlighting to a Server Component or API route—avoid client-side bundle bloat
code-display.tsxis marked with"use client"and directly imports + executesshikiclient-side, which will significantly inflate the client bundle. Shiki should be used server-side only; either create a Server Component that pre-highlights code and passes HTML as a prop, or expose an API route for highlighting.
@usesend/libpackaging is acceptable but unconventionalThe package exports TypeScript directly (
main: "./index.ts") without a build step. This works within the monorepo but assumes consumers can handle.tsimports. Consider adding a build step and exporting compiled JavaScript for better compatibility, or ensure documentation explicitly states TypeScript requirement.apps/web/src/server/service/contact-service.ts (1)
122-142:subscribeContact/unsubscribeContacthave the same cross-tenant update-by-id risk.Line 123-141 updates by
idonly, with no ownership/tenant scoping. Consider addingteamId(and/orcontactBookId) to these APIs and enforcing ownership the same way asupdateContact.
♻️ Duplicate comments (15)
apps/web/src/server/api/routers/contacts.ts (1)
143-163: Block cross-book / cross-team contact update/delete (and don’t leakcontactBookIdinto update payload)
These mutations still don’t ensure thecontactIdbelongs toctx.contactBook, enabling ID-based modification/deletion outside the intended book (and potentially outside the team if the service deletes byidonly). Also,...contactlikely includescontactBookIddue to input merging.Router-level fix (plus service changes to enforce in DB):
updateContact: contactBookProcedure .input( z.object({ contactId: z.string(), email: z.string().optional(), firstName: z.string().optional(), lastName: z.string().optional(), properties: z.record(z.string()).optional(), subscribed: z.boolean().optional(), }), ) - .mutation(async ({ ctx: { team }, input }) => { - const { contactId, ...contact } = input; - return contactService.updateContact(contactId, contact, team.id); + .mutation(async ({ ctx: { contactBook, team }, input }) => { + const { contactId, contactBookId: _contactBookId, ...contact } = input; + return contactService.updateContact( + contactId, + contact, + team.id, + contactBook.id, + ); }), deleteContact: contactBookProcedure .input(z.object({ contactId: z.string() })) - .mutation(async ({ ctx: { team }, input }) => { - return contactService.deleteContact(input.contactId, team.id); + .mutation(async ({ ctx: { contactBook, team }, input }) => { + return contactService.deleteContact(input.contactId, team.id, contactBook.id); }),Then, in
apps/web/src/server/service/contact-service.ts, enforce ownership in the actual Prisma mutation (e.g., delete/update scoped byid+contactBookId(and/orteamIdvia relation)).packages/lib/package.json (1)
1-21: Fix@usesend/libentrypoints (currently TS) to avoid runtime resolution failures
Right now"main"/"types"point at./index.ts. That’s fragile for any runtime consumer (Node can’t load.tswithout a loader), and becomes a hard blocker once another package externalizes@usesend/libat build time.Suggested direction (pick one, but make it consistent across the repo):
{ "name": "@usesend/lib", "version": "0.0.0", "private": true, - "main": "./index.ts", - "types": "./index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "module": "./dist/index.mjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, "files": [ - "src" + "dist" ], "scripts": { "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix" } }To verify whether this is currently a real runtime break vs “bundled away”, please check how
tsupis configured for the SDK (external vs bundled dependencies):#!/bin/bash rg -n --hidden --glob='!**/node_modules/**' 'tsup|noExternal|external' packages/sdk -S rg -n --hidden --glob='!**/node_modules/**' '"@usesend/lib"' packages/sdk/package.json -napps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx (2)
60-60: UseWebhookCallStatus.FAILEDinstead of"FAILED".
19-31: Handle query error state (don’t show “Loading…” forever).apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx (1)
11-32: Add an exhaustiveness default branch for future enum values.apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx (2)
18-26: 7-day activity stats may be truncated by the 50-call fetch.
40-43: Addaria-label/titleto icon buttons and handle clipboard failures.- const handleCopySecret = () => { - navigator.clipboard.writeText(webhook.secret); - toast.success("Secret copied to clipboard"); - }; + const handleCopySecret = async () => { + try { + await navigator.clipboard.writeText(webhook.secret); + toast.success("Secret copied to clipboard"); + } catch { + toast.error("Failed to copy secret"); + } + }; ... <Button variant="ghost" size="icon" onClick={() => setShowSecret(!showSecret)} + aria-label={showSecret ? "Hide signing secret" : "Show signing secret"} + title={showSecret ? "Hide signing secret" : "Show signing secret"} className="h-5 w-5 text-muted-foreground hover:text-foreground" > ... <Button variant="ghost" size="icon" onClick={handleCopySecret} + aria-label="Copy signing secret" + title="Copy signing secret" className="h-5 w-5 text-muted-foreground hover:text-foreground" >Also applies to: 81-101
apps/web/src/components/code-display.tsx (1)
67-74: Add plaintext fallback when code highlighting fails and suppress lint warning.When
codeToHtml()fails,htmlremains empty and renders an empty bordered div with no fallback. Change the error handler to explicitly sethtmlto empty, update the render condition to also check!html, and add a lint suppression comment fordangerouslySetInnerHTML:} catch (error) { console.error("Failed to highlight code:", error); if (isMounted) { + setHtml(""); setIsLoading(false); } } ... - if (isLoading) { + if (isLoading || !html) { return ( <div className="rounded-lg overflow-hidden border bg-muted/50"> ... return ( <div className="rounded-lg overflow-hidden border"> <div className={`text-xs overflow-auto ${className}`} style={{ maxHeight }} + // @ts-expect-error HTML is generated by Shiki and expected to be safe; falls back to plaintext when empty dangerouslySetInnerHTML={{ __html: html }} /> </div> );apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
71-101:colSpanshould match the 5-column table. (Loading/empty states currently span 6.)
This is the same issue raised previously.- <TableCell colSpan={6} className="py-4 text-center"> + <TableCell colSpan={5} className="py-4 text-center"> <Spinner className="mx-auto h-6 w-6" innerSvgClass="stroke-primary" /> </TableCell> ... - <TableCell colSpan={6} className="py-4 text-center"> + <TableCell colSpan={5} className="py-4 text-center"> <p>No webhooks configured</p> </TableCell>apps/web/prisma/schema.prisma (1)
464-484: Do not storeWebhook.secretin plaintext.
This repeats a previously raised blocker: secrets used for signing must be decryptable, but still should be stored encrypted-at-rest with key metadata (envelope encryption/KMS), and never logged/returned.apps/web/src/server/service/contact-service.ts (1)
80-107: Fix cross-tenant mutation risk: scopeupdateContact/deleteContactby team/contactBook (not justid).Line 85-90 and Line 98-102 still mutate by
{ id: contactId }only.Suggested direction (single-tenant guard before write):
export async function updateContact( contactId: string, contact: Partial<ContactInput>, - teamId?: number, + teamId: number, ) { + const existing = await db.contact.findFirst({ + where: { id: contactId, contactBook: { teamId } }, + }); + if (!existing) throw new Error("Contact not found"); + const updatedContact = await db.contact.update({ where: { id: contactId, }, data: contact, });Apply the same pattern to
deleteContact(and only emit after the scoped mutation succeeds).apps/web/src/server/service/webhook-service.ts (4)
1-31: Remove unusedlogimport (likely lint failure).Line 21:
import { log } from "console";isn’t used.
403-568: Failure-path consistency issues: stale auto-disable check, non-transactional writes, and staleresponse*fields.
- Line 520-523: uses
call.webhook.consecutiveFailures + 1(stale under concurrency) for auto-disable.- Line 512-540: webhook + call updates aren’t in one transaction.
- Line 536-539:
?? undefinedmeans timeouts can keep oldresponseStatus/responseTimeMs/responseText.Minimal correctness fix for the stale
response*fields (always overwrite with nullable values):await db.webhookCall.update({ where: { id: call.id }, data: { status: attempt >= WEBHOOK_MAX_ATTEMPTS ? WebhookCallStatus.FAILED : WebhookCallStatus.PENDING, attempt, nextAttemptAt, lastError: errorMessage, - responseStatus: responseStatus ?? undefined, - responseTimeMs: responseTimeMs ?? undefined, - responseText: responseText ?? undefined, + responseStatus, + responseTimeMs, + responseText, }, });Separately (recommended), wrap the webhook increment + call update in a single
db.$transaction([...]), and base auto-disable on the updated row returned by the increment.
449-460: Lock contention retry timing is inconsistent with BullMQ backoff.Line 454 sets
nextAttemptAtusingWEBHOOK_LOCK_RETRY_DELAY_MS = 2000, but BullMQ is configured with exponential backoff starting at 5000ms. Either treatnextAttemptAtas “observability only” (and make it match BullMQ), or stop writingnextAttemptAthere and let BullMQ own the retry schedule.
746-806: FlushTextDecoderat stream end to avoid dropping trailing multibyte chars.After the read loop, flush once:
while (true) { const { done, value } = await reader.read(); if (done) { break; } ... } + chunks += decoder.decode();
🧹 Nitpick comments (9)
apps/web/src/app/(dashboard)/webhooks/page.tsx (1)
1-17: Consider dropping"use client"on the page to keep it server-rendered
IfH1doesn’t require client-side rendering, this page can be a Server Component and still includeAddWebhook/WebhookListas client children, reducing shipped JS.apps/web/src/server/api/routers/webhook.ts (1)
7-8: ConsiderEVENT_TYPES_ENUMnaming/shape for clarity.
Minor:EVENT_TYPES_ENUMis actually a Zod schema, not an enum type—EVENT_TYPE_SCHEMA(or similar) reads clearer, but behavior is fine.apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx (1)
1-1: Avoid@usesend/ui/src/*import paths in app code.
Prefer the public export of@usesend/ui(or an explicit exported subpath) so the UI package can reorganize without breaking apps/web.apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
53-69: TypestatusasWebhookStatus(avoid stringly-typed logic / invalid transitions).
Right now"ACTIVE" | "PAUSED" | "AUTO_DISABLED"is handled viastring, andAUTO_DISABLEDwould currently toggle toACTIVEif the handler is ever invoked.+ import { type Webhook, WebhookStatus } from "@prisma/client"; - import { type Webhook } from "@prisma/client"; - async function handleToggleStatus(webhookId: string, currentStatus: string) { - const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE"; + async function handleToggleStatus( + webhookId: string, + currentStatus: WebhookStatus, + ) { + if (currentStatus === WebhookStatus.AUTO_DISABLED) return; + const newStatus = + currentStatus === WebhookStatus.ACTIVE + ? WebhookStatus.PAUSED + : WebhookStatus.ACTIVE; setStatusMutation.mutate( { id: webhookId, status: newStatus }, { onSuccess: async () => { await utils.webhook.list.invalidate(); toast.success( `Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`, ); }, ... - function WebhookStatusBadge({ status }: { status: string }) { + function WebhookStatusBadge({ status }: { status: WebhookStatus }) {Also applies to: 228-250
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (2)
44-55: Verifyz.enum(WebhookEvents)typing works as intended.
Depending on howWebhookEventsis typed/exported, Zod may require an explicit tuple cast to satisfyz.enum.- const EVENT_TYPES_ENUM = z.enum(WebhookEvents); + const EVENT_TYPES_ENUM = z.enum( + WebhookEvents as [WebhookEventType, ...WebhookEventType[]], + );
128-333: Dropdown multi-select: preventDefault usage is appropriate; consider extracting shared event-toggle helpers.
handleToggleAll/group/eventis non-trivial; pulling it into a small local hook/helper would make this easier to maintain (and reuse with EditWebhookDialog, which has similar logic).apps/web/src/server/service/domain-service.ts (1)
230-233: Consider not awaiting webhook emission in request paths.
SinceemitDomainEventalready swallows/logs errors, awaiting it mostly adds latency; making it fire-and-forget preserves behavior while improving UX.- await emitDomainEvent(domain, "domain.created"); + void emitDomainEvent(domain, "domain.created"); ... - await emitDomainEvent(domainWithDns, eventType); + void emitDomainEvent(domainWithDns, eventType); ... - await emitDomainEvent(updated, "domain.updated"); + void emitDomainEvent(updated, "domain.updated"); ... - await emitDomainEvent(domain, "domain.deleted"); + void emitDomainEvent(domain, "domain.deleted");Also applies to: 306-315, 329-332, 355-358
packages/sdk/src/webhooks.ts (1)
147-171: Normalize/trim header values before validation.
Helps avoid accidental failures from whitespace or multiple header values.- const signature = getHeader(options.headers, WEBHOOK_SIGNATURE_HEADER); - const timestamp = getHeader(options.headers, WEBHOOK_TIMESTAMP_HEADER); + const signature = getHeader(options.headers, WEBHOOK_SIGNATURE_HEADER)?.trim(); + const timestamp = getHeader(options.headers, WEBHOOK_TIMESTAMP_HEADER)?.trim();Also applies to: 232-261
apps/web/src/server/service/contact-service.ts (1)
19-78: Webhook emission plumbing looks reasonable; consider tightening types + avoiding relative imports inapps/web.
- Line 71-76: event derivation +
emitContactEvent(...)integration is clean (and try/catch prevents breaking the main flow).- Coding guideline: in
apps/web/**/*, prefer~/alias imports over..//./(Line 6-9).- Minor: event type can be wrong under races between Line 25 and Line 48 (exists-check vs upsert).
Also applies to: 143-192
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (50)
apps/web/package.json(2 hunks)apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql(1 hunks)apps/web/prisma/schema.prisma(3 hunks)apps/web/src/app/(dashboard)/dev-settings/layout.tsx(2 hunks)apps/web/src/app/(dashboard)/emails/email-details.tsx(2 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx(1 hunks)apps/web/src/components/AppSideBar.tsx(2 hunks)apps/web/src/components/code-display.tsx(1 hunks)apps/web/src/lib/constants/plans.ts(3 hunks)apps/web/src/server/api/root.ts(2 hunks)apps/web/src/server/api/routers/contacts.ts(1 hunks)apps/web/src/server/api/routers/email.ts(2 hunks)apps/web/src/server/api/routers/limits.ts(2 hunks)apps/web/src/server/api/routers/webhook.ts(1 hunks)apps/web/src/server/jobs/webhook-cleanup-job.ts(1 hunks)apps/web/src/server/public-api/api/contacts/add-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/delete-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/update-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/upsert-contact.ts(1 hunks)apps/web/src/server/queue/queue-constants.ts(1 hunks)apps/web/src/server/service/contact-queue-service.ts(1 hunks)apps/web/src/server/service/contact-service.ts(5 hunks)apps/web/src/server/service/domain-service.ts(12 hunks)apps/web/src/server/service/limit-service.ts(1 hunks)apps/web/src/server/service/ses-hook-parser.ts(3 hunks)apps/web/src/server/service/webhook-service.ts(1 hunks)apps/web/tailwind.config.ts(1 hunks)docker/dev/compose.yml(1 hunks)packages/lib/.eslintrc.cjs(1 hunks)packages/lib/package.json(1 hunks)packages/lib/src/index.ts(1 hunks)packages/lib/src/webhook/webhook-events.ts(1 hunks)packages/lib/tsconfig.json(1 hunks)packages/lib/tsconfig.lint.json(1 hunks)packages/sdk/README.md(1 hunks)packages/sdk/index.ts(1 hunks)packages/sdk/package.json(1 hunks)packages/sdk/src/usesend.ts(2 hunks)packages/sdk/src/webhooks.ts(1 hunks)packages/ui/src/dropdown-menu.tsx(7 hunks)
✅ Files skipped from review due to trivial changes (2)
- packages/sdk/README.md
- packages/lib/tsconfig.lint.json
🚧 Files skipped from review as they are similar to previous changes (23)
- apps/web/src/lib/constants/plans.ts
- apps/web/src/server/api/routers/limits.ts
- packages/sdk/src/usesend.ts
- apps/web/src/server/api/routers/email.ts
- apps/web/src/app/(dashboard)/dev-settings/layout.tsx
- apps/web/src/server/api/root.ts
- docker/dev/compose.yml
- packages/sdk/index.ts
- packages/lib/src/index.ts
- packages/lib/.eslintrc.cjs
- apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx
- apps/web/src/app/(dashboard)/emails/email-details.tsx
- apps/web/src/components/AppSideBar.tsx
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx
- packages/lib/tsconfig.json
- apps/web/src/server/jobs/webhook-cleanup-job.ts
- packages/ui/src/dropdown-menu.tsx
- apps/web/src/server/queue/queue-constants.ts
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx
- apps/web/src/server/public-api/api/contacts/update-contact.ts
- apps/web/src/server/service/contact-queue-service.ts
- apps/web/src/server/service/ses-hook-parser.ts
- apps/web/src/server/public-api/api/contacts/add-contact.ts
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/server/service/limit-service.tsapps/web/src/server/public-api/api/contacts/delete-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/server/service/contact-service.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/api/routers/contacts.tsapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/public-api/api/contacts/upsert-contact.tspackages/sdk/src/webhooks.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/tailwind.config.tsapps/web/src/server/service/webhook-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxpackages/lib/src/webhook/webhook-events.tsapps/web/src/components/code-display.tsx
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/server/service/limit-service.tsapps/web/src/server/public-api/api/contacts/delete-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/server/service/contact-service.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/api/routers/contacts.tsapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/public-api/api/contacts/upsert-contact.tspackages/sdk/src/webhooks.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/tailwind.config.tsapps/web/src/server/service/webhook-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxpackages/lib/src/webhook/webhook-events.tsapps/web/src/components/code-display.tsx
**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
React components must use PascalCase naming convention (e.g., AppSideBar.tsx)
Files:
apps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/components/code-display.tsx
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/server/service/limit-service.tsapps/web/src/server/public-api/api/contacts/delete-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/server/service/contact-service.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/api/routers/contacts.tsapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/tailwind.config.tsapps/web/src/server/service/webhook-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/components/code-display.tsx
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/server/service/limit-service.tsapps/web/src/server/public-api/api/contacts/delete-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/server/service/contact-service.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/api/routers/contacts.tsapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/public-api/api/contacts/upsert-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/tailwind.config.tsapps/web/src/server/service/webhook-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxapps/web/src/components/code-display.tsx
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/app/(dashboard)/webhooks/page.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/server/service/limit-service.tsapps/web/src/server/public-api/api/contacts/delete-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/server/service/contact-service.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/api/routers/contacts.tsapps/web/src/server/service/domain-service.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/server/public-api/api/contacts/upsert-contact.tspackages/sdk/src/webhooks.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/tailwind.config.tsapps/web/src/server/service/webhook-service.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsxpackages/lib/src/webhook/webhook-events.tsapps/web/src/components/code-display.tsx
🧠 Learnings (7)
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx} : Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Applied to files:
apps/web/src/server/api/routers/webhook.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Run ESLint via usesend/eslint-config and ensure no warnings remain before submitting PRs
Applied to files:
packages/lib/package.jsonapps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Applied to files:
packages/lib/package.jsonapps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx,js,jsx} : Use alias `~/` for src imports in apps/web (e.g., `import { x } from "~/utils/x"`)
Applied to files:
apps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:13:56.758Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: .cursor/rules/general.mdc:0-0
Timestamp: 2025-11-28T21:13:56.758Z
Learning: Applies to **/*.{tsx,ts,jsx,js} : Include all required imports and ensure proper naming of key components in React/NextJS code
Applied to files:
apps/web/tailwind.config.tsapps/web/src/components/code-display.tsx
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,md} : Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Applied to files:
apps/web/tailwind.config.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Never use dynamic imports; always import on the top level
Applied to files:
apps/web/tailwind.config.ts
🧬 Code graph analysis (10)
apps/web/src/app/(dashboard)/webhooks/page.tsx (2)
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (1)
AddWebhook(66-333)apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
WebhookList(28-161)
apps/web/src/server/api/routers/webhook.ts (3)
packages/lib/src/webhook/webhook-events.ts (1)
WebhookEvents(36-40)apps/web/src/server/api/trpc.ts (2)
createTRPCRouter(82-82)teamProcedure(127-152)apps/web/src/server/service/webhook-service.ts (1)
WebhookService(85-385)
apps/web/src/server/public-api/api/contacts/delete-contact.ts (1)
apps/web/src/server/service/contact-service.ts (1)
deleteContact(97-107)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx (1)
packages/ui/src/code-block.tsx (1)
CodeBlock(13-36)
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (3)
packages/ui/src/spinner.tsx (1)
Spinner(4-51)apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (1)
EditWebhookDialog(66-323)apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
DeleteWebhook(11-61)
apps/web/src/server/api/routers/contacts.ts (1)
apps/web/src/server/api/trpc.ts (1)
contactBookProcedure(204-222)
apps/web/src/server/service/domain-service.ts (3)
packages/lib/src/webhook/webhook-events.ts (2)
DomainPayload(86-100)DomainWebhookEventType(16-16)apps/web/src/server/db.ts (1)
db(20-20)apps/web/src/server/service/webhook-service.ts (1)
WebhookService(85-385)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx (4)
packages/ui/src/toaster.tsx (1)
toast(31-31)packages/ui/src/card.tsx (4)
Card(74-74)CardHeader(75-75)CardTitle(77-77)CardContent(79-79)packages/ui/src/button.tsx (1)
Button(80-80)packages/email-editor/src/renderer.tsx (1)
code(740-756)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx (6)
packages/ui/src/card.tsx (4)
Card(74-74)CardContent(79-79)CardHeader(75-75)CardTitle(77-77)packages/ui/src/toaster.tsx (1)
toast(31-31)packages/ui/src/button.tsx (1)
Button(80-80)apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx (1)
WebhookCallStatusBadge(3-41)packages/ui/src/separator.tsx (1)
Separator(31-31)apps/web/src/components/code-display.tsx (1)
CodeDisplay(13-76)
apps/web/src/components/code-display.tsx (1)
packages/email-editor/src/renderer.tsx (1)
code(740-756)
🪛 ast-grep (0.40.0)
apps/web/src/components/code-display.tsx
[warning] 71-71: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.1.2)
apps/web/src/components/code-display.tsx
[error] 72-72: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (12)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx (1)
9-37: LGTM: robust JSON pretty-print with fallback.
The parse/pretty-print fallback is defensive and keeps the display stable even with non-JSON payloads.apps/web/src/server/service/limit-service.ts (1)
104-133: LGTM; verify whether “count” should include non-active webhooks.
Implementation matches other limit checks. Just confirmdb.webhook.count({ where: { teamId } })aligns with your business rule (e.g., exclude soft-deleted rows/statuses if they exist).apps/web/src/server/api/routers/webhook.ts (1)
1-8: The suggested import path does not work;WebhookEventsis not exported from@usesend/libpublic API.
WebhookEventsis defined only inpackages/lib/src/webhook/webhook-events.tsand is not re-exported frompackages/lib/index.ts. The current import path"@usesend/lib/src/webhook/webhook-events"is the only working approach today. Other files in the codebase (add-webhook.tsx,webhook-update-dialog.tsx) also use this same path. To enable importing from the public API, first exportWebhookEventsfrompackages/lib/index.ts, then update all three files to use"@usesend/lib".apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx (1)
68-72: The suggested wrapping withnew Date()is unnecessary. TRPC uses SuperJSON as its transformer, which automatically serializes and deserializes Date objects, ensuringwebhook.createdAtarrives as a Date on the client. Additionally,formatDistanceToNowfrom date-fns v4.1.0 accepts both Date objects and string inputs, so even if serialization resulted in a string, the function would handle it correctly. No changes needed.Likely an incorrect or invalid review comment.
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
103-155: Popover actions + row navigation interaction looks solid.
Stopping propagation on the actions wrapper prevents accidental navigation while clicking menu items.Also applies to: 163-226
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (1)
83-127: Good limit enforcement + “all events” encoding is consistent.
You guard both dialog-open and submit, and you consistently map “All events” →eventTypes: [].apps/web/prisma/schema.prisma (1)
81-94: Schema additions (Webhook/WebhookCall + relations + indexes) look coherent.
The cascade behaviors and call indexes for team/webhook/status and recency are a good baseline for the dispatch/monitoring flows.Also applies to: 101-128, 449-506
apps/web/src/server/service/domain-service.ts (1)
90-106: Payload builder + non-fatal emit wrapper is a good pattern.
Keeping webhook emission failures from breaking core domain flows is the right default.Also applies to: 387-396
packages/sdk/src/webhooks.ts (2)
139-198: Confirm timestamp unit + signature scheme matches what the server sends.
This verifier assumesX-UseSend-Timestampis milliseconds since epoch and signs${timestamp}.${rawBody}withv1=prefix; any server-side seconds/ms mismatch will cause false failures.Also applies to: 201-205
69-137: API ergonomics are good (verify boolean vs constructEvent throwing typed errors).
This is a nice UX for integrators.apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (1)
106-323: Event selection UX / submit behavior looks solid.The “all events” toggle + per-group/per-event behavior is consistent, and invalidation on successful update is in the right place.
packages/lib/src/webhook/webhook-events.ts (1)
1-196: Typed event surface + runtime arrays look good (niceas constderivations).
| CREATE TABLE "Webhook" ( | ||
| "id" TEXT NOT NULL, | ||
| "teamId" INTEGER NOT NULL, | ||
| "url" TEXT NOT NULL, | ||
| "description" TEXT, | ||
| "secret" TEXT NOT NULL, | ||
| "status" "WebhookStatus" NOT NULL DEFAULT 'ACTIVE', | ||
| "eventTypes" TEXT[], | ||
| "apiVersion" TEXT, | ||
| "consecutiveFailures" INTEGER NOT NULL DEFAULT 0, | ||
| "lastFailureAt" TIMESTAMP(3), | ||
| "lastSuccessAt" TIMESTAMP(3), | ||
| "createdByUserId" INTEGER, | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| "updatedAt" TIMESTAMP(3) NOT NULL, | ||
|
|
||
| CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: Webhook.eventTypes must be NOT NULL DEFAULT '{}' (NULL breaks UI + server filters).
Line 15 defines eventTypes as nullable. The app treats “all events” as empty array and uses isEmpty filters + .length checks.
Suggested fix in migration:
- "eventTypes" TEXT[],
+ "eventTypes" TEXT[] NOT NULL DEFAULT '{}'::TEXT[],(If this migration is already applied in some environments, add a follow-up migration to backfill NULL -> '{}' before setting NOT NULL.)
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| CREATE TABLE "Webhook" ( | |
| "id" TEXT NOT NULL, | |
| "teamId" INTEGER NOT NULL, | |
| "url" TEXT NOT NULL, | |
| "description" TEXT, | |
| "secret" TEXT NOT NULL, | |
| "status" "WebhookStatus" NOT NULL DEFAULT 'ACTIVE', | |
| "eventTypes" TEXT[], | |
| "apiVersion" TEXT, | |
| "consecutiveFailures" INTEGER NOT NULL DEFAULT 0, | |
| "lastFailureAt" TIMESTAMP(3), | |
| "lastSuccessAt" TIMESTAMP(3), | |
| "createdByUserId" INTEGER, | |
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| "updatedAt" TIMESTAMP(3) NOT NULL, | |
| CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") | |
| ); | |
| CREATE TABLE "Webhook" ( | |
| "id" TEXT NOT NULL, | |
| "teamId" INTEGER NOT NULL, | |
| "url" TEXT NOT NULL, | |
| "description" TEXT, | |
| "secret" TEXT NOT NULL, | |
| "status" "WebhookStatus" NOT NULL DEFAULT 'ACTIVE', | |
| "eventTypes" TEXT[] NOT NULL DEFAULT '{}'::TEXT[], | |
| "apiVersion" TEXT, | |
| "consecutiveFailures" INTEGER NOT NULL DEFAULT 0, | |
| "lastFailureAt" TIMESTAMP(3), | |
| "lastSuccessAt" TIMESTAMP(3), | |
| "createdByUserId" INTEGER, | |
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| "updatedAt" TIMESTAMP(3) NOT NULL, | |
| CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") | |
| ); |
🤖 Prompt for AI Agents
In apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql around
lines 8 to 25, the Webhook.eventTypes column is currently nullable which breaks
UI/server logic; change its definition to be NOT NULL with a default empty array
(DEFAULT '{}') and, if this migration may already be applied in environments,
add a follow-up migration that first updates existing rows setting NULL
eventTypes to '{}' and then alters the column to SET NOT NULL and set the
default to '{}'.
| "use client"; | ||
|
|
||
| import { WebhookCall } from "@prisma/client"; | ||
| import { formatDate } from "date-fns"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n apps/web/src/app/\(dashboard\)/webhooks/\[webhookId\]/webhook-call-details.tsx | head -100Repository: usesend/useSend
Length of output: 4077
🌐 Web query:
date-fns formatDate export function
💡 Result:
Short answer: date-fns exposes the formatter as format (import { format } from 'date-fns'). Some TypeScript/JSDoc views refer to a formatDate signature, but you should call format. Example:
- ESM: import { format } from 'date-fns';
format(new Date(), 'yyyy-MM-dd');. [1][2]
Notes: format tokens follow date-fns rules (use 'MM' for month, 'mm' is minutes). See docs for token details. [2]
Sources: date-fns README and format docs. [1][2]
Fix invalid date-fns import (formatDate → format).
The formatDate function is not exported by date-fns. Use format instead, and wrap call.createdAt with new Date(...) for type safety.
-import { formatDate } from "date-fns";
+import { format } from "date-fns";
...
- {formatDate(call.createdAt, "MMM dd, yyyy HH:mm:ss")}
+ {format(new Date(call.createdAt), "MMM dd, yyyy HH:mm:ss")}Also applies to: 95-97
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx
around line 4 (and also affecting usages at lines 95-97), the code imports a
non-existent date-fns export `formatDate`; change the import to `format` from
"date-fns" and update the call site(s) to pass a Date object (e.g., wrap
call.createdAt with new Date(call.createdAt)) so formatting is type-safe and
uses the correct function signature.
| import { | ||
| Table, | ||
| TableBody, | ||
| TableCell, | ||
| TableHead, | ||
| TableHeader, | ||
| TableRow, | ||
| } from "@usesend/ui/src/table"; | ||
| import Spinner from "@usesend/ui/src/spinner"; | ||
| import { api } from "~/trpc/react"; | ||
| import { formatDistanceToNow } from "date-fns"; | ||
| import { Edit3, MoreVertical, Pause, Play, Trash2 } from "lucide-react"; | ||
| import { Button } from "@usesend/ui/src/button"; | ||
| import { toast } from "@usesend/ui/src/toaster"; | ||
| import { DeleteWebhook } from "./delete-webhook"; | ||
| import { useState } from "react"; | ||
| import { EditWebhookDialog } from "./webhook-update-dialog"; | ||
| import { useRouter } from "next/navigation"; | ||
| import { | ||
| Popover, | ||
| PopoverContent, | ||
| PopoverTrigger, | ||
| } from "@usesend/ui/src/popover"; | ||
| import { type Webhook } from "@prisma/client"; | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix Spinner import (likely a runtime/TS compile break).
@usesend/ui/src/spinner (as shown in the referenced snippet) exports Spinner as a named export, not default.
- import Spinner from "@usesend/ui/src/spinner";
+ import { Spinner } from "@usesend/ui/src/spinner";📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import { | |
| Table, | |
| TableBody, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow, | |
| } from "@usesend/ui/src/table"; | |
| import Spinner from "@usesend/ui/src/spinner"; | |
| import { api } from "~/trpc/react"; | |
| import { formatDistanceToNow } from "date-fns"; | |
| import { Edit3, MoreVertical, Pause, Play, Trash2 } from "lucide-react"; | |
| import { Button } from "@usesend/ui/src/button"; | |
| import { toast } from "@usesend/ui/src/toaster"; | |
| import { DeleteWebhook } from "./delete-webhook"; | |
| import { useState } from "react"; | |
| import { EditWebhookDialog } from "./webhook-update-dialog"; | |
| import { useRouter } from "next/navigation"; | |
| import { | |
| Popover, | |
| PopoverContent, | |
| PopoverTrigger, | |
| } from "@usesend/ui/src/popover"; | |
| import { type Webhook } from "@prisma/client"; | |
| import { | |
| Table, | |
| TableBody, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow, | |
| } from "@usesend/ui/src/table"; | |
| import { Spinner } from "@usesend/ui/src/spinner"; | |
| import { api } from "~/trpc/react"; | |
| import { formatDistanceToNow } from "date-fns"; | |
| import { Edit3, MoreVertical, Pause, Play, Trash2 } from "lucide-react"; | |
| import { Button } from "@usesend/ui/src/button"; | |
| import { toast } from "@usesend/ui/src/toaster"; | |
| import { DeleteWebhook } from "./delete-webhook"; | |
| import { useState } from "react"; | |
| import { EditWebhookDialog } from "./webhook-update-dialog"; | |
| import { useRouter } from "next/navigation"; | |
| import { | |
| Popover, | |
| PopoverContent, | |
| PopoverTrigger, | |
| } from "@usesend/ui/src/popover"; | |
| import { type Webhook } from "@prisma/client"; |
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx around lines 3 to 27,
the Spinner import uses a default import but the module exports Spinner as a
named export; change the import to a named import (e.g., import { Spinner } from
"...") so TypeScript/runtime will find the export and avoid compile/runtime
errors, then ensure any existing Spinner usage remains unchanged.
| const updateWebhook = api.webhook.update.useMutation(); | ||
| const utils = api.useUtils(); | ||
| const initialHasAllEvents = | ||
| (webhook.eventTypes as WebhookEventType[]).length === 0; | ||
| const [allEventsSelected, setAllEventsSelected] = | ||
| useState(initialHasAllEvents); | ||
|
|
||
| const form = useForm<EditWebhookFormValues>({ | ||
| resolver: zodResolver(editWebhookSchema), | ||
| defaultValues: { | ||
| url: webhook.url, | ||
| eventTypes: initialHasAllEvents | ||
| ? [] | ||
| : (webhook.eventTypes as WebhookEventType[]), | ||
| }, | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| if (open) { | ||
| const hasAllEvents = | ||
| (webhook.eventTypes as WebhookEventType[]).length === 0; | ||
| form.reset({ | ||
| url: webhook.url, | ||
| eventTypes: hasAllEvents | ||
| ? [] | ||
| : (webhook.eventTypes as WebhookEventType[]), | ||
| }); | ||
| setAllEventsSelected(hasAllEvents); | ||
| } | ||
| }, [open, webhook, form]); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make webhook.eventTypes null-safe (UI can crash if DB allows NULL).
(webhook.eventTypes as WebhookEventType[]).length (Line 78-80, 95-103) will throw at runtime if eventTypes is null.
- const initialHasAllEvents =
- (webhook.eventTypes as WebhookEventType[]).length === 0;
+ const webhookEventTypes = (webhook.eventTypes ?? []) as WebhookEventType[];
+ const initialHasAllEvents = webhookEventTypes.length === 0;…and reuse webhookEventTypes in defaultValues / reset.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx around lines
75 to 105, the code assumes webhook.eventTypes is non-null and uses
(webhook.eventTypes as WebhookEventType[]).length which will throw if eventTypes
is null; fix by creating a null-safe const like const webhookEventTypes =
(webhook.eventTypes ?? []) as WebhookEventType[] and use webhookEventTypes for
computing initialHasAllEvents, for defaultValues.eventTypes and in the useEffect
reset call, and remove the direct casts/length checks on webhook.eventTypes so
the UI won't crash when the DB returns null.
| const contact = await addOrUpdateContact( | ||
| contactBook.id, | ||
| c.req.valid("json") | ||
| c.req.valid("json"), | ||
| team.id, | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, examine the full upsert-contact.ts file
cat -n apps/web/src/server/public-api/api/contacts/upsert-contact.tsRepository: usesend/useSend
Length of output: 2087
🏁 Script executed:
# Find the route definition for this endpoint
rg "upsert-contact|/contacts/\{contactId\}" apps/web/src/server/public-api --type ts -B 2 -A 2Repository: usesend/useSend
Length of output: 2375
🏁 Script executed:
# Find addOrUpdateContact function definition
rg "function addOrUpdateContact|const addOrUpdateContact|export.*addOrUpdateContact" apps/web --type ts -B 2 -A 5Repository: usesend/useSend
Length of output: 609
🏁 Script executed:
# Search for all call sites of addOrUpdateContact
rg "addOrUpdateContact\(" apps/web --type ts -B 2 -A 2Repository: usesend/useSend
Length of output: 1703
🏁 Script executed:
# Check update-contact.ts to see if it validates/uses contactId
cat -n apps/web/src/server/public-api/api/contacts/update-contact.tsRepository: usesend/useSend
Length of output: 2223
🏁 Script executed:
# Check delete-contact.ts to see if it validates/uses contactId
cat -n apps/web/src/server/public-api/api/contacts/delete-contact.tsRepository: usesend/useSend
Length of output: 1693
🏁 Script executed:
# Check get-contact.ts to see if it validates/uses contactId
cat -n apps/web/src/server/public-api/api/contacts/get-contact.tsRepository: usesend/useSend
Length of output: 2687
Add missing contactId param validation and thread it through the service call to match API contract.
The route declares {contactId} in the path but doesn't validate or use it—unlike all peer endpoints (update-contact, delete-contact, get-contact), which all validate and extract contactId via c.req.param(). The upsert currently uses email-based matching from the request body instead of ID-based matching. Add contactId to the Zod params schema (lines 10–21) and thread it into the addOrUpdateContact call to enforce the API contract consistently across all contact operations.
🤖 Prompt for AI Agents
In apps/web/src/server/public-api/api/contacts/upsert-contact.ts around lines
10–21 and 55–59, the route declares a {contactId} path param but it's not
validated or passed to the service; update the Zod params schema to include
contactId (string, required) and use
c.req.param("contactId")/c.req.valid("params") to extract it, then thread that
contactId into the addOrUpdateContact call (e.g.,
addOrUpdateContact(contactBook.id, c.req.valid("json"), team.id, contactId)) so
the handler validates the path param and passes the ID into the service per the
API contract.
| export class WebhookQueueService { | ||
| private static queue = new Queue<WebhookCallJobData>(WEBHOOK_DISPATCH_QUEUE, { | ||
| connection: getRedis(), | ||
| defaultJobOptions: { | ||
| ...DEFAULT_QUEUE_OPTIONS, | ||
| attempts: WEBHOOK_MAX_ATTEMPTS, | ||
| backoff: { | ||
| type: "exponential", | ||
| delay: WEBHOOK_BASE_BACKOFF_MS, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| private static worker = new Worker( | ||
| WEBHOOK_DISPATCH_QUEUE, | ||
| createWorkerHandler(processWebhookCall), | ||
| { | ||
| connection: getRedis(), | ||
| concurrency: WEBHOOK_DISPATCH_CONCURRENCY, | ||
| }, | ||
| ); | ||
|
|
||
| static { | ||
| this.worker.on("error", (error) => { | ||
| logger.error({ error }, "[WebhookQueueService]: Worker error"); | ||
| }); | ||
|
|
||
| logger.info("[WebhookQueueService]: Initialized webhook queue service"); | ||
| } | ||
|
|
||
| public static async enqueueCall(callId: string, teamId: number) { | ||
| await this.queue.add( | ||
| callId, | ||
| { | ||
| callId, | ||
| teamId, | ||
| }, | ||
| { jobId: callId }, | ||
| ); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don’t start BullMQ Worker on module import unless you truly want it running in every server process.
Line 56-63 instantiates a Worker as a static field. If this module is imported by the web/API server, you can accidentally run dispatch workers there too (and multiple times across replicas).
Typical pattern: export an explicit startWebhookWorker() (or guard behind an env flag) and call it only from the dedicated worker entrypoint.
🤖 Prompt for AI Agents
In apps/web/src/server/service/webhook-service.ts around lines 43 to 84, a
BullMQ Worker is being instantiated as a static field which will start on module
import; remove the static Worker initialization and the static block that wires
its error handler and logging, and instead export a startWebhookWorker (and
optional stopWebhookWorker) function that creates the Worker, attaches event
handlers, and returns or stores it; call that start function only from the
dedicated worker entrypoint (or guard it behind a config/env flag) so the worker
is not started in web/API server processes.
| public static async listWebhookCalls(params: { | ||
| teamId: number; | ||
| webhookId?: string; | ||
| status?: WebhookCallStatus; | ||
| limit: number; | ||
| cursor?: string; | ||
| }) { | ||
| const calls = await db.webhookCall.findMany({ | ||
| where: { | ||
| teamId: params.teamId, | ||
| webhookId: params.webhookId, | ||
| status: params.status, | ||
| }, | ||
| orderBy: { createdAt: "desc" }, | ||
| take: params.limit + 1, | ||
| cursor: params.cursor ? { id: params.cursor } : undefined, | ||
| }); | ||
|
|
||
| let nextCursor: string | null = null; | ||
| if (calls.length > params.limit) { | ||
| const next = calls.pop(); | ||
| nextCursor = next?.id ?? null; | ||
| } | ||
|
|
||
| return { | ||
| items: calls, | ||
| nextCursor, | ||
| }; | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cursor pagination is wrong: cursor: { id } + orderBy: createdAt desc + missing skip: 1 will duplicate/skip items.
Suggested fix:
const calls = await db.webhookCall.findMany({
where: {
teamId: params.teamId,
webhookId: params.webhookId,
status: params.status,
},
- orderBy: { createdAt: "desc" },
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: params.limit + 1,
cursor: params.cursor ? { id: params.cursor } : undefined,
+ skip: params.cursor ? 1 : 0,
});🤖 Prompt for AI Agents
apps/web/src/server/service/webhook-service.ts lines 341-370: the current cursor
pagination uses cursor: { id } together with orderBy: { createdAt: "desc" } and
no skip which can cause duplicated/skipped items; fix by making ordering
deterministic (add a secondary orderBy on id, e.g., id desc) and include skip: 1
when a cursor is provided (keep cursor: { id: params.cursor }) so the returned
page excludes the cursor item; ensure take stays params.limit + 1 and compute
nextCursor the same way.
apps/web/tailwind.config.ts
Outdated
| content: [ | ||
| "./src/**/*.tsx", | ||
| `${path.join(require.resolve("@usesend/ui"), "..")}/**/*.{ts,tsx}`, | ||
| `${path.join(require.resolve("@usesend/lib"), "..")}/**/*.{ts,tsx}`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find the lib package and check for React components with Tailwind usage
# Locate @usesend/lib (typically in packages/lib)
lib_path=$(find packages -maxdepth 2 -type d -name "lib" 2>/dev/null | head -n 1)
if [ -z "$lib_path" ]; then
echo "Searching for @usesend/lib structure..."
fd -t d "lib" packages --max-depth 1
lib_path="packages/lib"
fi
echo "=== Checking package structure in: $lib_path ==="
ls -la "$lib_path" 2>/dev/null | head -20
echo ""
echo "=== Looking for .tsx files ==="
find "$lib_path" -name "*.tsx" -type f 2>/dev/null
echo ""
echo "=== Looking for .ts files ==="
find "$lib_path" -name "*.ts" -type f 2>/dev/null | head -20
echo ""
echo "=== Checking src directory structure ==="
if [ -d "$lib_path/src" ]; then
ls -la "$lib_path/src" 2>/dev/null
fi
echo ""
echo "=== Searching for className usage in lib ==="
rg "className" "$lib_path" --type tsx --type jsx 2>/dev/null | head -10Repository: usesend/useSend
Length of output: 455
🏁 Script executed:
#!/bin/bash
# Search for @usesend/lib package more broadly
echo "=== All packages in packages/ directory ==="
ls -la packages/
echo ""
echo "=== Looking for package.json files with @usesend/lib ==="
find packages -name "package.json" -exec grep -l '"name".*"@usesend/lib"' {} \;
echo ""
echo "=== Current tailwind.config.ts content ==="
cat -n apps/web/tailwind.config.ts
echo ""
echo "=== Checking if packages/lib exists ==="
if [ -d "packages/lib" ]; then
echo "packages/lib structure:"
find packages/lib -type f | head -20
else
echo "packages/lib does not exist"
fiRepository: usesend/useSend
Length of output: 1677
🏁 Script executed:
#!/bin/bash
# Verify @usesend/lib content
echo "=== All files in packages/lib ==="
find packages/lib -type f -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx"
echo ""
echo "=== Content of packages/lib/src/index.ts ==="
cat packages/lib/src/index.ts
echo ""
echo "=== Content of packages/lib/src/constants/ses-errors.ts ==="
head -30 packages/lib/src/constants/ses-errors.ts
echo ""
echo "=== Content of packages/lib/src/webhook/webhook-events.ts ==="
head -30 packages/lib/src/webhook/webhook-events.ts
echo ""
echo "=== Searching for React imports in @usesend/lib ==="
rg "import.*React|from.*react" packages/lib --type ts
echo ""
echo "=== Searching for className in @usesend/lib ==="
rg "className" packages/libRepository: usesend/useSend
Length of output: 3612
Remove @usesend/lib from Tailwind content paths—it contains only utilities and constants, not UI components.
This package exports webhook event types, SES error constants, and utility functions (invariant, assertUnreachable, isDefined). There are no React components or Tailwind class names in @usesend/lib, making this content entry unnecessary and wasteful during the Tailwind build process.
🤖 Prompt for AI Agents
In apps/web/tailwind.config.ts around line 10, remove the content entry
referencing `${path.join(require.resolve("@usesend/lib"), "..")}/**/*.{ts,tsx}`
because @usesend/lib contains only utilities/constants and no UI/Tailwind
classes; delete that line from the content array, keep other content paths
intact, save the file, and run the Tailwind build/dev server to verify no
missing styles or broken imports.
| "dependencies": { | ||
| "@usesend/lib": "workspace:*", | ||
| "@react-email/render": "^1.0.6", | ||
| "react": "^19.1.0" | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Check the @usesend/lib package.json status
cat packages/lib/package.json
# Check if lib has dist folder and contents
echo "=== Checking lib dist folder ==="
ls -la packages/lib/dist 2>/dev/null || echo "No dist folder found"
# Check SDK's build configuration
echo "=== Checking SDK build config ==="
rg -n --hidden --glob='!**/node_modules/**' 'noExternal|external|tsup\.config' packages/sdk -S
# Also check if tsup.config exists in SDK
echo "=== Checking for tsup config files ==="
find packages/sdk -maxdepth 2 -name 'tsup.config.*' -o -name 'vite.config.*' 2>/dev/null
# Check SDK's package.json for build scripts and config
echo "=== SDK package.json build related fields ==="
cat packages/sdk/package.json | grep -A 20 '"scripts"\|"build"\|"tsup"\|"vite"'Repository: usesend/useSend
Length of output: 1555
@usesend/lib is private and unbuildable — SDK cannot be published as-is
The SDK depends on @usesend/lib which is marked private: true, has no dist folder, and points to TypeScript source ("main": "./index.ts"). When the SDK is published to npm, consumers will be unable to resolve this private workspace dependency at runtime, causing the package to break.
The tsup build command (tsup index.ts --format esm,cjs --dts) does not bundle @usesend/lib — tsup externalizes all dependencies by default.
Fix: Either
- Bundle
@usesend/libinto the SDK — addtsup.config.tswithexport default { entry: { index: 'index.ts' }, format: ['esm', 'cjs'], dts: true, noExternal: ['@usesend/lib'] }and move it todevDependencies. - Publish
@usesend/libseparately — removeprivate: true, add build output tofiles, publish to npm, and keep as a runtime dependency.
🤖 Prompt for AI Agents
packages/sdk/package.json lines 27-31: the SDK depends on a private workspace
package @usesend/lib which is not built (main points to TypeScript source) and
tsup currently externalizes it, so the published SDK will be broken; fix by
either (A) bundling @usesend/lib into the SDK: add a tsup.config.ts exporting {
entry: { index: 'index.ts' }, format: ['esm','cjs'], dts: true, noExternal:
['@usesend/lib'] }, update build scripts to use tsup.config.ts, move
@usesend/lib to devDependencies (so it isn’t required at runtime), and ensure
the bundled output includes its code; or (B) publish @usesend/lib as a real npm
package: remove private: true, add a build step that emits dist and include dist
in package.json files, publish it to npm, and keep it as a regular runtime
dependency in packages/sdk/package.json.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/src/app/(dashboard)/dev-settings/layout.tsx (1)
3-12: Fix TS error:React.ReactNodeused without importingReacttypeThe file uses
React.ReactNodeon line 11 without a corresponding type import. With the project's TypeScript configuration (strict: true, no global React types), this will fail type checking.import { H1 } from "@usesend/ui"; +import type React from "react"; import { SettingsNavButton } from "./settings-nav-button";
♻️ Duplicate comments (5)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx (2)
17-40: 7-day stats can be incomplete due tolimit: 50+ client-side filtering.
If a webhook has >50 calls within 7 days, counts will under-report. Consider higher limits or server-side date filtering/aggregation.
42-45: Add a11y labels to icon buttons + await/handle clipboard failures.
Both icon-only buttons needaria-label/title, andnavigator.clipboard.writeText(...)should be awaited/caught (clipboard can fail outside secure contexts / permissions).- const handleCopySecret = () => { - navigator.clipboard.writeText(webhook.secret); - toast.success("Secret copied to clipboard"); - }; + const handleCopySecret = async () => { + try { + await navigator.clipboard.writeText(webhook.secret); + toast.success("Secret copied to clipboard"); + } catch { + toast.error("Failed to copy secret"); + } + }; ... <Button variant="ghost" size="icon" onClick={() => setShowSecret(!showSecret)} + aria-label={showSecret ? "Hide signing secret" : "Show signing secret"} + title={showSecret ? "Hide signing secret" : "Show signing secret"} className="h-5 w-5 text-muted-foreground hover:text-foreground" > ... <Button variant="ghost" size="icon" onClick={handleCopySecret} + aria-label="Copy signing secret" + title="Copy signing secret" className="h-5 w-5 text-muted-foreground hover:text-foreground" >Also applies to: 88-107
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (2)
11-11: FixSpinnerimport to match module exports (named vs default).
If@usesend/ui/src/spinnerexportsSpinneras a named export, this will break at runtime/TS.-import Spinner from "@usesend/ui/src/spinner"; +import { Spinner } from "@usesend/ui/src/spinner";
88-102:colSpanmismatch: table has 5 columns, but loading/empty rows use 6.
This can render misaligned table rows.- <TableCell colSpan={6} className="py-4 text-center"> + <TableCell colSpan={5} className="py-4 text-center"> ... - <TableCell colSpan={6} className="py-4 text-center"> + <TableCell colSpan={5} className="py-4 text-center">apps/web/src/components/code-display.tsx (1)
44-49: The fallback issue from the previous review remains unaddressed.The error handler still doesn't provide a fallback to display plain code when highlighting fails, leaving users with an empty bordered box. The previous review comment correctly identified this issue and provided a solution.
🧹 Nitpick comments (4)
apps/web/src/app/(dashboard)/dev-settings/layout.tsx (1)
8-15: Consider renamingApiKeysPageto match thislayout.tsx’s roleThis is a dev-settings layout (wraps
childrenand renders nav + title), so a name likeDevSettingsLayout(or similar) would read clearer.apps/web/package.json (1)
75-75: Consider alphabetical ordering of dependencies.The
usesend-jsdependency (line 75) breaks the alphabetical sequence within the dependencies section—it appears afterstripebut should come earlier if strict alphabetical order is desired (betweensuperjsonandzod). This is a minor organization point that can improve maintainability.If alphabetical ordering is the project convention, reorder
usesend-jsto maintain consistency:"superjson": "^2.2.2", "tldts": "^7.0.4", "ua-parser-js": "^2.0.3", + "usesend-js": "workspace:*", "use-debounce": "^10.0.4", - "usesend-js": "workspace:*", "zod": "^3.24.3",apps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsx (1)
1-23: Use Prisma enum typing forstatus(avoid stringly-typed statuses).
Right now any string is accepted; preferWebhook["status"](orWebhookStatus) so callers can’t pass invalid values. Optionally, format unknown statuses (e.g., replace_with space) instead of showing raw enum tokens.apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (1)
54-70: Avoid stringly-typed webhook statuses inhandleToggleStatus.
PrefercurrentStatus: Webhook["status"](orWebhookStatus) and computenewStatusfrom that union.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (10)
apps/web/package.json(3 hunks)apps/web/src/app/(dashboard)/dev-settings/layout.tsx(2 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsx(1 hunks)apps/web/src/components/code-display.tsx(1 hunks)packages/ui/styles/globals.css(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/components/code-display.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsx
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/components/code-display.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsx
**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
React components must use PascalCase naming convention (e.g., AppSideBar.tsx)
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/components/code-display.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsx
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/components/code-display.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsx
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/components/code-display.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsx
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsxapps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/components/code-display.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsx
🧠 Learnings (3)
📚 Learning: 2025-11-28T21:13:56.758Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: .cursor/rules/general.mdc:0-0
Timestamp: 2025-11-28T21:13:56.758Z
Learning: Applies to **/*.{tsx,ts,jsx,js} : Include all required imports and ensure proper naming of key components in React/NextJS code
Applied to files:
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/components/code-display.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsx
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Never use dynamic imports; always import on the top level
Applied to files:
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Applied to files:
apps/web/package.json
🪛 ast-grep (0.40.0)
apps/web/src/components/code-display.tsx
[warning] 106-106: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.1.2)
apps/web/src/components/code-display.tsx
[error] 107-107: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (8)
apps/web/src/app/(dashboard)/dev-settings/layout.tsx (1)
15-21: H1 swap looks goodUsing
@usesend/ui’sH1here is consistent and should centralize typography.apps/web/package.json (2)
7-7: Turbopack enabled for local development.Good move enabling Turbopack in the dev script. Next.js 15.3.7 has stable Turbopack support for development builds, which improves iteration speed. This aligns with the framework's direction.
42-42: The workspace package@usesend/libis properly configured and exists. The pnpm-workspace.yaml correctly includespackages/*, and packages/lib/package.json declares the correct name with"main": "./index.ts". The dependency in apps/web/package.json at line 42 is properly declared as"@usesend/lib": "workspace:*". The package also includes the required ESLint configuration per repository standards.apps/web/src/components/code-display.tsx (4)
1-23: LGTM! Clean component setup.The imports, TypeScript interface, and component structure follow best practices. The use of "use client" directive, PascalCase naming, and proper default values align with the coding guidelines.
59-67: LGTM! Clean copy implementation.The copy handler properly uses the Clipboard API with error handling and provides good UX through temporary visual feedback.
69-92: LGTM! Good loading state UX.The loading state provides immediate feedback by showing the plain code with a functional copy button, ensuring users aren't left waiting with a blank screen.
104-108: The dangerouslySetInnerHTML usage is safe here.Static analysis flagged the
dangerouslySetInnerHTMLusage, but this is a false positive. The HTML is generated by Shiki'scodeToHtmlfunction, a trusted syntax highlighting library that properly escapes and sanitizes the code content. The rawcodeprop never reaches the DOM directly—it's processed through Shiki's secure rendering pipeline first. This is a standard and safe pattern for syntax highlighting in React applications.packages/ui/styles/globals.css (1)
159-165: Formatting changes to.no-scrollbarutility.The
.no-scrollbarrules have been reformatted within the@layer utilitiesblock. These changes preserve the existing functionality and behavior. The logic remains unchanged.
| <Popover open={open} onOpenChange={setOpen}> | ||
| <PopoverTrigger asChild> | ||
| <Button variant="ghost" size="sm"> | ||
| <MoreVertical className="h-4 w-4" /> | ||
| </Button> | ||
| </PopoverTrigger> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a11y labels to the icon-only “Actions” trigger button.
MoreVertical without text should have aria-label (and ideally title).
- <Button variant="ghost" size="sm">
+ <Button variant="ghost" size="sm" aria-label="Webhook actions" title="Webhook actions">
<MoreVertical className="h-4 w-4" />
</Button>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <Popover open={open} onOpenChange={setOpen}> | |
| <PopoverTrigger asChild> | |
| <Button variant="ghost" size="sm"> | |
| <MoreVertical className="h-4 w-4" /> | |
| </Button> | |
| </PopoverTrigger> | |
| <Popover open={open} onOpenChange={setOpen}> | |
| <PopoverTrigger asChild> | |
| <Button variant="ghost" size="sm" aria-label="Webhook actions" title="Webhook actions"> | |
| <MoreVertical className="h-4 w-4" /> | |
| </Button> | |
| </PopoverTrigger> |
🤖 Prompt for AI Agents
In apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx around lines 180 to
185, the icon-only "Actions" Popover trigger lacks accessible labeling; add an
appropriate aria-label (e.g., "Actions") and a title attribute to the Button
(the PopoverTrigger asChild wrapping it) so screen readers and tooltips get
meaningful text; ensure attributes are placed on the actual button element
(since asChild passes props) and keep the label concise and localized if
applicable.
| .dark .shiki, | ||
| .dark .shiki span { | ||
| color: var(--shiki-dark) !important; | ||
| background-color: var(--shiki-dark-bg) !important; | ||
| /* Optional, if you also want font styles */ | ||
| font-weight: var(--shiki-dark-font-weight) !important; | ||
| text-decoration: var(--shiki-dark-text-decoration) !important; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
rg -i 'shiki-dark' --type css --type scss --type ts --type tsx --type js --type jsxRepository: usesend/useSend
Length of output: 87
🏁 Script executed:
# Also search for where these variables might be defined or set
rg '(--shiki-dark|shiki.*dark)' -g '!node_modules' -A 2 -B 2Repository: usesend/useSend
Length of output: 635
🏁 Script executed:
# Check for Shiki configuration files or setup
fd -e 'shiki' -o -e 'shiki.config' -o -e 'shiki.ts' -o -e 'shiki.js'Repository: usesend/useSend
Length of output: 175
🏁 Script executed:
# Look at the full globals.css file to understand context
wc -l packages/ui/styles/globals.cssRepository: usesend/useSend
Length of output: 93
🏁 Script executed:
# Check if there's a parent theme file or CSS setup
fd -e 'theme' -o -e 'colors' -o -e 'variables' | grep -E '\.(css|scss|ts|tsx)$' | head -20Repository: usesend/useSend
Length of output: 175
🏁 Script executed:
# Search for Shiki in package.json and other config files
rg 'shiki' package.json pnpm-lock.yaml yarn.lock 2>/dev/null | head -20Repository: usesend/useSend
Length of output: 991
🏁 Script executed:
# Search for Shiki component files or setup
rg 'shiki' --type ts --type tsx --type js --type jsx -l | head -20Repository: usesend/useSend
Length of output: 86
🏁 Script executed:
# Look for CSS variable definitions or :root declarations
rg '(--shiki|:root|--color|--bg)' packages/ui/styles/globals.css -B 1 -A 1 | head -40Repository: usesend/useSend
Length of output: 378
🏁 Script executed:
# Check if there's a Shiki configuration in app or components
fd 'shiki' -e ts -e tsx -e js -e jsx packages/ 2>/dev/null | head -20Repository: usesend/useSend
Length of output: 41
🏁 Script executed:
# Search for where .dark class is being set
rg '\.dark' --type ts --type tsx --type js --type jsx -l | head -10Repository: usesend/useSend
Length of output: 86
🏁 Script executed:
# Show the :root and .light sections of globals.css to see what variables are defined
cat -n packages/ui/styles/globals.css | head -60Repository: usesend/useSend
Length of output: 1879
🏁 Script executed:
# Search for any reference to shiki variables or theme config
rg 'shiki' packages/ -g '!node_modules' -A 3 -B 3Repository: usesend/useSend
Length of output: 2198
🏁 Script executed:
# Check if Shiki outputs inline styles with these variables
rg 'style=' packages/ -g '!node_modules' -lRepository: usesend/useSend
Length of output: 423
🏁 Script executed:
# Check the code-block.tsx to understand how Shiki is actually used
cat -n packages/ui/src/code-block.tsx | head -80Repository: usesend/useSend
Length of output: 1137
🏁 Script executed:
# Verify that these CSS rules have no actual effect by checking Shiki's actual output
# Look for any test or example files that show what Shiki actually generates
fd 'shiki\|code-block' packages/ui -g '*.test.*' -o -g '*.spec.*' 2>/dev/nullRepository: usesend/useSend
Length of output: 41
🏁 Script executed:
# Check if there are any other Shiki configuration files
rg 'catppuccin' packages/ -g '!node_modules' -B 2 -A 2Repository: usesend/useSend
Length of output: 358
Remove or fix the undefined Shiki dark mode CSS variables.
The CSS rules for .dark .shiki reference undefined custom properties (--shiki-dark, --shiki-dark-bg, --shiki-dark-font-weight, --shiki-dark-text-decoration) that don't exist anywhere in the codebase. These variables have no effect on styling.
Since your CodeBlock component already uses Shiki with explicit themes (catppuccin-mocha for dark and catppuccin-latte for light), which generate inline styles in the HTML, these CSS rules are unnecessary. Either remove them or replace with actual theme-specific CSS selectors that work with Shiki's generated HTML structure.
🤖 Prompt for AI Agents
packages/ui/styles/globals.css lines 148-155: the .dark .shiki CSS uses
undefined custom properties (--shiki-dark, --shiki-dark-bg,
--shiki-dark-font-weight, --shiki-dark-text-decoration) so it has no effect;
either remove this entire rule block, or replace it with selectors that match
Shiki's actual generated HTML/CSS (e.g., target the token classes or
theme-specific container classes used by the CodeBlock output), or alternatively
define those CSS variables in :root and .dark with concrete values; pick one
approach and update the file accordingly so the styles are either removed or
wired to real selectors/values.
stuff webhook call changes more stuff stuff stuff more webhook stuff add pnpm more ui stuff stuff sdk stuff stuff
3c077b3 to
b4e1dfc
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/src/app/(dashboard)/emails/email-details.tsx (1)
18-22: Fix @usesend/lib package.json main entry point to resolve import inconsistencies.The import path
@usesend/lib/src/constants/ses-errorsdirectly accesses the internal/src/directory because the package's main entry point is misconfigured. Thepackages/lib/package.jsoncurrently specifies"main": "./index.ts"(empty file), but should point to"main": "./src/index.ts"where the actual exports are defined. This would allow cleaner imports as@usesend/lib(consistent with the pattern inapps/web/src/server/api/routers/email.ts) and eliminate direct/src/access across the codebase (currently used in 9 files). Update the package.json main field to properly expose the package entry point.
♻️ Duplicate comments (22)
packages/sdk/package.json (1)
27-31: SDK publish will break:@usesend/libcannot be aworkspace:*runtime dependencyThis repeats the earlier concern: when
usesend-jsis published, npm consumers can’t resolve a workspace-only/private@usesend/lib, andtsupwill externalize it by default—so runtime imports will fail unless you bundle it or publish@usesend/libas a real package."dependencies": { - "@usesend/lib": "workspace:*", + "@usesend/lib": "workspace:*", "@react-email/render": "^1.0.6", "react": "^19.1.0" }Verification (choose one approach):
- Bundle it into the SDK (and move it to
devDependencies) by adding atsup.config.tswithnoExternal: ['@usesend/lib'], then update the build script to use the config.- Publish
@usesend/lib(removeprivate, emitdist, include it infiles, and depend on a real semver instead ofworkspace:*).To confirm the current state quickly:
#!/bin/bash set -euo pipefail echo "=== packages/lib/package.json ===" cat packages/lib/package.json || true echo "=== SDK tsup config / externalization hints ===" rg -n --hidden -S --glob='!**/node_modules/**' 'noExternal|external|tsup\.config' packages/sdk || truepackages/ui/styles/globals.css (1)
148-155: The undefined CSS variables issue remains unaddressed.As noted in the previous review, these CSS rules reference undefined custom properties (
--shiki-dark,--shiki-dark-bg,--shiki-dark-font-weight,--shiki-dark-text-decoration) that don't exist in the codebase. Since the CodeBlock and CodeDisplay components use Shiki with explicit theme configurations that generate inline styles, these CSS rules have no effect.apps/web/src/components/code-display.tsx (1)
44-49: Add fallback for failed highlighting.As previously noted, when highlighting fails, users see an empty bordered box instead of the original code. The loading state already shows plain code, so the error case should do the same.
apps/web/src/server/api/routers/webhook.ts (2)
90-97: Service layer should validate webhook status before creating test calls.The router delegates to
WebhookService.testWebhook, but based on past review comments, the service method may not validate webhook status (ACTIVE vs DELETED/AUTO_DISABLED) before creating test calls. While deleted webhooks are eventually discarded during async processing, the test call is still created and queued unnecessarily.Run the following script to verify if status validation exists in the service:
#!/bin/bash # Check if testWebhook validates webhook status before creating test calls rg -n -A 20 'testWebhook.*\{' apps/web/src/server/service/webhook-service.ts | head -30
23-41: Potential TOCTOU race in webhook limit enforcement.There's a time-of-check to time-of-use race between
checkWebhookLimit(executed in the service layer) and webhook creation. Two concurrent requests could both pass the limit check and exceed the plan limit.Given the low webhook limits (Free=1, Basic=unlimited per PR description) and the nature of user-initiated operations, this is unlikely to be exploited in practice.
If stricter enforcement is needed, consider using a database transaction with a subquery or a unique partial index.
apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (1)
77-104: Makewebhook.eventTypesnull-safe (UI can crash if DB allows NULL).
(webhook.eventTypes as WebhookEventType[]).lengthwill throw at runtime ifeventTypesisnull. This occurs in multiple places (lines 78, 95, 100).Apply this diff:
+ const webhookEventTypes = (webhook.eventTypes ?? []) as WebhookEventType[]; const initialHasAllEvents = - (webhook.eventTypes as WebhookEventType[]).length === 0; + webhookEventTypes.length === 0; const [allEventsSelected, setAllEventsSelected] = useState(initialHasAllEvents); const form = useForm<EditWebhookFormValues>({ resolver: zodResolver(editWebhookSchema), defaultValues: { url: webhook.url, eventTypes: initialHasAllEvents ? [] - : (webhook.eventTypes as WebhookEventType[]), + : webhookEventTypes, }, }); useEffect(() => { if (open) { + const webhookEventTypes = (webhook.eventTypes ?? []) as WebhookEventType[]; const hasAllEvents = - (webhook.eventTypes as WebhookEventType[]).length === 0; + webhookEventTypes.length === 0; form.reset({ url: webhook.url, eventTypes: hasAllEvents ? [] - : (webhook.eventTypes as WebhookEventType[]), + : webhookEventTypes, }); setAllEventsSelected(hasAllEvents); } }, [open, webhook, form]);apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
26-39: UsemutateAsyncfor proper async handling.The
onConfirmfunction is declaredasyncbut usesmutate()which is fire-and-forget. This means the function returns immediately without waiting for the mutation to complete, potentially causing theisLoadingstate to not work correctly with theDeleteResourcecomponent.Apply this diff:
async function onConfirm(values: z.infer<typeof schema>) { - deleteWebhookMutation.mutate( - { id: webhook.id }, - { - onSuccess: async () => { - await utils.webhook.list.invalidate(); - toast.success("Webhook deleted"); - }, - onError: (error) => { - toast.error(error.message); - }, - }, - ); + try { + await deleteWebhookMutation.mutateAsync({ id: webhook.id }); + await utils.webhook.list.invalidate(); + toast.success("Webhook deleted"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to delete webhook"); + } }apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (3)
11-11: FixSpinnerimport (likely a compile/runtime error).
@usesend/ui/src/spinnerlikely exportsSpinneras a named export, not default. This would cause a TypeScript or runtime error.Apply this diff:
-import Spinner from "@usesend/ui/src/spinner"; +import { Spinner } from "@usesend/ui/src/spinner";
89-102: IncorrectcolSpanvalue.The table has 5 columns (URL, Status, Last success, Last failure, Actions), but
colSpan={6}is used for the loading and empty states.Apply this diff:
<TableRow className="h-32"> - <TableCell colSpan={6} className="py-4 text-center"> + <TableCell colSpan={5} className="py-4 text-center"> <Spinner className="mx-auto h-6 w-6" innerSvgClass="stroke-primary" /> </TableCell> </TableRow> ) : webhooks.length === 0 ? ( <TableRow className="h-32"> - <TableCell colSpan={6} className="py-4 text-center"> + <TableCell colSpan={5} className="py-4 text-center"> <p>No webhooks configured</p> </TableCell> </TableRow>
180-185: Add accessibility labels to the icon-only button.The
MoreVerticalicon button lacks accessible labeling for screen readers.Apply this diff:
<PopoverTrigger asChild> - <Button variant="ghost" size="sm"> + <Button variant="ghost" size="sm" aria-label="Webhook actions" title="Webhook actions"> <MoreVertical className="h-4 w-4" /> </Button> </PopoverTrigger>apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx (1)
8-32: Add an exhaustiveness guard (future enum values silently fall back).
Right now a newWebhookCallStatusvalue will render as raw enum text with the default gray styling.switch (status) { case WebhookCallStatus.DELIVERED: badgeColor = "bg-green/15 text-green border border-green/20"; label = "Delivered"; break; @@ case WebhookCallStatus.DISCARDED: badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; label = "Discarded"; break; + default: { + const _exhaustive: never = status; + badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; + label = "Unknown"; + break; + } }apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx (1)
179-195: Fix status typing + explicitly decide AUTO_DISABLED semantics.
handleToggleStatus(currentStatus: string)(Line 179) defeats enum typing;newStatusbecomesstringand may not match the TRPC input type. Also: the UI blocks toggling forAUTO_DISABLED, which may prevent recovery if manual re-enable is intended.- const handleToggleStatus = (currentStatus: string) => { - const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE"; + const handleToggleStatus = (currentStatus: Webhook["status"]) => { + const newStatus: Webhook["status"] = + currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE"; setStatusMutation.mutate( { id: webhookId, status: newStatus }, {If you want to allow recovery from auto-disable, handle it explicitly:
- const newStatus: Webhook["status"] = - currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE"; + const newStatus: Webhook["status"] = + currentStatus === "AUTO_DISABLED" + ? "ACTIVE" + : currentStatus === "ACTIVE" + ? "PAUSED" + : "ACTIVE";Also applies to: 249-258
apps/web/prisma/schema.prisma (1)
449-484: Do not store webhook secrets in plaintext.
Webhook.secret String(Line 469) is directly storing signing secrets in the DB.At minimum: store ciphertext + key metadata (KMS/Vault-backed) and only decrypt in-memory for signing.
apps/web/src/server/service/ses-hook-parser.ts (1)
397-428: Don’t map unknown statuses toemail.queuedby default.
The default (Line 425-427) can mislabel statuses likeEmailStatus.SCHEDULED(and any future additions).function emailStatusToEvent(status: EmailStatus): EmailWebhookEventType { switch (status) { + case EmailStatus.SCHEDULED: + // If you don't want to emit, consider throwing/logging instead. + return "email.queued"; @@ default: - return "email.queued"; + logger.warn({ status }, "[SesHookParser]: Unexpected email status for webhook event"); + return "email.queued"; } }apps/web/src/server/service/contact-service.ts (1)
80-107: Enforce team scoping on update/delete (cross-tenant mutation risk).
On Line 85 and Line 98, mutations are scoped only byid, so a caller with another team’scontactIdcan update/delete it.One safe pattern is: require
teamId(and ideallycontactBookId) and verify ownership before mutating.-export async function updateContact( - contactId: string, - contact: Partial<ContactInput>, - teamId?: number, -) { +export async function updateContact( + contactId: string, + contact: Partial<ContactInput>, + teamId: number, +) { + const existing = await db.contact.findFirst({ + where: { id: contactId, contactBook: { teamId } }, + }); + if (!existing) throw new Error("Contact not found"); const updatedContact = await db.contact.update({ - where: { id: contactId }, + where: { id: contactId }, data: contact, });Apply the same approach to
deleteContact.apps/web/src/server/service/webhook-service.ts (7)
21-21: Remove unused import.The
logimport from"console"is never used in this file.-import { log } from "console";
43-84: Worker starts on module import — ensure this is intentional.The
Workeris instantiated as a static field, meaning it starts when this module is imported. If imported by the web/API server, you'll run dispatch workers there unintentionally. Consider guarding behind an env flag or exporting astartWebhookWorker()function called only from the dedicated worker entrypoint.
341-370: Cursor pagination may duplicate or skip items.Using
cursor: { id }withorderBy: { createdAt: "desc" }withoutskip: 1and a secondary sort key can cause inconsistent pagination.
449-460: Lock contention throws to trigger BullMQ retry — may mismatchnextAttemptAt.When the lock isn't acquired,
nextAttemptAtis set but BullMQ controls actual retry timing via its own backoff. Consider letting BullMQ handle retry timing without updatingnextAttemptAt, or re-enqueue with a custom delay instead of throwing.
512-524: StaleconsecutiveFailuresin auto-disable check.The check uses
call.webhook.consecutiveFailures + 1, but the actual value is incremented atomically in the database. Under concurrent failures, the local value may be stale, potentially delaying auto-disable.
512-540: Failure path updates are not transactional.The webhook update (line 512) and webhookCall update (line 526) are separate operations. If one fails, the counters and call record may become inconsistent. Consider wrapping in
db.$transaction([...]).
780-806: FlushTextDecoderwhen stream ends to avoid losing partial multi-byte characters.When using
decoder.decode(value, { stream: true }), you need a finaldecoder.decode()call (without arguments) after the loop to flush any remaining bytes from incomplete multi-byte sequences.while (true) { const { done, value } = await reader.read(); if (done) { + // Flush any remaining bytes + const remaining = decoder.decode(); + if (remaining && !truncated) { + chunks += remaining; + } break; } // ... rest of loop }
🧹 Nitpick comments (6)
apps/web/src/app/(dashboard)/dev-settings/layout.tsx (1)
1-1: Consider removing the conflictingdynamicexport.The
"use client"directive on line 1 makes this a Client Component, which means it's always rendered dynamically. Theexport const dynamic = "force-static"on line 6 has no effect and creates confusion.Apply this diff to remove the unused export:
"use client"; import { H1 } from "@usesend/ui"; import { SettingsNavButton } from "./settings-nav-button"; -export const dynamic = "force-static"; - export default function ApiKeysPage({Also applies to: 6-6
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx (1)
9-25: Remove unnecessary async modifier.The function is marked
asyncbut doesn't contain anyawaitstatements or asynchronous operations. The JSON parsing and stringification are synchronous.Apply this diff:
-export async function WebhookPayloadDisplay({ +export function WebhookPayloadDisplay({ payload, title, lang = "json", }: WebhookPayloadDisplayProps) {apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (1)
170-246: Consider extracting the event multi-select logic (duplicated vs Edit dialog).
This selection/toggling logic is fairly intricate and appears in both “create” and “edit” flows; extracting a shared helper/hook reduces drift and future bugs.Also applies to: 248-316
apps/web/prisma/schema.prisma (1)
486-506: Consider usingJson(orBytes) forpayloadto avoid stringifying contracts everywhere.
WebhookCall.payload String(Line 491) forces string handling and makes it easier to accidentally double-encode JSON. If you intend to store structured JSON,Jsonis typically safer.apps/web/src/server/service/domain-service.ts (1)
334-358: Prefer emitting delete payload from the deleted DB record.
On Line 355, you emit using the pre-deletedomainobject; usingdeletedRecordreduces the chance of staleupdatedAt(or other drift).- await emitDomainEvent(domain, "domain.deleted"); + await emitDomainEvent(deletedRecord, "domain.deleted");packages/sdk/src/webhooks.ts (1)
118-137: Consider validatingX-UseSend-Event/X-UseSend-Callagainst the parsed body (defense-in-depth).
You already define the header constants, butconstructEventignores them. If those headers are present, checking consistency helps catch misrouted requests or unexpected middleware behavior.Also applies to: 232-261
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (52)
apps/web/package.json(1 hunks)apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql(1 hunks)apps/web/prisma/schema.prisma(3 hunks)apps/web/src/app/(dashboard)/dev-settings/layout.tsx(2 hunks)apps/web/src/app/(dashboard)/emails/email-details.tsx(2 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/page.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsx(1 hunks)apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx(1 hunks)apps/web/src/components/AppSideBar.tsx(2 hunks)apps/web/src/components/code-display.tsx(1 hunks)apps/web/src/lib/constants/plans.ts(3 hunks)apps/web/src/server/api/root.ts(2 hunks)apps/web/src/server/api/routers/contacts.ts(1 hunks)apps/web/src/server/api/routers/email.ts(2 hunks)apps/web/src/server/api/routers/limits.ts(2 hunks)apps/web/src/server/api/routers/webhook.ts(1 hunks)apps/web/src/server/jobs/webhook-cleanup-job.ts(1 hunks)apps/web/src/server/public-api/api/contacts/add-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/delete-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/update-contact.ts(1 hunks)apps/web/src/server/public-api/api/contacts/upsert-contact.ts(1 hunks)apps/web/src/server/queue/queue-constants.ts(1 hunks)apps/web/src/server/service/contact-queue-service.ts(1 hunks)apps/web/src/server/service/contact-service.ts(5 hunks)apps/web/src/server/service/domain-service.ts(12 hunks)apps/web/src/server/service/limit-service.ts(1 hunks)apps/web/src/server/service/ses-hook-parser.ts(3 hunks)apps/web/src/server/service/webhook-service.ts(1 hunks)apps/web/tailwind.config.ts(1 hunks)docker/dev/compose.yml(1 hunks)packages/lib/.eslintrc.cjs(1 hunks)packages/lib/package.json(1 hunks)packages/lib/src/index.ts(1 hunks)packages/lib/src/webhook/webhook-events.ts(1 hunks)packages/lib/tsconfig.json(1 hunks)packages/lib/tsconfig.lint.json(1 hunks)packages/sdk/README.md(1 hunks)packages/sdk/index.ts(1 hunks)packages/sdk/package.json(1 hunks)packages/sdk/src/usesend.ts(2 hunks)packages/sdk/src/webhooks.ts(1 hunks)packages/ui/src/dropdown-menu.tsx(7 hunks)packages/ui/styles/globals.css(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (27)
- apps/web/src/server/api/routers/contacts.ts
- packages/sdk/README.md
- packages/lib/tsconfig.lint.json
- packages/lib/package.json
- apps/web/src/components/AppSideBar.tsx
- apps/web/src/server/jobs/webhook-cleanup-job.ts
- apps/web/src/lib/constants/plans.ts
- apps/web/src/server/public-api/api/contacts/add-contact.ts
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx
- apps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsx
- apps/web/src/app/(dashboard)/webhooks/page.tsx
- packages/lib/src/index.ts
- apps/web/src/server/api/routers/email.ts
- apps/web/src/server/service/contact-queue-service.ts
- docker/dev/compose.yml
- packages/sdk/index.ts
- apps/web/src/server/public-api/api/contacts/upsert-contact.ts
- apps/web/src/server/api/root.ts
- apps/web/tailwind.config.ts
- apps/web/src/server/service/limit-service.ts
- packages/lib/tsconfig.json
- apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql
- apps/web/src/server/public-api/api/contacts/delete-contact.ts
- packages/ui/src/dropdown-menu.tsx
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx
- apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx
- apps/web/package.json
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}
📄 CodeRabbit inference engine (.cursor/rules/general.mdc)
Include all required imports and ensure proper naming of key components in React/NextJS code
Files:
apps/web/src/app/(dashboard)/emails/email-details.tsxpackages/sdk/src/usesend.tsapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/api/routers/limits.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/server/queue/queue-constants.tsapps/web/src/server/service/contact-service.tspackages/sdk/src/webhooks.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/webhook-service.tsapps/web/src/server/service/domain-service.tspackages/lib/src/webhook/webhook-events.tsapps/web/src/components/code-display.tsx
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs
Files:
apps/web/src/app/(dashboard)/emails/email-details.tsxpackages/sdk/src/usesend.tsapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/api/routers/limits.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/server/queue/queue-constants.tsapps/web/src/server/service/contact-service.tspackages/sdk/src/webhooks.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/webhook-service.tsapps/web/src/server/service/domain-service.tspackages/lib/src/webhook/webhook-events.tsapps/web/src/components/code-display.tsx
**/*.{tsx,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
React components must use PascalCase naming convention (e.g., AppSideBar.tsx)
Files:
apps/web/src/app/(dashboard)/emails/email-details.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/components/code-display.tsx
apps/web/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use alias
~/for src imports in apps/web (e.g.,import { x } from "~/utils/x")
Files:
apps/web/src/app/(dashboard)/emails/email-details.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/api/routers/limits.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/server/queue/queue-constants.tsapps/web/src/server/service/contact-service.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/webhook-service.tsapps/web/src/server/service/domain-service.tsapps/web/src/components/code-display.tsx
apps/web/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web
Files:
apps/web/src/app/(dashboard)/emails/email-details.tsxapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/api/routers/limits.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/server/queue/queue-constants.tsapps/web/src/server/service/contact-service.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/webhook-service.tsapps/web/src/server/service/domain-service.tsapps/web/src/components/code-display.tsx
**/*.{ts,tsx,md}
📄 CodeRabbit inference engine (AGENTS.md)
Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files
Files:
apps/web/src/app/(dashboard)/emails/email-details.tsxpackages/sdk/src/usesend.tsapps/web/src/app/(dashboard)/webhooks/delete-webhook.tsxapps/web/src/server/public-api/api/contacts/update-contact.tsapps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsxapps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/server/service/ses-hook-parser.tsapps/web/src/server/api/routers/limits.tsapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/server/api/routers/webhook.tsapps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsxapps/web/src/server/queue/queue-constants.tsapps/web/src/server/service/contact-service.tspackages/sdk/src/webhooks.tsapps/web/src/app/(dashboard)/webhooks/add-webhook.tsxapps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsxapps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsxapps/web/src/server/service/webhook-service.tsapps/web/src/server/service/domain-service.tspackages/lib/src/webhook/webhook-events.tsapps/web/src/components/code-display.tsx
🧠 Learnings (5)
📚 Learning: 2025-11-28T21:13:56.758Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: .cursor/rules/general.mdc:0-0
Timestamp: 2025-11-28T21:13:56.758Z
Learning: Applies to **/*.{tsx,ts,jsx,js} : Include all required imports and ensure proper naming of key components in React/NextJS code
Applied to files:
apps/web/src/app/(dashboard)/dev-settings/layout.tsxapps/web/src/app/(dashboard)/webhooks/webhook-list.tsxapps/web/src/components/code-display.tsx
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Never use dynamic imports; always import on the top level
Applied to files:
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to apps/web/**/*.{ts,tsx} : Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Applied to files:
apps/web/src/server/api/routers/webhook.ts
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Run ESLint via usesend/eslint-config and ensure no warnings remain before submitting PRs
Applied to files:
packages/lib/.eslintrc.cjs
📚 Learning: 2025-11-28T21:14:07.734Z
Learnt from: CR
Repo: usesend/useSend PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:14:07.734Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Applied to files:
packages/lib/.eslintrc.cjs
🧬 Code graph analysis (10)
apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
apps/web/src/components/DeleteResource.tsx (1)
DeleteResource(63-199)
apps/web/src/server/public-api/api/contacts/update-contact.ts (1)
apps/web/src/server/service/contact-service.ts (1)
updateContact(80-95)
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (3)
apps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsx (1)
WebhookStatusBadge(1-23)apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (1)
EditWebhookDialog(66-323)apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
DeleteWebhook(11-61)
apps/web/src/server/api/routers/webhook.ts (3)
packages/lib/src/webhook/webhook-events.ts (1)
WebhookEvents(36-40)apps/web/src/server/api/trpc.ts (2)
createTRPCRouter(82-82)teamProcedure(127-152)apps/web/src/server/service/webhook-service.ts (1)
WebhookService(85-385)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx (6)
apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx (1)
DeleteWebhook(11-61)apps/web/src/server/service/webhook-service.ts (2)
testWebhook(163-195)updateWebhook(257-294)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx (1)
WebhookInfo(14-116)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx (1)
WebhookCallsTable(25-134)apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx (1)
WebhookCallDetails(13-158)apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (1)
EditWebhookDialog(66-323)
apps/web/src/server/service/contact-service.ts (4)
apps/web/src/server/db.ts (1)
db(20-20)packages/lib/src/webhook/webhook-events.ts (2)
ContactWebhookEventType(7-7)ContactPayload(74-84)apps/web/src/server/logger/log.ts (1)
logger(31-63)apps/web/src/server/service/webhook-service.ts (1)
WebhookService(85-385)
packages/sdk/src/webhooks.ts (1)
packages/lib/src/webhook/webhook-events.ts (1)
WebhookEventData(194-196)
apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx (1)
packages/ui/src/code-block.tsx (1)
CodeBlock(13-36)
apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx (9)
packages/sdk/index.ts (1)
WebhookEventType(16-16)packages/sdk/src/webhooks.ts (1)
WebhookEventType(278-278)apps/web/src/server/service/webhook-service.ts (1)
updateWebhook(257-294)packages/ui/src/toaster.tsx (1)
toast(31-31)packages/ui/src/dialog.tsx (4)
Dialog(113-113)DialogContent(118-118)DialogHeader(119-119)DialogTitle(121-121)packages/ui/src/form.tsx (6)
Form(170-170)FormField(176-176)FormItem(171-171)FormLabel(172-172)FormControl(173-173)FormMessage(175-175)packages/ui/src/input.tsx (1)
Input(25-25)packages/ui/src/dropdown-menu.tsx (5)
DropdownMenu(185-185)DropdownMenuTrigger(186-186)DropdownMenuContent(187-187)DropdownMenuCheckboxItem(189-189)DropdownMenuSeparator(192-192)packages/ui/src/button.tsx (1)
Button(80-80)
apps/web/src/server/service/webhook-service.ts (6)
apps/web/src/server/queue/bullmq-context.ts (2)
TeamJob(5-5)createWorkerHandler(10-24)packages/lib/src/webhook/webhook-events.ts (2)
WebhookEventType(42-42)WebhookPayloadData(184-185)apps/web/src/server/queue/queue-constants.ts (1)
WEBHOOK_DISPATCH_QUEUE(6-6)apps/web/src/server/redis.ts (1)
getRedis(6-13)apps/web/src/server/public-api/api-error.ts (1)
UnsendApiError(62-75)apps/web/src/server/service/limit-service.ts (1)
LimitService(19-280)
🪛 ast-grep (0.40.0)
apps/web/src/components/code-display.tsx
[warning] 106-106: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.1.2)
apps/web/src/components/code-display.tsx
[error] 107-107: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cloudflare Pages
🔇 Additional comments (45)
packages/lib/.eslintrc.cjs (1)
1-10: Configuration is well-formed and all dependencies are in place.The ESLint setup correctly extends
@usesend/eslint-config/library.js, includes the required dependencies (eslint,@usesend/eslint-config), and references an existingtsconfig.lint.json. This follows the project's TypeScript-first approach and monorepo best practices.apps/web/src/app/(dashboard)/dev-settings/layout.tsx (1)
3-3: LGTM! Clean UI component migration.The import and usage of the
H1component from@usesend/uifollows the coding guidelines and improves consistency. The text capitalization change to "Developer Settings" also improves readability.Also applies to: 15-15
packages/ui/styles/globals.css (1)
159-165: LGTM! Formatting improved for readability.The expanded formatting of the no-scrollbar utility rules improves readability while preserving the same runtime behavior.
apps/web/src/components/code-display.tsx (5)
1-13: LGTM! Clean imports and well-typed interface.The imports include all necessary dependencies, and the
CodeDisplayPropsinterface is properly typed with sensible defaults.
15-24: LGTM! State management is appropriate.The component state (HTML output, loading status, and copy feedback) is properly initialized for the async highlighting workflow.
25-43: LGTM! Proper async highlighting with mount guard.The effect correctly uses an
isMountedflag to prevent state updates on unmounted components, and the Shiki configuration with dual themes is appropriate.
59-92: LGTM! Copy functionality and loading state are well implemented.The copy handler provides clear visual feedback, and the loading state appropriately displays the raw code while highlighting is in progress.
94-110: LGTM! Safe use of dangerouslySetInnerHTML with Shiki.Using
dangerouslySetInnerHTMLwith Shiki's output is safe because Shiki is a trusted syntax highlighting library that generates sanitized HTML. The library properly escapes any content in the code strings to prevent XSS attacks.apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx (3)
1-7: LGTM! Clean imports and well-defined props.The interface clearly defines the component's contract with appropriate types and defaults.
16-25: LGTM! Robust JSON handling with graceful fallback.The try-catch ensures that invalid JSON doesn't break the component, falling back to displaying the raw payload.
27-36: LGTM! Clean layout with proper CodeBlock usage.The component correctly uses the async
CodeBlockServer Component from the UI package. In Next.js App Router, async components in JSX are automatically handled by React.apps/web/src/server/api/routers/limits.ts (1)
21-22: LGTM!The webhook limit handling integrates correctly with the existing limits router pattern and delegates to the appropriate service method.
apps/web/src/server/public-api/api/contacts/update-contact.ts (1)
59-63: LGTM!The addition of
team.idparameter correctly enables webhook event emission for contact updates, consistent with the service layer signature.apps/web/src/server/queue/queue-constants.ts (1)
6-7: LGTM!The webhook queue constants follow consistent naming conventions and align with the new webhook dispatch and cleanup infrastructure.
packages/sdk/src/usesend.ts (1)
175-196: LGTM!The webhook handler method follows the Stripe pattern and includes clear documentation with usage examples. The API design is intuitive and consistent with industry standards.
apps/web/src/server/api/routers/webhook.ts (9)
1-8: LGTM!The imports and enum definition are correct.
WebhookEventsis properly imported from@usesend/liband used to create a Zod enum for validation.
10-12: LGTM!The list endpoint correctly delegates to the service layer with team-scoped access.
14-21: LGTM!The getById endpoint appropriately validates input and enforces team ownership.
43-64: LGTM!The update endpoint correctly handles optional fields and delegates to the service layer with proper validation.
66-79: LGTM!The setStatus endpoint appropriately validates the status enum and enforces team ownership.
81-88: LGTM!The delete endpoint correctly validates input and enforces team-scoped deletion.
99-116: LGTM!The listCalls endpoint provides appropriate pagination controls with sensible defaults and limits.
118-125: LGTM!The getCall endpoint correctly enforces team-scoped access to webhook call records.
127-134: LGTM!The retryCall endpoint appropriately delegates to the service layer with team ownership validation.
apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx (4)
29-52: LGTM!The webhook query and test handler are correctly implemented with proper error handling and cache invalidation.
54-70: LGTM!The status toggle handler correctly switches between ACTIVE and PAUSED states with appropriate user feedback.
104-162: LGTM!The webhook table rendering logic correctly handles loading, empty, and populated states with appropriate navigation and action integration.
164-227: LGTM!The actions menu appropriately handles Edit, Pause/Resume, and Delete operations with correct state management and popover control.
apps/web/src/server/service/ses-hook-parser.ts (1)
279-303: Webhook emission placement looks fine; keep it non-blocking.
The try/catch aroundWebhookService.emit(...)ensures SES hook processing isn’t broken by downstream delivery issues.apps/web/src/server/service/domain-service.ts (1)
90-106: Domain webhook emission helpers look good (typed payload + non-blocking emit).
buildDomainPayload+emitDomainEventis a clean pattern and keeps webhook failures from impacting domain operations.Also applies to: 387-396
apps/web/src/server/service/contact-service.ts (1)
19-78: Upsert flow + event typing is solid.
Deriving"contact.created"vs"contact.updated"based on existence and emitting best-effort is a good fit for this service.packages/sdk/src/webhooks.ts (1)
45-199: Header names and timestamp unit are correctly aligned between SDK and server.Verification confirms the implementation is correct:
- Server (webhook-service.ts:676-677) sends
Date.now().toString()(milliseconds) and usessignBody()with HMAC-SHA256- SDK (webhooks.ts:170-186) receives the timestamp and compares directly with
Date.now()using a 5-minute tolerance- All header names (
X-UseSend-Signature,X-UseSend-Timestamp, etc.) match exactly between server and SDK- Signature computation is identical:
v1=+ hex(HMAC_SHA256(secret,${timestamp}.${body}))No action required.
apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx (1)
44-64: Document the empty eventTypes array semantics explicitly.The code at line 108 sends
eventTypes: allEventsSelected ? [] : selectedEvents, which is correctly stored asnullin the database via the create/update procedures (events: input.eventTypes.length === 0 ? null : input.eventTypes). However, this transformation should be documented or explicitly validated in the schema to make the "all events" contract unambiguous. Consider adding a Zod refinement or comment explaining that an empty array means "subscribe to all events" to prevent future confusion.apps/web/src/server/service/webhook-service.ts (8)
23-31: LGTM!Constants are well-defined with sensible defaults for webhook delivery (concurrency, backoff, timeouts, limits).
86-134: LGTM!The
emitmethod correctly queries webhooks by event type (or empty eventTypes as a catch-all), creates pending call records, and enqueues them. The serial loop is acceptable for typical webhook counts per team.
197-339: LGTM!CRUD methods are well-implemented with proper team scoping, limit enforcement, and error handling. The
setWebhookStatuscorrectly resetsconsecutiveFailureswhen activating a webhook.
570-602: LGTM!Lock acquisition uses proper
SET NX PXpattern, release uses a Lua script to ensure only the lock owner can release, and backoff includes jitter to prevent thundering herd.
614-642: LGTM!The
buildPayloadfunction safely parses the stored JSON payload with a fallback, and constructs a well-structured envelope including version and attempt tracking.
662-738: LGTM!The
postWebhookfunction properly handles timeouts viaAbortController, signs requests with HMAC, and classifies errors appropriately. Usingredirect: "manual"prevents following redirects, which is a reasonable security choice for webhooks.
740-744: LGTM!Standard HMAC-SHA256 signature with
timestamp.bodyformat andv1=prefix, following common webhook signing conventions.
181-190: No changes needed —"webhook.test"is a special internal test type intentionally separate fromWebhookEvents.The test webhook uses
type: "webhook.test"which is not part of theWebhookEventsconstant. This is by design: test calls are internal and bypass webhook event subscription filtering (unlike regular events emitted viaWebhookService.emit()). Thetypefield in the database is stored as a plain string with no validation constraints, and the test type is used for logging and identification only. There is no validation failure risk.packages/lib/src/webhook/webhook-events.ts (4)
1-42: LGTM!Well-structured event constants using
as constfor both runtime values and type extraction. The aggregatedWebhookEventsarray provides a single source of truth.
44-58: LGTM!The
EmailStatustype covers all email lifecycle states. Note thatSCHEDULEDis a status without a corresponding webhook event, which is reasonable as it represents an internal state before any external action.
60-100: LGTM!Payload types are well-defined with appropriate optional fields. The type variations (e.g.,
DomainPayload.idasnumbervs others asstring) correctly reflect the underlying data model.
132-149: Consider privacy implications of IP addresses in webhook payloads.
EmailOpenedPayloadandEmailClickedPayloadinclude optionalipfields. If these are populated, webhook recipients will receive user IP addresses, which may have GDPR/privacy compliance implications depending on the customer's jurisdiction.This is likely intentional for analytics purposes, but worth documenting in webhook documentation that these fields may contain PII.
| function WebhookDetailActions({ | ||
| webhook, | ||
| onTest, | ||
| onEdit, | ||
| onToggleStatus, | ||
| onRotateSecret, | ||
| isTestPending, | ||
| isToggling, | ||
| isRotating, | ||
| }: { | ||
| webhook: Webhook; | ||
| onTest: () => void; | ||
| onEdit: () => void; | ||
| onToggleStatus: () => void; | ||
| onRotateSecret: () => void; | ||
| isTestPending: boolean; | ||
| isToggling: boolean; | ||
| isRotating: boolean; | ||
| }) { | ||
| const [open, setOpen] = useState(false); | ||
| const isPaused = webhook.status === "PAUSED"; | ||
| const isAutoDisabled = webhook.status === "AUTO_DISABLED"; | ||
|
|
||
| return ( | ||
| <Popover open={open} onOpenChange={setOpen}> | ||
| <PopoverTrigger asChild> | ||
| <Button variant="default" className="gap-1"> | ||
| <MoreVertical className="h-4 -ml-2" /> | ||
| Actions | ||
| </Button> | ||
| </PopoverTrigger> | ||
| <PopoverContent className="w-52 rounded-xl p-1" align="end"> | ||
| <div className="flex flex-col"> | ||
| <Button | ||
| variant="ghost" | ||
| size="sm" | ||
| className="justify-start rounded-lg hover:bg-accent" | ||
| onClick={() => { | ||
| onTest(); | ||
| setOpen(false); | ||
| }} | ||
| disabled={isTestPending} | ||
| > | ||
| <TestTube className="mr-2 h-4 w-4" /> | ||
| Test webhook | ||
| </Button> | ||
| <Button | ||
| variant="ghost" | ||
| size="sm" | ||
| className="justify-start rounded-lg hover:bg-accent" | ||
| onClick={() => { | ||
| onEdit(); | ||
| setOpen(false); | ||
| }} | ||
| > | ||
| <Edit3 className="mr-2 h-4 w-4" /> | ||
| Edit | ||
| </Button> | ||
| <Button | ||
| variant="ghost" | ||
| size="sm" | ||
| className="justify-start rounded-lg hover:bg-accent" | ||
| onClick={() => { | ||
| onToggleStatus(); | ||
| setOpen(false); | ||
| }} | ||
| disabled={isToggling || isAutoDisabled} | ||
| > | ||
| {isPaused ? ( | ||
| <> | ||
| <Play className="mr-2 h-4 w-4" /> | ||
| Resume | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <Pause className="mr-2 h-4 w-4" /> | ||
| Pause | ||
| </> | ||
| )} | ||
| </Button> | ||
| <Button | ||
| variant="ghost" | ||
| size="sm" | ||
| className="justify-start rounded-lg hover:bg-accent" | ||
| onClick={() => { | ||
| onRotateSecret(); | ||
| setOpen(false); | ||
| }} | ||
| disabled={isRotating} | ||
| > | ||
| <Key className="mr-2 h-4 w-4" /> | ||
| Rotate secret | ||
| </Button> | ||
| <DeleteWebhook webhook={webhook} /> | ||
| </div> | ||
| </PopoverContent> | ||
| </Popover> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AUTO_DISABLED action label is misleading when the toggle is disabled.
When webhook.status === "AUTO_DISABLED", the button is disabled but renders the “Pause” variant (since isPaused is false). Consider rendering a distinct “Auto-disabled” row (or “Resume” if you decide to allow it).
🤖 Prompt for AI Agents
apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx around lines 37 to
135: the toggle action currently shows “Pause” when webhook.status ===
"AUTO_DISABLED" even though the button is disabled, which is misleading; change
the conditional rendering for that Button so when isAutoDisabled is true it
renders a disabled row with an explicit label like “Auto-disabled” (and an
appropriate icon) instead of the Pause/Resume variants (or, if you intend to
allow resuming AUTO_DISABLED, render “Resume” and enable the button
accordingly); ensure the disabled state still respects isToggling/isRotating and
keep setOpen(false) behavior consistent.
| export type WebhookEvent<TType extends WebhookEventType> = { | ||
| id: string; | ||
| type: TType; | ||
| createdAt: string; | ||
| data: WebhookPayloadData<TType>; | ||
| }; | ||
|
|
||
| export type WebhookEventData = { | ||
| [T in WebhookEventType]: WebhookEvent<T>; | ||
| }[WebhookEventType]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WebhookEvent type is missing fields that buildPayload includes.
The WebhookEvent type defines { id, type, createdAt, data }, but buildPayload in webhook-service.ts constructs payloads with additional fields: version, teamId, and attempt.
Consider aligning the type with the actual payload structure:
export type WebhookEvent<TType extends WebhookEventType> = {
id: string;
type: TType;
+ version: string;
createdAt: string;
+ teamId: number;
data: WebhookPayloadData<TType>;
+ attempt: number;
};This ensures SDK consumers and webhook receivers have accurate type information for the full payload envelope.
🤖 Prompt for AI Agents
In packages/lib/src/webhook/webhook-events.ts around lines 187 to 196, the
WebhookEvent type only defines {id, type, createdAt, data} but buildPayload
actually includes additional envelope fields (version, teamId, attempt); update
the WebhookEvent generic to include those fields (e.g. version: string, teamId?:
string | null, attempt?: number) with appropriate types/optionality, and ensure
the WebhookEventData union remains derived from the updated WebhookEvent so the
exported types match the actual payload shape produced by webhook-service.ts.
Summary by cubic
Adds first-class webhooks: dashboard UI for endpoint management, signed delivery with retries, and event emission for email, domain, and contact events. This enables teams to receive real-time updates and review delivery history, and ships SDK helpers to verify signatures and handle typed events.
New Features
Migration
Written for commit b4e1dfc. Summary will update automatically on new commits.
Summary by CodeRabbit
New Features
Chores
✏️ Tip: You can customize this high-level summary in your review settings.