-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Improve /track/ endpoints #2828
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
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds workspace-scoped idempotency and 7-day cached responses for trackSale when invoiceId is provided; fetches, persists, and returns the full Link object in trackLead responses; updates schemas and tests to include link and idempotent/cached sale behavior. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant C as Client
participant API as /trackSale
participant R as Redis
participant S as SaleProcessor
C->>API: POST /trackSale (invoiceId, workspaceId, payload)
API->>R: MGET legacyKey, scopedKey
alt scopedKey exists
R-->>API: scopedResponse
API-->>C: return scopedResponse
else legacyKey exists
R-->>API: legacyFlag
API-->>C: return { eventName, customer: null, sale: null }
else no keys
API->>S: process sale
S-->>API: trackSaleResponse
API->>R: SETEX scopedKey 7d trackSaleResponse
API-->>C: return trackSaleResponse
end
sequenceDiagram
autonumber
participant C as Client
participant API as /track/lead
participant DB as Prisma
participant W as Workspace
C->>API: POST /track/lead (clickId, linkId?, payload)
API->>DB: findUnique Link (full object)
alt Link found
API->>DB: Promise.all([update Link, update Workspace])
DB-->>API: [updatedLink, updatedWorkspace]
API->>API: assign link = updatedLink
API->>W: run workflows / create partner commissions (uses updatedLink)
API-->>C: return { click, customer, link }
else no Link
DB-->>API: null
API-->>C: return { click, customer, link: null }
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Pre-merge checks (3 passed)✅ Passed checks (3 passed)
Poem
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
⏰ 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)
✨ Finishing touches
🧪 Generate unit tests
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 |
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 (3)
apps/web/lib/api/conversions/track-sale.ts (3)
60-76: Idempotency race: no lock → duplicate sales possible under concurrency.Two requests with the same
invoiceIdcan pass the checks before the cache is written and both record a sale. Add a per-workspace+invoice lock usingSET NXwith a short TTL and fall back to cached/minimal response when the lock isn’t acquired. Also parse cached values via the response schema.Suggested patch:
// Return idempotent response if invoiceId is already processed if (invoiceId) { - const [oldKeyValue, newKeyValue] = await redis.mget(legacyKey, resultKey); + const legacyKey = `dub_sale_events:invoiceId:${invoiceId}`; + const resultKey = `trackSale:${workspace.id}:invoiceId:${invoiceId}`; + const lockKey = `${resultKey}:lock`; + const [oldKeyValue, newKeyValue] = await redis.mget(legacyKey, resultKey); - if (newKeyValue) { - return newKeyValue; + if (newKeyValue) { + return trackSaleResponseSchema.parse(newKeyValue); } else if (oldKeyValue) { return { eventName, customer: null, sale: null, }; } + + // Acquire lock to prevent double-processing (expires automatically) + const acquired = await redis.set(lockKey, 1, { nx: true, ex: 60 }); + if (!acquired) { + // Another worker is handling this invoice; best-effort read + const cached = await redis.get(resultKey); + if (cached) return trackSaleResponseSchema.parse(cached); + return { eventName, customer: null, sale: null }; + } }Reference for
SEToptions (nx,ex): Upstash TS docs. (upstash.com)
548-558: metadata can be undefined → Zod parse failure.
trackSaleResponseSchema.sale.metadataisnullable, notoptional; passingundefinedwill fail validation and 500 the request. Default tonull.const trackSaleResponse = trackSaleResponseSchema.parse({ eventName, customer, sale: { amount, currency, invoiceId, paymentProcessor, - metadata, + metadata: metadata ?? null, }, });
67-75: Validate cached response before returning; avoid returningany.When returning
newKeyValue, parse/cast to the response schema to ensure shape consistency and type-safety.- if (newKeyValue) { - return newKeyValue; + if (newKeyValue) { + return trackSaleResponseSchema.parse(newKeyValue); }
🧹 Nitpick comments (2)
apps/web/lib/api/conversions/track-sale.ts (1)
560-568: Consider PII minimization in cache.You cache the full
customer(email, name, avatar) for 7 days in Redis. If privacy constraints apply, consider caching only a stable sale fingerprint and reloading fresh customer fields, or encrypting/shortening TTL.apps/web/tests/tracks/track-sale.test.ts (1)
97-144: Optional: add a concurrency/idempotency test.Spawn two parallel POSTs with the same
invoiceIdand assert only one commission/sale is recorded, the other returns the cached response. Helps prevent regressions.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/web/lib/api/conversions/track-sale.ts(3 hunks)apps/web/tests/tracks/track-sale.test.ts(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/api/conversions/track-sale.ts (2)
apps/web/lib/upstash/redis.ts (1)
redis(4-7)apps/web/lib/zod/schemas/sales.ts (1)
trackSaleResponseSchema(98-118)
⏰ 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: Vade Review
- GitHub Check: build
🔇 Additional comments (1)
apps/web/tests/tracks/track-sale.test.ts (1)
84-95: LGTM: assertion updated to expect full idempotent response.Matches the new behavior of returning the cached sale when
invoiceIdrepeats.
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
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/lib/api/conversions/track-lead.ts (1)
274-301: eventQuantity=0 bug: payload vs. counters diverge.
createLeadEventPayloaduses a truthy check (records once when 0), while counters useeventQuantity ?? 1(increments by 0). This records one event but increments zero — inconsistent.Apply a single sanitized quantity and use it everywhere (payload, counters, commissions):
@@ - let isDuplicateEvent = false; + let isDuplicateEvent = false; + // Normalize quantity early; 0/negative -> 1 (paired with schema tightening) + const qty = typeof eventQuantity === "number" && eventQuantity > 0 + ? Math.floor(eventQuantity) + : 1; @@ - const createLeadEventPayload = (customerId: string) => { + const createLeadEventPayload = (customerId: string) => { const basePayload = { @@ - return eventQuantity - ? Array(eventQuantity) + return qty > 1 + ? Array(qty) .fill(null) .map(() => ({ ...basePayload, event_id: nanoid(16), })) : basePayload; }; @@ - data: { - leads: { - increment: eventQuantity ?? 1, - }, - }, + data: { leads: { increment: qty } }, @@ - data: { - usage: { - increment: eventQuantity ?? 1, - }, - }, + data: { usage: { increment: qty } }, @@ - quantity: eventQuantity ?? 1, + quantity: qty,Also consider tightening the schema as suggested in
leads.tsto forbid non-positive values.
🧹 Nitpick comments (4)
apps/web/tests/tracks/track-lead-client.test.ts (1)
46-53: Strengthen link assertions; avoid self-referential expectation.Using
link: leadResponse.linkmakes the check tautological. Assert the shape/values you know (domain/key) so we actually validate the new response field.- expect(leadResponse).toStrictEqual({ - click: { - id: clickId, - }, - link: leadResponse.link, - customer: customer, - }); + expect(leadResponse).toMatchObject({ + click: { id: clickId }, + customer, + }); + expect(leadResponse.link).toMatchObject({ + id: expect.any(String), + domain: "getacme.link", + key: "derek", + });apps/web/tests/tracks/track-lead.test.ts (1)
22-24: Make link check meaningful and resilient to duplicates.The current
link: response.data.linkis self-referential. Validate known fields when present, but allownullfor idempotent duplicates.- expect(response.data).toStrictEqual({ - click: { - id: clickId, - }, - link: response.data.link, - customer, - }); + expect(response.data).toMatchObject({ + click: { id: clickId }, + customer, + }); + if (response.data.link) { + expect(response.data.link).toMatchObject({ + id: expect.any(String), + domain: "getacme.link", + key: "derek", + }); + } else { + expect(response.data.link).toBeNull(); + }apps/web/lib/api/conversions/track-lead.ts (2)
236-337: Add error guard inside waitUntil to avoid unhandled rejections.Failures in
recordLead, uploads, or webhooks currently bubble silently. Wrap the async body in try/catch and log.- waitUntil( - (async () => { + waitUntil( + (async () => { + try { @@ - await sendWorkspaceWebhook({ + await sendWorkspaceWebhook({ trigger: "lead.created", data: transformLeadEventData({ ...clickData, eventName, link, customer, }), workspace, }); - } - })(), + } + } catch (err) { + console.error("trackLead waitUntil error", { + err, + clickId, + workspaceId: workspace.id, + }); + } + })(), );
340-351: Response may contain pre-update link — acceptable here.Because only counters change in
updatedLinkand those fields aren’t in the responsepick, returning the pre-update link is fine.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (6)
apps/web/lib/api/conversions/track-lead.ts(6 hunks)apps/web/lib/api/conversions/track-sale.ts(3 hunks)apps/web/lib/zod/schemas/leads.ts(2 hunks)apps/web/tests/tracks/track-lead-client.test.ts(2 hunks)apps/web/tests/tracks/track-lead.test.ts(2 hunks)apps/web/tests/tracks/track-sale.test.ts(2 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/web/tests/tracks/track-sale.test.ts
- apps/web/lib/api/conversions/track-sale.ts
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-25T17:33:45.072Z
Learnt from: devkiran
PR: dubinc/dub#2736
File: apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts:12-12
Timestamp: 2025-08-25T17:33:45.072Z
Learning: The WorkflowTrigger enum in packages/prisma/schema/workflow.prisma contains three values: leadRecorded, saleRecorded, and commissionEarned. All three are properly used throughout the codebase.
Applied to files:
apps/web/lib/api/conversions/track-lead.ts
🧬 Code graph analysis (1)
apps/web/tests/tracks/track-lead.test.ts (2)
apps/web/tests/utils/helpers.ts (1)
randomCustomer(7-17)apps/web/lib/types.ts (1)
TrackLeadResponse(388-388)
⏰ 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: Vade Review
- GitHub Check: build
🔇 Additional comments (5)
apps/web/tests/tracks/track-lead-client.test.ts (1)
26-26: Test name clarifies precondition — good change.apps/web/tests/tracks/track-lead.test.ts (1)
178-188: Renames for backward-compat tests look good.Variable renames (
customer5/customer6) and aligned expectations are consistent and improve clarity.Also applies to: 191-195, 199-209, 212-216
apps/web/lib/zod/schemas/leads.ts (1)
5-5: Importing LinkSchema is correct.apps/web/lib/api/conversions/track-lead.ts (2)
18-18: Type import for Link/WorkflowTrigger — good.
139-145: Fetching full Link is fine given response stripping.You're returning the link through a schema that strips extra fields, so fetching full Link here is acceptable.
Summary by CodeRabbit
Bug Fixes
New Features
Tests