Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/playwright.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ jobs:
UPSTASH_REDIS_REST_TOKEN: "e2e_srh_token"
UPSTASH_VECTOR_REST_URL: "https://sensible-camel-xxxx.upstash.io"
UPSTASH_VECTOR_REST_TOKEN: "xx"
UPSTASH_VECTOR_EMOJI_REST_URL: "https://clever-bear-xxxx.upstash.io"
UPSTASH_VECTOR_EMOJI_REST_TOKEN: "xx"

# QStash local-dev (User 1) — must match jobs.e2e Start QStash step
QSTASH_URL: "http://127.0.0.1:8080"
Expand Down
111 changes: 10 additions & 101 deletions apps/web/app/(ee)/api/cron/program-application-reminder/route.ts
Original file line number Diff line number Diff line change
@@ -1,104 +1,13 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { qstash } from "@/lib/cron";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@dub/email";
import ProgramApplicationReminder from "@dub/email/templates/program-application-reminder";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils/src/constants";
import { withCron } from "@/lib/cron/with-cron";
import { programApplicationReminderJob } from "@/lib/jobs/handlers/program-application-reminder-job";
import { logAndRespond } from "../utils";

export const dynamic = "force-dynamic";

// POST - /api/cron/program-application-reminder
// Sends an email to a program application email if they haven't verified their account on Dub yet
export async function POST(req: Request) {
try {
const rawBody = await req.text();
await verifyQstashSignature({ req, rawBody });

const { applicationId } = JSON.parse(rawBody);

const application = await prisma.programApplication.findFirst({
where: {
id: applicationId,
// Only send reminders for applications that were created less than 3 days ago
createdAt: {
gt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),
},
},
include: {
enrollment: true,
program: {
select: {
id: true,
name: true,
slug: true,
supportEmail: true,
},
},
},
});

if (!application) {
return new Response(
`Application ${applicationId} not found. Skipping...`,
);
}

if (application.enrollment) {
return new Response(
`Partner with applicationId ${application.id} has already been enrolled in program ${application.program.name}. Skipping...`,
);
}

const programEnrollment = await prisma.programEnrollment.findFirst({
where: {
programId: application.program.id,
partner: {
email: application.email,
},
},
});

if (programEnrollment) {
await prisma.programEnrollment.update({
where: {
id: programEnrollment.id,
},
data: {
applicationId: application.id,
},
});

return new Response(
`Partner with email ${application.email} has already been enrolled in program ${application.program.name}. Updated applicationId to ${application.id} and skipping...`,
);
}

await sendEmail({
subject: `Complete your application for ${application.program.name}`,
to: application.email,
replyTo: application.program.supportEmail || "noreply",
react: ProgramApplicationReminder({
email: application.email,
program: {
name: application.program.name,
slug: application.program.slug,
},
}),
variant: "notifications",
});

await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/program-application-reminder`,
// repeat every 24 hours, but it'll be canceled if the application is more than 3 days old or is associated with a partner
delay: 24 * 60 * 60,
body: {
applicationId: application.id,
},
});

return new Response(
`Email sent to ${application.email} for application ${applicationId}.`,
);
} catch (error) {
return handleAndReturnErrorResponse(error);
}
}
// TODO: Remove this route once in-flight QStash messages to this URL have drained (~3 days after deploy)
export const POST = withCron(async ({ rawBody }) => {
await programApplicationReminderJob.execute(JSON.parse(rawBody));
return logAndRespond("Program application reminder processed.");
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-progr
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { ProgramEnrollmentStatus } from "@prisma/client";
import {
ProgramApplicationStatus,
ProgramEnrollmentStatus,
} from "@prisma/client";
import { NextResponse } from "next/server";
import * as z from "zod/v4";
import { assertE2EWorkspace } from "../../guard";
Expand Down Expand Up @@ -90,6 +93,7 @@ export const POST = withWorkspace(
data: {
id: applicationId,
programId,
partnerId,
groupId,
name: partner.name,
email: partner.email ?? `${partner.id}@e2e.dub-internal-test.com`,
Expand Down Expand Up @@ -123,6 +127,7 @@ export const POST = withWorkspace(
prisma.programApplication.update({
where: { id: enrollment.applicationId },
data: {
status: ProgramApplicationStatus.pending,
reviewedAt: null,
rejectionReason: null,
rejectionNote: null,
Expand Down Expand Up @@ -152,6 +157,7 @@ export const POST = withWorkspace(
data: {
id: applicationId,
programId,
partnerId,
groupId,
name: partner.name,
email: partner.email ?? `${partner.id}@e2e.dub-internal-test.com`,
Expand Down
41 changes: 40 additions & 1 deletion apps/web/app/(ee)/api/workflows/merge-partner-accounts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { logger } from "@/lib/axiom/server";
import { PRISMA_UPDATEMANY_LIMIT } from "@/lib/cron";
import { conn } from "@/lib/planetscale";
import { prisma } from "@/lib/prisma";
import { approveLinkedApplication } from "@/lib/program-applications/approve-linked-application";
import { storage } from "@/lib/storage";
import { recordLink } from "@/lib/tinybird";
import { redis } from "@/lib/upstash";
Expand Down Expand Up @@ -144,6 +145,17 @@ export const { POST } = serve<Input>(
});
logs.push(fraudLog);

const movedApplications = await prisma.programApplication.updateMany({
where: {
partnerId: sourcePartnerId,
},
data: {
partnerId: targetPartnerId,
},
});

logs.push(`Moved ${movedApplications.count} program applications`);

// Delete the source partner account (must be last)
const { outputLog: partnerLog } = await deleteSourcePartner({
sourcePartnerId,
Expand Down Expand Up @@ -569,7 +581,14 @@ async function mergeSingleEnrollment({
programId,
},
},
data: { status: "approved" },
data: {
status: "approved",
},
});

await approveLinkedApplication({
applicationId: targetEnrollment.applicationId,
tx,
});
}

Expand All @@ -584,6 +603,16 @@ async function mergeSingleEnrollment({
where: { id: sourceEnrollment.id, partnerId: sourcePartnerId },
});

await tx.programApplication.updateMany({
where: {
programId,
partnerId: sourcePartnerId,
},
data: {
partnerId: targetPartnerId,
},
});

const tenantIdToCopy =
targetEnrollment.tenantId ?? sourceEnrollment.tenantId;

Expand Down Expand Up @@ -628,6 +657,16 @@ async function mergeSingleEnrollment({
});
}

await prisma.programApplication.updateMany({
where: {
programId,
partnerId: sourcePartnerId,
},
data: {
partnerId: targetPartnerId,
},
});

action = "transfer";
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import {
EnrolledPartnerExtendedProps,
EnrolledPartnerProps,
} from "@/lib/types";
import { COMMISSION_ELIGIBLE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners";
import {
ACTIVE_ENROLLMENT_STATUSES,
COMMISSION_ELIGIBLE_ENROLLMENT_STATUSES,
} from "@/lib/zod/schemas/partners";
import { PageContent } from "@/ui/layout/page-content";
import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper";
import { useArchivePartnerModal } from "@/ui/modals/archive-partner-modal";
Expand Down Expand Up @@ -441,7 +444,7 @@ function PageControls({ partner }: { partner: EnrolledPartnerProps }) {
</div>
<div className="border-t border-neutral-200" />
<div className="grid gap-px p-2">
{!["banned", "deactivated"].includes(partner.status) && (
{ACTIVE_ENROLLMENT_STATUSES.includes(partner.status) && (
<MenuItem
icon={BoxArchive}
onClick={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -930,7 +930,7 @@ function RowMenuButton({
<Command.Separator className="border-t border-neutral-200" />

<Command.Group className="grid gap-px p-1.5">
{!["banned", "deactivated"].includes(
{ACTIVE_ENROLLMENT_STATUSES.includes(
row.original.status,
) && (
<MenuItem
Expand Down
11 changes: 10 additions & 1 deletion apps/web/lib/actions/partners/archive-partner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { queuePartnerSearchSync } from "@/lib/api/partners/queue-partner-search-
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
import { prisma } from "@/lib/prisma";
import { archivePartnerSchema } from "@/lib/zod/schemas/partners";
import {
ACTIVE_ENROLLMENT_STATUSES,
archivePartnerSchema,
} from "@/lib/zod/schemas/partners";
import { waitUntil } from "@vercel/functions";
import { authActionClient } from "../safe-action";
import { throwIfNoPermission } from "../throw-if-no-permission";
Expand All @@ -30,6 +33,12 @@ export const archivePartnerAction = authActionClient
include: {},
});

if (!ACTIVE_ENROLLMENT_STATUSES.includes(programEnrollment.status)) {
throw new Error(
`You can only archive or unarchive partners in ${ACTIVE_ENROLLMENT_STATUSES.join(", ")} statuses.`,
);
}

const { status } = await prisma.programEnrollment.update({
where: {
partnerId_programId: {
Expand Down
9 changes: 9 additions & 0 deletions apps/web/lib/actions/partners/bulk-approve-partners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { dispatchWorkflows } from "@/lib/jobs/publish-workflows";
import { throwIfPartnersLimitExceeded } from "@/lib/partners/throw-if-partners-limit-exceeded";
import { prisma } from "@/lib/prisma";
import { bulkApprovePartnersSchema } from "@/lib/zod/schemas/partners";
import {
ProgramApplicationStatus,
ProgramEnrollmentStatus,
} from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { authActionClient } from "../safe-action";
import { throwIfNoPermission } from "../throw-if-no-permission";
Expand Down Expand Up @@ -88,8 +92,12 @@ export const bulkApprovePartnersAction = authActionClient
id: {
in: applicationIds,
},
enrollment: {
status: ProgramEnrollmentStatus.approved,
},
},
data: {
status: ProgramApplicationStatus.approved,
reviewedAt: now,
rejectionReason: null,
rejectionNote: null,
Expand Down Expand Up @@ -120,6 +128,7 @@ export const bulkApprovePartnersAction = authActionClient
id: {
in: programEnrollments.map(({ id }) => id),
},
status: ProgramEnrollmentStatus.approved,
},
include: {
partner: true,
Expand Down
Loading
Loading