Skip to content

Commit 7b3428a

Browse files
committed
Merge branch 'main' into add-more-user-roles
2 parents 51067ba + fa59523 commit 7b3428a

File tree

84 files changed

+3134
-587
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

84 files changed

+3134
-587
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"use client";
2+
3+
import { LoadingSpinner } from "@dub/ui";
4+
import { cn } from "@dub/utils";
5+
import { useFormStatus } from "react-dom";
6+
import { toast } from "sonner";
7+
8+
export function ResetLoginAttempts() {
9+
return (
10+
<div className="flex flex-col space-y-5">
11+
<form
12+
action={async (data) =>
13+
await fetch("/api/admin/reset-login-attempts", {
14+
method: "POST",
15+
body: JSON.stringify({
16+
email: data.get("email"),
17+
}),
18+
})
19+
.then((res) => res.json())
20+
.then((res) => {
21+
if (res.error) {
22+
toast.error(res.error);
23+
} else {
24+
toast.success("Login attempts have been reset");
25+
}
26+
})
27+
}
28+
>
29+
<Form />
30+
</form>
31+
</div>
32+
);
33+
}
34+
35+
const Form = () => {
36+
const { pending } = useFormStatus();
37+
38+
return (
39+
<div className="relative flex w-full rounded-md shadow-sm">
40+
<input
41+
name="email"
42+
id="email"
43+
type="email"
44+
required
45+
disabled={pending}
46+
autoComplete="off"
47+
className={cn(
48+
"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm",
49+
pending && "bg-neutral-100",
50+
)}
51+
placeholder="user@example.com"
52+
aria-invalid="true"
53+
/>
54+
{pending && (
55+
<LoadingSpinner className="absolute inset-y-0 right-2 my-auto h-full w-5 text-neutral-400" />
56+
)}
57+
</div>
58+
);
59+
};

apps/web/app/(ee)/admin.dub.co/(dashboard)/page.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { DeletePartnerAccount } from "./components/delete-partner-account";
44
import { ImpersonateUser } from "./components/impersonate-user";
55
import { ImpersonateWorkspace } from "./components/impersonate-workspace";
66
import { RefreshDomain } from "./components/refresh-domain";
7+
import { ResetLoginAttempts } from "./components/reset-login-attempts";
78

89
export const metadata = constructMetadata({
910
title: "Dub Admin",
@@ -51,6 +52,13 @@ export default function AdminPage() {
5152
</p>
5253
<RefreshDomain />
5354
</div>
55+
<div className="flex flex-col space-y-4 px-5 py-10">
56+
<h2 className="text-xl font-semibold">Reset Login Attempts</h2>
57+
<p className="text-sm text-neutral-500">
58+
Reset a user's invalidLoginAttempts and lockedAt fields
59+
</p>
60+
<ResetLoginAttempts />
61+
</div>
5462
</div>
5563
);
5664
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { withAdmin } from "@/lib/auth";
2+
import { prisma } from "@dub/prisma";
3+
import { NextResponse } from "next/server";
4+
5+
// POST /api/admin/reset-login-attempts
6+
export const POST = withAdmin(async ({ req }) => {
7+
const { email } = await req.json();
8+
9+
if (!email) {
10+
return NextResponse.json(
11+
{ error: "Email is required" },
12+
{ status: 400 },
13+
);
14+
}
15+
16+
const user = await prisma.user.findUnique({
17+
where: { email },
18+
select: {
19+
id: true,
20+
email: true,
21+
invalidLoginAttempts: true,
22+
lockedAt: true,
23+
},
24+
});
25+
26+
if (!user) {
27+
return NextResponse.json({ error: "User not found" }, { status: 404 });
28+
}
29+
30+
const updatedUser = await prisma.user.update({
31+
where: { email },
32+
data: {
33+
invalidLoginAttempts: 0,
34+
lockedAt: null,
35+
},
36+
select: {
37+
id: true,
38+
email: true,
39+
invalidLoginAttempts: true,
40+
lockedAt: true,
41+
},
42+
});
43+
44+
return NextResponse.json({
45+
success: true,
46+
user: updatedUser,
47+
});
48+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { approveBountySubmission } from "@/lib/api/bounties/approve-bounty-submission";
2+
import { getBountyOrThrow } from "@/lib/api/bounties/get-bounty-or-throw";
3+
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
4+
import { parseRequestBody } from "@/lib/api/utils";
5+
import { withWorkspace } from "@/lib/auth";
6+
import { approveBountySubmissionBodySchema } from "@/lib/zod/schemas/bounties";
7+
import { NextResponse } from "next/server";
8+
9+
// POST /api/bounties/[bountyId]/submissions/[submissionId]/approve - approve a submission
10+
export const POST = withWorkspace(
11+
async ({ workspace, params, req, session }) => {
12+
const { bountyId, submissionId } = params;
13+
const programId = getDefaultProgramIdOrThrow(workspace);
14+
15+
let body;
16+
try {
17+
body = await parseRequestBody(req);
18+
} catch (e) {
19+
// If body is empty or invalid, use empty object since body is optional
20+
body = {};
21+
}
22+
23+
const { rewardAmount } = approveBountySubmissionBodySchema.parse(body);
24+
25+
await getBountyOrThrow({
26+
bountyId,
27+
programId,
28+
});
29+
30+
const approvedSubmission = await approveBountySubmission({
31+
programId,
32+
bountyId,
33+
submissionId,
34+
rewardAmount,
35+
user: session.user,
36+
});
37+
38+
return NextResponse.json(approvedSubmission);
39+
},
40+
{
41+
requiredPlan: ["advanced", "enterprise"],
42+
},
43+
);
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { getBountyOrThrow } from "@/lib/api/bounties/get-bounty-or-throw";
2+
import { rejectBountySubmission } from "@/lib/api/bounties/reject-bounty-submission";
3+
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
4+
import { parseRequestBody } from "@/lib/api/utils";
5+
import { withWorkspace } from "@/lib/auth";
6+
import { rejectBountySubmissionBodySchema } from "@/lib/zod/schemas/bounties";
7+
import { NextResponse } from "next/server";
8+
9+
// POST /api/bounties/[bountyId]/submissions/[submissionId]/reject - reject a submission
10+
export const POST = withWorkspace(
11+
async ({ workspace, params, req, session }) => {
12+
const { bountyId, submissionId } = params;
13+
const programId = getDefaultProgramIdOrThrow(workspace);
14+
15+
let body;
16+
try {
17+
body = await parseRequestBody(req);
18+
} catch (e) {
19+
// If body is empty or invalid, use empty object since body is optional
20+
body = {};
21+
}
22+
23+
const { rejectionReason, rejectionNote } =
24+
rejectBountySubmissionBodySchema.parse(body);
25+
26+
await getBountyOrThrow({
27+
bountyId,
28+
programId,
29+
});
30+
31+
const rejectedSubmission = await rejectBountySubmission({
32+
programId,
33+
bountyId,
34+
submissionId,
35+
rejectionReason,
36+
rejectionNote,
37+
user: session.user,
38+
});
39+
40+
return NextResponse.json(rejectedSubmission);
41+
},
42+
{
43+
requiredPlan: ["advanced", "enterprise"],
44+
},
45+
);

apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { getBountySubmissions } from "@/lib/api/bounties/get-bounty-submissions";
2-
import { DubApiError } from "@/lib/api/errors";
1+
import { getBountyOrThrow } from "@/lib/api/bounties/get-bounty-or-throw";
32
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
43
import { withWorkspace } from "@/lib/auth";
54
import {
@@ -8,40 +7,69 @@ import {
87
} from "@/lib/zod/schemas/bounties";
98
import { prisma } from "@dub/prisma";
109
import { NextResponse } from "next/server";
11-
import * as z from "zod/v4";
1210

1311
// GET /api/bounties/[bountyId]/submissions - get all submissions for a bounty
1412
export const GET = withWorkspace(
1513
async ({ workspace, params, searchParams }) => {
1614
const { bountyId } = params;
1715
const programId = getDefaultProgramIdOrThrow(workspace);
1816

19-
const bounty = await prisma.bounty.findUniqueOrThrow({
20-
where: {
21-
id: bountyId,
22-
},
17+
const bounty = await getBountyOrThrow({
18+
bountyId,
19+
programId,
2320
include: {
2421
groups: true,
2522
},
2623
});
2724

28-
if (bounty.programId !== programId) {
29-
throw new DubApiError({
30-
code: "not_found",
31-
message: `Bounty ${bountyId} not found.`,
32-
});
33-
}
34-
35-
const filters = getBountySubmissionsQuerySchema.parse(searchParams);
25+
const { status, groupId, partnerId, sortOrder, sortBy, page, pageSize } =
26+
getBountySubmissionsQuerySchema.parse(searchParams);
3627

37-
const bountySubmissions = await getBountySubmissions({
38-
...filters,
39-
bountyId: bounty.id,
28+
const submissions = await prisma.bountySubmission.findMany({
29+
where: {
30+
bountyId,
31+
status: status ?? {
32+
in: ["draft", "submitted", "approved"],
33+
},
34+
...(groupId && {
35+
programEnrollment: {
36+
groupId,
37+
},
38+
}),
39+
...(partnerId && {
40+
partnerId,
41+
}),
42+
},
43+
include: {
44+
user: true,
45+
commission: true,
46+
partner: true,
47+
programEnrollment: true,
48+
},
49+
orderBy: {
50+
[sortBy === "completedAt" ? "completedAt" : "performanceCount"]:
51+
sortOrder,
52+
},
53+
skip: (page - 1) * pageSize,
54+
take: pageSize,
4055
});
4156

42-
return NextResponse.json(
43-
z.array(BountySubmissionExtendedSchema).parse(bountySubmissions),
57+
const bountySubmissions = submissions.map(
58+
({ partner, programEnrollment, commission, user, ...submissionData }) =>
59+
BountySubmissionExtendedSchema.parse({
60+
...submissionData,
61+
partner: {
62+
...partner,
63+
...(programEnrollment || {}),
64+
id: partner.id,
65+
status: programEnrollment?.status ?? null,
66+
},
67+
commission,
68+
user,
69+
}),
4470
);
71+
72+
return NextResponse.json(bountySubmissions);
4573
},
4674
{
4775
requiredPlan: [

apps/web/app/(ee)/api/commissions/export/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { NextResponse } from "next/server";
1111

1212
const MAX_COMMISSIONS_TO_EXPORT = 1000;
1313

14-
// GET /api/commissions/export – export commissions to CSV
14+
// GET /api/commissions/export – export commissions to CSV (with async support if >1000 commissions)
1515
export const GET = withWorkspace(
1616
async ({ searchParams, workspace, session }) => {
1717
const programId = getDefaultProgramIdOrThrow(workspace);
@@ -28,7 +28,7 @@ export const GET = withWorkspace(
2828
// Process the export in the background if the number of commissions is greater than MAX_COMMISSIONS_TO_EXPORT
2929
if (counts.all.count > MAX_COMMISSIONS_TO_EXPORT) {
3030
await qstash.publishJSON({
31-
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/commissions/export`,
31+
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/commissions`,
3232
body: {
3333
...parsedParams,
3434
columns: columns.join(","),

apps/web/app/(ee)/api/cron/commissions/export/fetch-commissions-batch.ts renamed to apps/web/app/(ee)/api/cron/export/commissions/fetch-commissions-batch.ts

File renamed without changes.

apps/web/app/(ee)/api/cron/commissions/export/route.ts renamed to apps/web/app/(ee)/api/cron/export/commissions/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const payloadSchema = commissionsExportQuerySchema.extend({
1919
userId: z.string(),
2020
});
2121

22-
// POST /api/cron/commissions/export - QStash worker for processing large commission exports
22+
// POST /api/cron/export/commissions - QStash worker for processing large commission exports
2323
export async function POST(req: Request) {
2424
try {
2525
const rawBody = await req.text();
@@ -89,7 +89,7 @@ export async function POST(req: Request) {
8989

9090
await sendEmail({
9191
to: user.email,
92-
subject: "Your commission export is ready",
92+
subject: "Your commissions export is ready",
9393
react: ExportReady({
9494
email: user.email,
9595
exportType: "commissions",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { getEvents } from "@/lib/analytics/get-events";
2+
import { EventsFilters } from "@/lib/analytics/types";
3+
4+
export async function* fetchEventsBatch(
5+
filters: Omit<EventsFilters, "page" | "limit">,
6+
pageSize: number = 1000,
7+
) {
8+
let page = 1;
9+
let hasMore = true;
10+
11+
while (hasMore) {
12+
const events = await getEvents({
13+
...filters,
14+
page,
15+
limit: pageSize,
16+
});
17+
18+
if (events.length > 0) {
19+
yield { events };
20+
page++;
21+
hasMore = events.length === pageSize;
22+
} else {
23+
hasMore = false;
24+
}
25+
}
26+
}

0 commit comments

Comments
 (0)