Thanks to visit codestin.com
Credit goes to github.com

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
41 changes: 32 additions & 9 deletions apps/web/app/(ee)/api/bounties/[bountyId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log";
import { generateBountyName } from "@/lib/api/bounties/generate-bounty-name";
import { generatePerformanceBountyName } from "@/lib/api/bounties/generate-performance-bounty-name";
import { getBountyWithDetails } from "@/lib/api/bounties/get-bounty-with-details";
import { DubApiError } from "@/lib/api/errors";
import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids";
Expand Down Expand Up @@ -57,17 +57,20 @@ export const PATCH = withWorkspace(
startsAt,
endsAt,
rewardAmount,
rewardDescription,
submissionRequirements,
performanceCondition,
groupIds,
} = updateBountySchema.parse(await parseRequestBody(req));

if (startsAt && endsAt && endsAt < startsAt) {
throw new DubApiError({
message: "endsAt must be on or after startsAt.",
message:
"Bounty end date (endsAt) must be on or after start date (startsAt).",
code: "bad_request",
});
}

const bounty = await prisma.bounty.findUniqueOrThrow({
where: {
id: bountyId,
Expand All @@ -78,6 +81,21 @@ export const PATCH = withWorkspace(
},
});

if (rewardAmount === null || rewardAmount === 0) {
if (bounty.type === "performance") {
throw new DubApiError({
code: "bad_request",
message: "Reward amount is required for performance bounties",
});
} else if (!rewardDescription) {
throw new DubApiError({
code: "bad_request",
message:
"For submission bounties, either reward amount or reward description is required",
});
}
}

// TODO:
// When we do archive, make sure it disables the workflow

Expand All @@ -96,23 +114,28 @@ export const PATCH = withWorkspace(
});
}

// Bounty name
let bountyName = name;

if (bounty.type === "performance" && performanceCondition) {
bountyName = generatePerformanceBountyName({
rewardAmount: rewardAmount ?? 0, // this shouldn't happen since we return early if rewardAmount is null
condition: performanceCondition,
});
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Incomplete Validation Allows Undefined Values

The update bounty route's rewardAmount validation is incomplete, missing undefined values possible in partial updates. This allows undefined rewardAmount to pass, leading to performance bounty names incorrectly showing "$0.00" when the field isn't provided, as the name generation defaults it to 0. This also creates an inconsistency with the create route's validation.

Additional Locations (1)

Fix in Cursor Fix in Web


const data = await prisma.$transaction(async (tx) => {
const updatedBounty = await tx.bounty.update({
where: {
id: bounty.id,
},
data: {
name:
bounty.type === "performance" && rewardAmount
? generateBountyName({
rewardAmount,
condition: performanceCondition,
})
: name ?? undefined,
name: bountyName ?? undefined,
description,
startsAt: startsAt!, // Can remove the ! when we're on a newer TS version (currently 5.4.4)
endsAt,
rewardAmount,
rewardDescription,
...(bounty.type === "submission" &&
submissionRequirements !== undefined && {
submissionRequirements: submissionRequirements ?? Prisma.DbNull,
Expand Down
40 changes: 34 additions & 6 deletions apps/web/app/(ee)/api/bounties/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log";
import { generateBountyName } from "@/lib/api/bounties/generate-bounty-name";
import { generatePerformanceBountyName } from "@/lib/api/bounties/generate-performance-bounty-name";
import { createId } from "@/lib/api/create-id";
import { DubApiError } from "@/lib/api/errors";
import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids";
Expand Down Expand Up @@ -79,6 +79,7 @@ export const POST = withWorkspace(
description,
type,
rewardAmount,
rewardDescription,
startsAt,
endsAt,
submissionRequirements,
Expand All @@ -88,22 +89,48 @@ export const POST = withWorkspace(

if (startsAt && endsAt && endsAt < startsAt) {
throw new DubApiError({
message: "endsAt must be on or after startsAt.",
message:
"Bounty end date (endsAt) must be on or after start date (startsAt).",
code: "bad_request",
});
}

if (!rewardAmount) {
if (type === "performance") {
throw new DubApiError({
code: "bad_request",
message: "Reward amount is required for performance bounties",
});
} else if (!rewardDescription) {
throw new DubApiError({
code: "bad_request",
message:
"For submission bounties, either reward amount or reward description is required",
});
}
}

const partnerGroups = await throwIfInvalidGroupIds({
programId,
groupIds,
});

const bountyName =
name ??
generateBountyName({
rewardAmount,
// Bounty name
let bountyName = name;

if (type === "performance" && performanceCondition) {
bountyName = generatePerformanceBountyName({
rewardAmount: rewardAmount ?? 0, // this shouldn't happen since we return early if rewardAmount is null
condition: performanceCondition,
});
}

if (!bountyName) {
throw new DubApiError({
code: "bad_request",
message: "Bounty name is required",
});
}

const bounty = await prisma.$transaction(async (tx) => {
let workflow: Workflow | null = null;
Expand Down Expand Up @@ -142,6 +169,7 @@ export const POST = withWorkspace(
startsAt: startsAt!, // Can remove the ! when we're on a newer TS version (currently 5.4.4)
endsAt,
rewardAmount,
rewardDescription,
...(submissionRequirements &&
type === "submission" && {
submissionRequirements,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { getBountyRewardDescription } from "@/lib/partners/get-bounty-reward-description";
import { PartnerBountyProps } from "@/lib/types";
import { BountyPerformance } from "@/ui/partners/bounties/bounty-performance";
import { BountyThumbnailImage } from "@/ui/partners/bounties/bounty-thumbnail-image";
import { useClaimBountyModal } from "@/ui/partners/bounties/claim-bounty-modal";
import { StatusBadge } from "@dub/ui";
import { Calendar6 } from "@dub/ui/icons";
import { Calendar6, Gift } from "@dub/ui/icons";
import { formatDate } from "@dub/utils";

export function PartnerBountyCard({ bounty }: { bounty: PartnerBountyProps }) {
Expand Down Expand Up @@ -43,6 +44,13 @@ export function PartnerBountyCard({ bounty }: { bounty: PartnerBountyProps }) {
)}
</span>
</div>

<div className="text-content-subtle flex items-center gap-2 text-sm font-medium">
<Gift className="size-3.5 shrink-0" />
<span className="truncate">
{getBountyRewardDescription(bounty)}
</span>
</div>
</div>

<div className="flex grow flex-col justify-end">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"use client";

import { getBountyRewardDescription } from "@/lib/partners/get-bounty-reward-description";
import useBounty from "@/lib/swr/use-bounty";
import {
SubmissionsCountByStatus,
useBountySubmissionsCount,
} from "@/lib/swr/use-bounty-submissions-count";
import { BountyThumbnailImage } from "@/ui/partners/bounties/bounty-thumbnail-image";
import { formatDate, nFormatter, pluralize } from "@dub/utils";
import { CalendarDays, Users } from "lucide-react";
import { CalendarDays, Gift, Users } from "lucide-react";
import { useMemo } from "react";
import { BountyActionButton } from "../bounty-action-button";

Expand All @@ -33,8 +34,8 @@ export function BountyInfo() {
}

return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-6">
<div className="relative flex size-20 shrink-0 items-center justify-center rounded-lg bg-neutral-100 p-3">
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start sm:gap-6">
<div className="relative flex h-[100px] w-[100px] shrink-0 items-center justify-center rounded-lg bg-neutral-100 p-3">
<BountyThumbnailImage bounty={bounty} />
</div>

Expand All @@ -54,6 +55,13 @@ export function BountyInfo() {
</span>
</div>

<div className="text-content-subtle flex items-center gap-2 text-sm font-medium">
<Gift className="size-4 shrink-0" />
<span className="text-ellipsis">
{getBountyRewardDescription(bounty)}
</span>
</div>

<div className="flex items-center space-x-2">
<Users className="size-4 shrink-0" />
<div className="text-sm text-neutral-500">
Expand Down Expand Up @@ -82,8 +90,8 @@ export function BountyInfo() {

function BountyInfoSkeleton() {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-6">
<div className="relative flex size-20 shrink-0 items-center justify-center rounded-lg bg-neutral-100 p-3" />
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start sm:gap-6">
<div className="relative flex h-[100px] w-[100px] shrink-0 items-center justify-center rounded-lg bg-neutral-100 p-3" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-6 w-48 animate-pulse rounded-md bg-neutral-200" />
<div className="flex items-center space-x-2">
Expand All @@ -94,6 +102,13 @@ function BountyInfoSkeleton() {
<div className="size-4 animate-pulse rounded bg-neutral-200" />
<div className="h-5 w-48 animate-pulse rounded bg-neutral-200" />
</div>
<div className="flex items-center space-x-2">
<div className="size-4 animate-pulse rounded bg-neutral-200" />
<div className="h-5 w-40 animate-pulse rounded bg-neutral-200" />
</div>
</div>
<div className="flex items-start">
<div className="h-9 w-24 animate-pulse rounded-md bg-neutral-200" />
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useConfirmModal } from "@/ui/modals/confirm-modal";
import { PartnerInfoSection } from "@/ui/partners/partner-info-section";
import { useRejectBountySubmissionModal } from "@/ui/partners/reject-bounty-submission-modal";
import { ButtonLink } from "@/ui/placeholders/button-link";
import { AmountInput } from "@/ui/shared/amount-input";
import { X } from "@/ui/shared/icons";
import {
Button,
Expand All @@ -21,7 +22,7 @@ import {
import { currencyFormatter, formatDate, getPrettyUrl } from "@dub/utils";
import Linkify from "linkify-react";
import { useAction } from "next-safe-action/hooks";
import { Dispatch, SetStateAction, useState } from "react";
import { Dispatch, SetStateAction, useMemo, useState } from "react";
import { toast } from "sonner";
import { BOUNTY_SUBMISSION_STATUS_BADGES } from "./bounty-submission-status-badges";

Expand All @@ -40,6 +41,8 @@ function BountySubmissionDetailsSheetContent({
const { setShowRejectModal, RejectBountySubmissionModal } =
useRejectBountySubmissionModal(submission);

const [rewardAmount, setRewardAmount] = useState<number | null>(null);

const {
executeAsync: approveBountySubmission,
isPending: isApprovingBountySubmission,
Expand Down Expand Up @@ -69,10 +72,23 @@ function BountySubmissionDetailsSheetContent({
await approveBountySubmission({
workspaceId,
submissionId: submission.id,
rewardAmount: rewardAmount ? rewardAmount * 100 : null,
});
},
});

const isValidForm = useMemo(() => {
if (bounty?.rewardAmount) {
return true;
}

if (!rewardAmount) {
return false;
}

return true;
}, [bounty, rewardAmount]);

if (!submission || !partner) {
return null;
}
Expand Down Expand Up @@ -273,7 +289,7 @@ function BountySubmissionDetailsSheetContent({
</div>

<div className="sticky bottom-0 z-10 border-t border-neutral-200 bg-white">
<div className="flex items-center justify-between gap-2 p-5">
<div className="flex items-center justify-between gap-2 border-t border-neutral-200 p-5">
{submission.status === "approved" ? (
<a
href={`/${workspaceSlug}/program/commissions?partnerId=${partner.id}&type=custom`}
Expand All @@ -283,28 +299,51 @@ function BountySubmissionDetailsSheetContent({
<Button variant="secondary" text="View commissions" />
</a>
) : (
<>
<Button
type="button"
variant="danger"
text="Reject"
disabledTooltip={
submission.status === "rejected"
? "Bounty submission already rejected."
: undefined
}
disabled={isApprovingBountySubmission}
onClick={() => setShowRejectModal(true)}
/>
<div className="flex w-full flex-col gap-4">
{!bounty?.rewardAmount && (
<div>
<label className="text-sm font-medium text-neutral-800">
Reward
</label>
<div className="mt-2">
<AmountInput
required
amountType="flat"
placeholder="0"
value={rewardAmount || ""}
onChange={(e) => {
const val = e.target.value;
setRewardAmount(val === "" ? null : parseFloat(val));
}}
/>
</div>
</div>
)}

<div className="flex w-full gap-4">
<Button
type="button"
variant="danger"
text="Reject"
disabledTooltip={
submission.status === "rejected"
? "Bounty submission already rejected."
: undefined
}
disabled={isApprovingBountySubmission}
onClick={() => setShowRejectModal(true)}
/>

<Button
type="submit"
variant="primary"
text="Approve"
loading={isApprovingBountySubmission}
onClick={() => setShowApproveBountySubmissionModal(true)}
/>
</>
<Button
type="submit"
variant="primary"
text="Approve"
loading={isApprovingBountySubmission}
onClick={() => setShowApproveBountySubmissionModal(true)}
disabled={!isValidForm}
/>
</div>
</div>
)}
</div>
</div>
Expand Down
Loading