-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Implement Upstash Workflows for better background job handling #2865
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
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
cc2b9e8
test out workflows
devkiran 2bd6c32
Merge branch 'group-links' into upstash-workflows
devkiran b03a4aa
add /api/workflows/partner-approved workflow
devkiran c93a1d6
add triggerWorkflows
devkiran 1585859
Update route.ts
devkiran 304386e
Update route.ts
devkiran 56ff36f
Merge branch 'main' into upstash-workflows
devkiran ff12a4e
Update route.ts
devkiran File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
293 changes: 293 additions & 0 deletions
293
apps/web/app/(ee)/api/workflows/partner-approved/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,293 @@ | ||
| import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; | ||
| import { createPartnerDefaultLinks } from "@/lib/api/partners/create-partner-default-links"; | ||
| import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; | ||
| import { createWorkflowLogger } from "@/lib/cron/qstash-workflow-logger"; | ||
| import { PlanProps, RewardProps } from "@/lib/types"; | ||
| import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; | ||
| import { EnrolledPartnerSchema } from "@/lib/zod/schemas/partners"; | ||
| import { ProgramRewardDescription } from "@/ui/partners/program-reward-description"; | ||
| import { resend } from "@dub/email/resend/client"; | ||
| import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; | ||
| import PartnerApplicationApproved from "@dub/email/templates/partner-application-approved"; | ||
| import { prisma } from "@dub/prisma"; | ||
| import { serve } from "@upstash/workflow/nextjs"; | ||
| import { z } from "zod"; | ||
|
|
||
| const payloadSchema = z.object({ | ||
| programId: z.string(), | ||
| partnerId: z.string(), | ||
| userId: z.string(), | ||
| }); | ||
|
|
||
| type Payload = z.infer<typeof payloadSchema>; | ||
|
|
||
| /** | ||
| * Partner Approved Workflow | ||
| * | ||
| * This workflow is triggered when a partner's application to join a program is approved. | ||
| * It performs three main steps in sequence: | ||
| * | ||
| * 1. **Create Default Links**: Creates partner-specific default links based on the group's | ||
| * configuration. | ||
| * | ||
| * 2. **Send Email Notification**: Sends an approval email to all partner users who have | ||
| * opted in to receive application approval notifications. | ||
| * | ||
| * 3. **Send Webhook**: Notifies the workspace via webhook that a new partner has been | ||
| * enrolled in the program. | ||
| */ | ||
|
|
||
| // POST /api/workflows/partner-approved | ||
| export const { POST } = serve<Payload>( | ||
| async (context) => { | ||
| const input = context.requestPayload; | ||
| const { programId, partnerId, userId } = input; | ||
|
|
||
| const logger = createWorkflowLogger({ | ||
| workflowId: "partner-approved", | ||
| workflowRunId: context.workflowRunId, | ||
| }); | ||
|
|
||
| const { program, partner, links, ...programEnrollment } = | ||
| await getProgramEnrollmentOrThrow({ | ||
| programId, | ||
| partnerId, | ||
| includePartner: true, | ||
| }); | ||
|
|
||
| const { groupId } = programEnrollment; | ||
|
|
||
| // Step 1: Create partner default links | ||
| await context.run("create-default-links", async () => { | ||
| logger.info({ | ||
| message: "Started executing workflow step 'create-default-links'.", | ||
| data: input, | ||
| }); | ||
|
|
||
| if (!groupId) { | ||
| logger.error({ | ||
| message: `The partner ${partnerId} is not associated with any group.`, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| let { partnerGroupDefaultLinks, utmTemplate } = | ||
| await prisma.partnerGroup.findUniqueOrThrow({ | ||
| where: { | ||
| id: groupId, | ||
| }, | ||
| include: { | ||
| partnerGroupDefaultLinks: true, | ||
| utmTemplate: true, | ||
| }, | ||
| }); | ||
|
|
||
| if (partnerGroupDefaultLinks.length === 0) { | ||
| logger.error({ | ||
| message: `Group ${groupId} does not have any default links.`, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Skip existing default links | ||
| for (const link of links) { | ||
| if (link.partnerGroupDefaultLinkId) { | ||
| partnerGroupDefaultLinks = partnerGroupDefaultLinks.filter( | ||
| (defaultLink) => defaultLink.id !== link.partnerGroupDefaultLinkId, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Find the workspace | ||
| const workspace = await prisma.project.findUniqueOrThrow({ | ||
| where: { | ||
| id: program.workspaceId, | ||
| }, | ||
| select: { | ||
| id: true, | ||
| plan: true, | ||
| }, | ||
| }); | ||
|
|
||
| const partnerLinks = await createPartnerDefaultLinks({ | ||
| workspace: { | ||
| id: workspace.id, | ||
| plan: workspace.plan as PlanProps, | ||
| }, | ||
| program: { | ||
| id: program.id, | ||
| defaultFolderId: program.defaultFolderId, | ||
| }, | ||
| partner: { | ||
| id: partner.id, | ||
| name: partner.name, | ||
| email: partner.email!, | ||
| tenantId: programEnrollment.tenantId ?? undefined, | ||
| }, | ||
| group: { | ||
| defaultLinks: partnerGroupDefaultLinks, | ||
| utmTemplate: utmTemplate, | ||
| }, | ||
| userId, | ||
| }); | ||
|
|
||
| logger.info({ | ||
| message: `Created ${partnerLinks.length} partner default links.`, | ||
| data: partnerLinks.map(({ id, url, shortLink }) => ({ | ||
| id, | ||
| url, | ||
| shortLink, | ||
| })), | ||
| }); | ||
|
|
||
| return; | ||
| }); | ||
|
|
||
| // Step 2: Send email to partner application approved | ||
| await context.run("send-email", async () => { | ||
| logger.info({ | ||
| message: "Started executing workflow step 'send-email'.", | ||
| data: input, | ||
| }); | ||
|
|
||
| if (!groupId) { | ||
| logger.error({ | ||
| message: `The partner ${partnerId} is not associated with any group.`, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Find the partner users to send email notification | ||
| const partnerUsers = await prisma.partnerUser.findMany({ | ||
| where: { | ||
| partnerId, | ||
| notificationPreferences: { | ||
| applicationApproved: true, | ||
| }, | ||
| user: { | ||
| email: { | ||
| not: null, | ||
| }, | ||
| }, | ||
| }, | ||
| select: { | ||
| user: { | ||
| select: { | ||
| id: true, | ||
| email: true, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| if (partnerUsers.length === 0) { | ||
| logger.info({ | ||
| message: `No partner users found for partner ${partnerId} to send email notification.`, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Find the group to get the rewards | ||
| const group = await getGroupOrThrow({ | ||
| programId, | ||
| groupId, | ||
| includeExpandedFields: true, | ||
| }); | ||
|
|
||
| const rewards = [ | ||
| group.clickReward, | ||
| group.leadReward, | ||
| group.saleReward, | ||
| ].filter(Boolean) as RewardProps[]; | ||
|
|
||
| logger.info({ | ||
| message: `Sending email notification to ${partnerUsers.length} partner users.`, | ||
| data: partnerUsers, | ||
| }); | ||
|
|
||
| if (!resend) { | ||
| return; | ||
| } | ||
|
|
||
| // Resend batch email | ||
| const { data, error } = await resend.batch.send( | ||
| partnerUsers.map(({ user }) => ({ | ||
| subject: `Your application to join ${program.name} partner program has been approved!`, | ||
| from: VARIANT_TO_FROM_MAP.notifications, | ||
| to: user.email!, | ||
| react: PartnerApplicationApproved({ | ||
| program: { | ||
| name: program.name, | ||
| logo: program.logo, | ||
| slug: program.slug, | ||
| }, | ||
| partner: { | ||
| name: partner.name, | ||
| email: user.email!, | ||
| payoutsEnabled: Boolean(partner.payoutsEnabledAt), | ||
| }, | ||
| rewardDescription: ProgramRewardDescription({ | ||
| reward: rewards.find((r) => r.event === "sale"), | ||
| showModifiersTooltip: false, | ||
| }), | ||
| }), | ||
| headers: { | ||
| "Idempotency-Key": `application-approved-${programEnrollment.id}`, | ||
| }, | ||
| })), | ||
| ); | ||
|
|
||
| if (data) { | ||
| logger.info({ | ||
| message: `Sent emails to ${partnerUsers.length} partner users.`, | ||
| data: data, | ||
| }); | ||
| } | ||
|
|
||
| if (error) { | ||
| throw new Error(`Failed to send email notification to partner users.`); | ||
| } | ||
| }); | ||
|
|
||
| // Step 3: Send webhook to workspace | ||
| await context.run("send-webhook", async () => { | ||
| logger.info({ | ||
| message: "Started executing workflow step 'send-webhook'.", | ||
| data: input, | ||
| }); | ||
|
|
||
| const enrolledPartner = EnrolledPartnerSchema.parse({ | ||
| ...programEnrollment, | ||
| ...partner, | ||
| id: partner.id, | ||
| status: programEnrollment.status, | ||
| links, | ||
| }); | ||
|
|
||
| const workspace = await prisma.project.findUniqueOrThrow({ | ||
| where: { | ||
| id: program.workspaceId, | ||
| }, | ||
| select: { | ||
| id: true, | ||
| webhookEnabled: true, | ||
| }, | ||
| }); | ||
|
|
||
| await sendWorkspaceWebhook({ | ||
| workspace, | ||
| trigger: "partner.enrolled", | ||
| data: enrolledPartner, | ||
| }); | ||
|
|
||
| logger.info({ | ||
| message: `Sent "partner.enrolled" webhook to workspace ${workspace.id}.`, | ||
| }); | ||
| }); | ||
| }, | ||
| { | ||
| initialPayloadParser: (requestPayload) => { | ||
| return payloadSchema.parse(JSON.parse(requestPayload)); | ||
| }, | ||
| }, | ||
| ); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.