Skip to content

FEAT: Dynamic bounty start date - #4045

Closed
devkiran wants to merge 64 commits into
mainfrom
limit-bounties-partner-tags
Closed

FEAT: Dynamic bounty start date #4045
devkiran wants to merge 64 commits into
mainfrom
limit-bounties-partner-tags

Conversation

@devkiran

@devkiran devkiran commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

Release Notes

  • New Features
    • Added partner tag-based bounty eligibility alongside partner group eligibility.
    • Introduced start modes (absolute/relative) with endsAfterDays, and updated bounty add/edit and duration selection UI.
    • Added/accepted partnerTags in bounty APIs and embed/partner flows; listings and details now surface eligible tags.
  • Bug Fixes
    • Strengthened eligibility enforcement across bounty submission, upload, and social-content access using both tags and groups.
    • Updated active/expired handling to use effective bounty periods consistently.
  • Chores
    • Updated cron syncing/notifications and webhook sample payloads for partnerTags; updated/removed related tests.

@vercel

vercel Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dub Error Error Jul 20, 2026 10:14pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds partner tag-based bounty eligibility, bounty timing modes, and related API, workflow, cron, UI, and schema updates. The change also propagates partner tag IDs through enrollment queries, submission checks, and bounty responses, while updating webhook samples, tests, and supporting docs.

Changes

Partner Tag Bounty Eligibility

Layer / File(s) Summary
Schema and helpers
apps/web/prisma/schema/bounty.prisma, apps/web/prisma/schema/tag.prisma, apps/web/prisma/schema/program.prisma, apps/web/lib/zod/schemas/bounties.ts, apps/web/lib/zod/schemas/partner-profile.ts, apps/web/lib/types.ts, apps/web/lib/bounty/bounty-period.ts, apps/web/lib/bounty/api/bounty-eligibility.ts, apps/web/lib/bounty/api/validate-bounty.ts, apps/web/lib/bounty/constants.ts, apps/web/lib/bounty/periods.ts, apps/web/lib/webhook/sample-events/bounty-*.json, apps/web/lib/embed/referrals/auth.ts
Adds bounty partner-tag relations, timing fields, workflow context extensions, shared eligibility/period helpers, updated validation, and sample payload/type updates.
Bounty API and backend queries
apps/web/app/(ee)/api/bounties/*, apps/web/lib/bounty/api/*, apps/web/lib/api/groups/*, apps/web/lib/fetchers/*, apps/web/lib/swr/use-partner-program-bounties.ts
Updates bounty listing, CRUD, lookup, and fetch helpers to filter by partner tags, compute effective timing, and return partner-tag data.
Workflows, cron, and partner notifications
apps/web/lib/api/workflows/*, apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts, apps/web/app/(ee)/api/cron/bounties/*, apps/web/lib/api/partners/*
Threads partner tag IDs and group-joined timestamps through workflows, cron jobs, partner updates, and notification fanout.
Dashboard bounty forms and cards
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/*, apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/*, apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/*, apps/web/lib/integrations/slack/transform.ts
Replaces the inline bounty form timing/groups UI, adds eligibility editing, and updates bounty display surfaces for start mode, end timing, and partner tags.
Docs, tests, and support files
apps/web/lib/bounty/PARTNER_PROFILE_BOUNTY_ROUTES.md, apps/web/scripts/migrations/backfill-group-joined-at.ts, apps/web/tests/*
Adds route inventory docs, a group-joined-at backfill script, and updates bounty/webhook test coverage.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • dubinc/dub#3986: Both changes alter execute-complete-bounty-workflow.ts and the surrounding bounty eligibility gating path.
  • dubinc/dub#3735: Both changes touch apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts and adjust bounty notification payload behavior.
  • dubinc/dub#2855: Both changes touch the bounty submissions route and related submission retrieval flow.

Suggested reviewers: pepeladeira

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly captures the dynamic bounty start date change, which is a major part of the PR.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch limit-bounties-partner-tags

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devkiran
devkiran marked this pull request as ready for review June 22, 2026 10:01

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts (1)

24-26: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Use a Set for invalid-tag detection to avoid quadratic scans.

The current filter + some check is O(n*m). A Set makes this O(n) and keeps behavior identical.

♻️ Suggested refactor
-    const invalidPartnerTagIds = partnerTagIds?.filter(
-      (partnerTagId) => !partnerTags?.some((tag) => tag.id === partnerTagId),
-    );
+    const validPartnerTagIdSet = new Set(partnerTags.map((tag) => tag.id));
+    const invalidPartnerTagIds = partnerTagIds.filter(
+      (partnerTagId) => !validPartnerTagIdSet.has(partnerTagId),
+    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts` around lines 24 -
26, The current implementation of invalidPartnerTagIds has O(n*m) complexity due
to the nested filter and some operations. To optimize this, create a Set from
the valid partnerTags IDs first (by mapping tag.id from the partnerTags array
into a Set), then use that Set in the filter operation to check if each
partnerTagId exists. This changes the complexity to O(n) while maintaining
identical behavior. Replace the filter and some check with a simple Set.has()
lookup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/app/`(ee)/api/cron/bounties/notify-partners/route.ts:
- Around line 16-18: The TODO comment near the top of the route.ts file is vague
and provides no actionable context about what needs to be fixed. Either replace
the comment with a specific, detailed description of what issue needs to be
addressed (including relevant details like function names, expected behavior, or
known problems), or remove the comment entirely if the issue has already been
resolved or is no longer relevant.

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx:
- Around line 164-170: The useEffect hook that checks the threshold condition
for `groups` (and similarly for `partnerTags` at line 230) includes `useAsync`
in its dependency array, causing a flip-flop effect. When the threshold is met
and `useAsync` is set to true via `setUseAsync`, this change triggers the effect
again, but now the condition evaluates differently and sets it back to false.
Remove `useAsync` from the dependency array in both effects and instead use a
functional updater pattern with `setUseAsync` that checks the previous state
value to prevent this repeating cycle. Apply this fix to both occurrences: the
one with `groups` and `GROUPS_MAX_PAGE_SIZE` around line 166, and the one with
`partnerTags` and `PARTNER_TAGS_MAX_PAGE_SIZE` around line 230.

In `@apps/web/lib/bounty/api/bounty-eligibility.ts`:
- Around line 75-85: The isPartnerEligibleForBounty function can return null
instead of a boolean because when partnerGroupId is null/falsy, the expression
(partnerGroupId && bountyTagIds.includes(partnerGroupId)) evaluates to null
rather than false. Fix this by explicitly converting the inGroup assignment to
always return a boolean value, either by using the double negation operator (!!)
on the inGroup expression or by ensuring the logical AND operation in the return
statement at line 84 always produces a proper boolean result.

In `@apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts`:
- Around line 16-21: The `ProgramEnrollmentWithPartnerTags` type incorrectly
references `BountyPartnerTag` instead of `ProgramPartnerTag` for the
`programPartnerTags` field. Since `ProgramEnrollment` has a relation to
`ProgramPartnerTag` and the actual data returned from
`getProgramEnrollmentOrThrow` provides `ProgramPartnerTag[]`, change the type
definition to use `ProgramPartnerTag` instead of `BountyPartnerTag` in the Pick
statement. Apply the same fix in the other location mentioned in
`apps/web/lib/embed/referrals/auth.ts` at line 32 to maintain type consistency
across the codebase.

In `@apps/web/ui/partners/tags/partner-tags-multi-select.tsx`:
- Around line 55-65: The useEffect hook in partner-tags-multi-select.tsx is
creating an oscillation loop due to the `!useAsync` condition in the Boolean
expression. The effect sets useAsync to true when partnerTags.length exceeds
PARTNER_TAGS_MAX_PAGE_SIZE, but then immediately toggles it back to false on the
next run because useAsync is now true. Remove the `!useAsync` check from the
condition so that the effect only enables useAsync when the threshold is met
without toggling it back off. The condition should simply be
`Boolean(partnerTags && partnerTags.length >= PARTNER_TAGS_MAX_PAGE_SIZE)`.

---

Nitpick comments:
In `@apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts`:
- Around line 24-26: The current implementation of invalidPartnerTagIds has
O(n*m) complexity due to the nested filter and some operations. To optimize
this, create a Set from the valid partnerTags IDs first (by mapping tag.id from
the partnerTags array into a Set), then use that Set in the filter operation to
check if each partnerTagId exists. This changes the complexity to O(n) while
maintaining identical behavior. Replace the filter and some check with a simple
Set.has() lookup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1e1f6c78-ed99-42ae-ab4c-b251134211aa

📥 Commits

Reviewing files that changed from the base of the PR and between cbf2171 and f68639d.

📒 Files selected for processing (35)
  • apps/web/app/(ee)/api/bounties/[bountyId]/route.ts
  • apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts
  • apps/web/app/(ee)/api/bounties/route.ts
  • apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts
  • apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
  • apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/social-content-stats/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts
  • apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts
  • apps/web/lib/actions/partners/tags/update-program-partner-tags.ts
  • apps/web/lib/actions/partners/upload-bounty-submission-file.ts
  • apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts
  • apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts
  • apps/web/lib/api/workflows/execute-workflows.ts
  • apps/web/lib/bounty/api/bounty-eligibility.ts
  • apps/web/lib/bounty/api/create-bounty-submission.ts
  • apps/web/lib/bounty/api/get-bounties-by-groups.ts
  • apps/web/lib/bounty/api/get-bounties-for-partner.ts
  • apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts
  • apps/web/lib/bounty/api/get-bounty-with-details.ts
  • apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts
  • apps/web/lib/embed/referrals/auth.ts
  • apps/web/lib/types.ts
  • apps/web/lib/webhook/sample-events/bounty-created.json
  • apps/web/lib/webhook/sample-events/bounty-updated.json
  • apps/web/lib/zod/schemas/bounties.ts
  • apps/web/lib/zod/schemas/partner-profile.ts
  • apps/web/prisma/schema/bounty.prisma
  • apps/web/prisma/schema/tag.prisma
  • apps/web/ui/partners/tags/partner-tags-multi-select.tsx
💤 Files with no reviewable changes (2)
  • apps/web/lib/bounty/api/get-bounties-by-groups.ts
  • apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts

Comment thread apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts Outdated
Comment thread apps/web/lib/bounty/api/bounty-eligibility.ts
Comment thread apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts
Comment thread apps/web/ui/partners/tags/partner-tags-multi-select.tsx Outdated
Treat null eligibility selections as empty so PATCH clears existing
restrictions, and refresh the eligibility picker when selections change.
@devkiran

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review. Look for breaking changes and security issues.

@devkiran devkiran changed the title Limit bounty eligibility to selected partner tags Limit bounty eligibility to selected partner tags & dynamic bounty start date Jul 1, 2026
@devkiran

devkiran commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review please

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@devkiran Understood — kicking off a full review of all the changes in this PR now.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (9)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx (1)

25-37: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include partner-tag filters in the recipient preview.

partnerTags is now part of the preview model, but this modal still derives the "selected partners" count from groupIds only. Tag-limited bounties will therefore show an inflated recipient count and still read like a group-only audience.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx
around lines 25 - 37, The recipient preview in ConfirmCreateBountyModal is still
counting only group-based targeting, so tag-limited bounties can show the wrong
audience size. Update the preview logic in confirm-create-bounty-modal.tsx to
include partner-tag filtering alongside groups, using the bounty.partnerTags
field in the same path that computes the “selected partners” count. Make sure
the displayed copy reflects the combined audience rules instead of implying a
group-only selection.
apps/web/lib/rewardful/import-partners.ts (1)

194-204: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Set groupJoinedAt on the approval path too.

This only stamps groupJoinedAt on insert. If an existing programEnrollment is approved through the update branch while that field is still null, the new relative bounty timing has no join timestamp to anchor to for imported partners.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/rewardful/import-partners.ts` around lines 194 - 204, The
approval flow in import-partners only sets groupJoinedAt in the create branch,
so existing programEnrollments approved through the update branch can stay null.
Update the approval path in the programEnrollment upsert logic to also assign
groupJoinedAt when setting status to approved, using the same timestamp approach
as the create path, and keep the change localized around the programEnrollment
create/update object.
apps/web/lib/api/groups/move-partners-to-group.ts (1)

74-92: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Exclude enrollments already in the target group before updating.

Line 85 makes this helper non-idempotent. Because the query never filters out rows whose groupId is already group.id, a retry or no-op move will reset groupJoinedAt, which can reopen group-based bounty windows and still trigger the downstream notifications/workflows for partners who did not actually move.

Suggested fix
-  partnerIds = programEnrollments.map(({ partnerId }) => partnerId);
+  const enrollmentsToMove = programEnrollments.filter(
+    ({ partnerGroup }) => partnerGroup?.id !== group.id,
+  );
+
+  if (enrollmentsToMove.length === 0) {
+    return 0;
+  }
+
+  partnerIds = enrollmentsToMove.map(({ partnerId }) => partnerId);

   const { count } = await prisma.programEnrollment.updateMany({
     where: {
       partnerId: {
         in: partnerIds,
       },
       programId,
     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/api/groups/move-partners-to-group.ts` around lines 74 - 92,
Exclude enrollments already assigned to the destination group before calling
prisma.programEnrollment.updateMany in movePartnersToGroup. Add a where
condition that filters out rows whose groupId already equals group.id so retries
and no-op moves do not reset groupJoinedAt or retrigger downstream side effects;
keep the update logic otherwise the same and apply the check alongside the
existing partnerId and programId filters.
apps/web/lib/actions/partners/bulk-invite-partners.ts (1)

127-140: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Don't stamp groupJoinedAt for still-invited enrollments.

This row is created with status: "invited", so groupJoinedAt becomes the invite time rather than the actual join/approval time. That will start relative bounty windows before the partner ever accepts the invite. Move this write to the approval/acceptance path instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/actions/partners/bulk-invite-partners.ts` around lines 127 -
140, The bulk invite flow in bulk-invite-partners.ts is setting groupJoinedAt
while creating ProgramEnrollment rows with status "invited", which incorrectly
treats the invite time as the actual group join time. Remove that assignment
from the createMany payload in the bulk invite path, and set groupJoinedAt only
in the acceptance/approval flow where the enrollment actually becomes active.
Use the ProgramEnrollment creation logic in bulk-invite-partners.ts as the place
to find the invite-time write and move the timestamp update to the approval
handler that transitions the status out of "invited".
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx (1)

96-102: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Relative bounties still render as if they have no end date.

The new fallback fixes the start side, but this branch still derives the end side from endsAt only. For relative bounties, endsAt is null and endsAfterDays carries the duration, so owners will see When a partner joins → No end date even when the bounty is actually time-bounded.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx
around lines 96 - 102, The bounty date display in bounty-info.tsx still treats
the end date as missing for relative bounties because it only checks
bounty.endsAt. Update the end-side branch in the bounty range rendering to also
derive a date from bounty.endsAfterDays when bounty.startsAt is absent, using
the same relative-bounty logic already applied on the start side. Keep the
change localized to the bounty-info.tsx display condition so relative bounties
render their actual time-bounded end instead of “No end date.”
apps/web/lib/firstpromoter/import-partners.ts (1)

173-195: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Populate groupJoinedAt when this upsert approves an existing enrollment.

Right now only the create branch stamps the new field. If partnerId_programId already exists, update: { status: "approved" } leaves groupJoinedAt null, so relative bounty windows still have no join date on this approval path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/firstpromoter/import-partners.ts` around lines 173 - 195, The
`prisma.programEnrollment.upsert` in `import-partners.ts` only sets
`groupJoinedAt` in the create path, so existing enrollments approved through the
update path remain without a join timestamp. Update the `update` branch for the
`programEnrollment` upsert to also set `groupJoinedAt` (and keep the status
approval logic) so `partnerId_programId` records get a join date when they are
newly approved via this flow.
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)

97-110: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Include default-group enrollments when filtering bounty recipients.

For bounties assigned to the program default group, enrollments with groupId: null inherit that group but are excluded by this query. Add an OR branch for groupId: null when groupIds contains bounty.program.defaultGroupId.

🐛 Proposed fix
         ...(groupIds.length > 0 && {
-          groupId: {
-            in: groupIds,
-          },
+          OR: [
+            {
+              groupId: {
+                in: groupIds,
+              },
+            },
+            ...(bounty.program.defaultGroupId &&
+            groupIds.includes(bounty.program.defaultGroupId)
+              ? [{ groupId: null }]
+              : []),
+          ],
         }),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(ee)/api/cron/bounties/notify-partners/route.ts around lines 97
- 110, The recipient filter in the bounty partner notification route is missing
enrollments that inherit the program default group via a null groupId, so adjust
the query building in the bounty recipient selection logic to include an OR
branch for groupId null whenever groupIds contains
bounty.program.defaultGroupId. Update the existing filter block that handles
groupIds and partnerTagIds in the notify-partners route so default-group
enrollments are matched alongside explicit group memberships.
apps/web/lib/integrations/slack/transform.ts (1)

452-505: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Relative bounties lose their end window in Slack.

For startMode === "relative", this now always renders Starts when partner joins, even when the bounty ends after a fixed number of days. A bounty like “starts on join, ends 14 days later” will look open-ended in the notification.

Suggested change
   const {
     id,
     name,
     description,
     rewardAmount,
     rewardDescription,
     submissionRequirements,
     type,
     startMode,
     startsAt,
     endsAt,
+    endsAfterDays,
   } = data;
@@
           {
             type: "mrkdwn",
             text: `*Duration*\n${
-              startMode === "relative" || !startsAt
-                ? "Starts when partner joins"
+              startMode === "relative"
+                ? `Starts when partner joins${endsAfterDays ? ` - ends ${endsAfterDays} day${endsAfterDays === 1 ? "" : "s"} later` : ""}`
+                : !startsAt
+                  ? "No start date"
                 : `${startsAt.toLocaleDateString()}${endsAt ? ` - ${endsAt.toLocaleDateString()}` : " (No end date)"}`
             }`,
           },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/integrations/slack/transform.ts` around lines 452 - 505, The
Slack bounty formatter in transform.ts is dropping the end date for relative
bounties because the Duration text in the bounty event block treats all
startMode === "relative" cases as open-ended. Update the duration logic in the
bounty payload formatter to preserve the end window when endsAt is present,
while still showing “Starts when partner joins” only for the relative start
label; use the existing event block construction around eventMessages and
getBountyRewardDescription to locate the affected section.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx (1)

76-97: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Replace font-regular with font-normal.

font-regular isn't a valid Tailwind utility in this codebase, so these changed rows won't get the intended 400 weight. Swap the changed occurrences to font-normal. Based on learnings, Tailwind’s normal font weight (400) must use font-normal.

Proposed fix
-          <div className="text-content-subtle font-regular flex items-center gap-2 text-sm">
+          <div className="text-content-subtle font-normal flex items-center gap-2 text-sm">
@@
-            className="font-regular"
+            className="font-normal"
@@
-          <div className="text-content-subtle font-regular flex items-center gap-2 text-sm">
+          <div className="text-content-subtle font-normal flex items-center gap-2 text-sm">
@@
-          <div className="text-content-subtle font-regular flex min-w-0 items-center gap-2 text-sm">
+          <div className="text-content-subtle font-normal flex min-w-0 items-center gap-2 text-sm">
@@
-                                  <span className="font-regular text-sm text-neutral-700">
+                                  <span className="font-normal text-sm text-neutral-700">

Also applies to: 135-153

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx
around lines 76 - 97, The updated BountyCard styling uses an invalid Tailwind
class, so replace every new `font-regular` usage with `font-normal` in
`BountyCard` and the related changed rows mentioned in the review; use the
existing `BountyRewardDescription` and surrounding text wrapper elements to
locate the affected className values and keep the intended 400 font weight.

Source: Learnings

🟠 Major comments (21)
apps/web/prisma/schema/tag.prisma-35-37 (1)

35-37: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Ship the backing Prisma migration with this schema change.

This relation now depends on BountyPartnerTag, but the supplied PR stack does not include a migration that creates the table/indexes. Deploying this as-is will break any read/write path that touches bounty partner tags.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/prisma/schema/tag.prisma` around lines 35 - 37, The Prisma schema
change in Tag depends on the BountyPartnerTag relation, but the PR is missing
the migration that creates the backing table and indexes. Add the corresponding
Prisma migration alongside the schema update so the database for
BountyPartnerTag exists before this relation is deployed; verify the migration
matches the Tag/BountyPartnerTag model changes and is included in the stack.
apps/web/lib/zod/schemas/partner-profile.ts-164-169 (1)

164-169: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Avoid silently removing fields from the partner bounty payload.

omit({ partnerTags: true, socialMetricsLastSyncedAt: true }) changes the response shape for every consumer of PartnerBountySchema. If this contract change is intentional, it needs endpoint/version coordination or a deprecation path instead of disappearing in-place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/zod/schemas/partner-profile.ts` around lines 164 - 169, The
PartnerBountySchema contract is dropping fields in-place via BountySchema.omit,
which can silently break existing consumers. Update PartnerBountySchema so it
does not remove partnerTags and socialMetricsLastSyncedAt without coordination;
either keep the full BountySchema shape here or move the trimmed shape into a
new, clearly versioned schema/endpoint and preserve the existing one for
backward compatibility.
apps/web/lib/bounty/api/get-bounties-for-partner.ts-43-49 (1)

43-49: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't pre-filter expired absolute bounties here.

buildActiveBountyPeriodWhere() removes absolute bounties once endsAt has passed, so those rows never reach the later getEffectiveBountyPeriod(...) mapping. The partner-profile list can no longer populate its expired tab for absolute-mode bounties. Query for "started + eligible + not archived" here, then let the returned effective dates drive active/expired classification.

Proposed fix
   const bounties = await prisma.bounty.findMany({
     where: {
       programId: program.id,
-      ...buildActiveBountyPeriodWhere(),
+      archivedAt: null,
+      OR: [
+        {
+          startMode: "relative",
+        },
+        {
+          startMode: "absolute",
+          startsAt: {
+            lt: new Date(),
+          },
+        },
+      ],
       ...buildBountyEligibilityWhere({
         groupId: groupId || program.defaultGroupId,
         partnerTagIds,
       }),

Also applies to: 83-95

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/bounty/api/get-bounties-for-partner.ts` around lines 43 - 49,
The partner bounty query in getBountiesForPartner is incorrectly pre-filtering
out expired absolute bounties by spreading buildActiveBountyPeriodWhere() into
prisma.bounty.findMany, which prevents later getEffectiveBountyPeriod()
classification from seeing them. Remove that active-period filter from the
initial fetch and keep only the “started + eligible + not archived” constraints
(via buildBountyEligibilityWhere and the existing base where conditions), then
rely on the returned effective dates for active vs expired tab grouping.
apps/web/app/(ee)/api/bounties/[bountyId]/route.ts-94-103 (1)

94-103: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Normalize startsAt before both validation and persistence when startMode changes.

Line 97 validates against the current row's startsAt, but Lines 226-247 write a different normalized value. That makes absolute -> relative fail unless the client also sends startsAt: null, while relative -> absolute with no startsAt leaves startMode = "absolute" and startsAt = null. That violates the invariant assumed by getEffectiveBountyPeriod() for absolute bounties.

Suggested fix
     const nextStartMode =
       startMode !== undefined ? startMode : bounty.startMode;
 
+    const normalizedStartsAt =
+      nextStartMode === "relative"
+        ? null
+        : startsAt !== undefined
+          ? startsAt ?? new Date()
+          : bounty.startMode === "relative"
+            ? new Date()
+            : bounty.startsAt;
+
     validateBounty({
       type: bounty.type,
-      startsAt: startsAt !== undefined ? startsAt : bounty.startsAt,
+      startsAt: normalizedStartsAt,
       endsAt: endsAt !== undefined ? endsAt : bounty.endsAt,
       startMode: nextStartMode,
       endsAfterDays:
         endsAfterDays !== undefined ? endsAfterDays : bounty.endsAfterDays,
@@
-    let startsAtUpdate: { startsAt?: Date | null } = {};
-
-    if (nextStartMode === "relative") {
-      startsAtUpdate = { startsAt: null };
-    } else if (startsAt !== undefined) {
-      startsAtUpdate = { startsAt: startsAt ?? new Date() };
-    }
+    const startsAtUpdate =
+      nextStartMode === bounty.startMode && startsAt === undefined
+        ? {}
+        : { startsAt: normalizedStartsAt };

Also applies to: 226-247

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(ee)/api/bounties/[bountyId]/route.ts around lines 94 - 103,
Normalize startsAt consistently in the bounty update flow so validation and
persistence use the same value when startMode changes. In the update handler
around validateBounty and the persistence logic in the route, derive a single
effective startsAt based on the incoming startMode and use it both for
validateBounty and for the stored record. Ensure the Bounty update path
preserves the invariant expected by getEffectiveBountyPeriod(), especially for
absolute bounties, by handling absolute-to-relative and relative-to-absolute
transitions without relying on the existing row’s startsAt.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts-77-79 (1)

77-79: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't let relative durations unlock the submission-window flow.

hasEndDate now flips true for endsAfterDays, but the rest of the submission-window logic in this hook still requires a real endsAt. With "when a partner joins" + "ends in 2 weeks", the UI enables that switch and then immediately rejects the form as invalid.

Also applies to: 227-230

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts
around lines 77 - 79, The add/edit bounty form is treating endsAfterDays as a
submission-window end date, which incorrectly enables the submission-window flow
without a real endsAt value. Update useAddEditBountyForm so hasEndDate only
reflects an actual date-based end (endsAt) and does not flip true for relative
durations; keep the submission-window logic aligned with the existing validation
and payload handling in this hook so the UI only unlocks that flow when endsAt
is present.
apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts-16-33 (1)

16-33: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve the effective group before running upload eligibility.

This path only carries the raw programEnrollment.groupId, and the new helper call has no access to program.defaultGroupId. Partners whose enrollment has groupId = null will therefore be treated as groupless here, even though they should inherit the program default group. That blocks valid uploads for bounties targeted at the default group.

Suggested direction
 type ProgramEnrollmentWithPartnerTags = Pick<
   ProgramEnrollment,
   | "programId"
   | "partnerId"
   | "groupId"
   | "createdAt"
   | "groupJoinedAt"
   | "status"
 > & {
+  defaultGroupId: string | null;
   programPartnerTags: Pick<ProgramPartnerTag, "partnerTagId">[];
 };
   throwIfPartnerCannotSubmitBounty({
-    programEnrollment,
+    programEnrollment: {
+      ...programEnrollment,
+      groupId: programEnrollment.groupId ?? programEnrollment.defaultGroupId,
+    },
     bounty,
   });

You'd also need to load defaultGroupId from the related program/enrollment fetch.

Also applies to: 90-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts` around lines 16
- 33, The upload eligibility flow in get-bounty-submission-upload-url is using
programEnrollment.groupId directly, which can miss the program’s default group
for enrollments with no explicit group. Update the programEnrollment data shape
and the related fetch so getBountySubmissionUploadUrl (and the helper call
around the eligibility check) also has access to program.defaultGroupId, then
resolve the effective group before evaluating upload eligibility. Ensure the
effective-group logic is used consistently wherever this eligibility path is
applied.
apps/web/lib/zod/schemas/bounties.ts-105-108 (1)

105-108: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep the create-bounty request schema backward compatible.

startMode and partnerTagIds are required at parse time now, so callers still sending the old payload shape will fail validation before the API can apply the previous default behavior. This is easy to hit with stale dashboard tabs during rollout.

Suggested fix
-  startMode: z.enum(BountyStartMode),
+  startMode: z.enum(BountyStartMode).nullish(),
   startsAt: parseDateSchema.nullish(),
   endsAt: parseDateSchema.nullish(),
   endsAfterDays: z.number().int().positive().nullish(),
@@
-  partnerTagIds: z.array(z.string()).nullable(),
+  partnerTagIds: z.array(z.string()).nullish(),

Then normalize them in the route to "absolute" and null respectively before validation/persistence.

Also applies to: 130-130

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/zod/schemas/bounties.ts` around lines 105 - 108, Keep the
create-bounty schema backward compatible by not requiring `startMode` and
`partnerTagIds` at parse time in the bounties Zod schemas; adjust the schema
around `startMode` and `partnerTagIds` so old payloads still validate, then
normalize missing values in the create-bounty route to `"absolute"` and `null`
before persistence. Update the corresponding schema definitions and any route
logic that consumes them so stale clients can still submit the old request shape
without failing validation.
apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts-2-5 (1)

2-5: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Filter by effective active period before syncing metrics.

The cron now checks only endsAt, and only after getSocialMetricsUpdates. This can call external metric providers for expired submissions and can submit draft rows before their effective startsAt.

Suggested fix
 import {
   getEffectiveBountyPeriod,
   isBountyExpired,
+  isBountyStarted,
 } from "`@/lib/bounty/bounty-period`";
-  const newMetrics = await getSocialMetricsUpdates({
-    bounty,
-    submissions,
-  });
-
   const minCount = bountyInfo.socialMetrics?.minCount;

   if (!minCount) {
     return logAndRespond(
       `Bounty ${bountyId} has no minimum social metrics count. Skipping...`,
     );
   }

-  const submissionById = new Map(submissions.map((s) => [s.id, s]));
+  const activeSubmissions = submissions.filter((submission) => {
+    if (!submission.programEnrollment) return false;
+
+    const { startsAt, endsAt } = getEffectiveBountyPeriod({
+      programEnrollment: submission.programEnrollment,
+      bounty,
+    });
+
+    return isBountyStarted(startsAt) && !isBountyExpired(endsAt);
+  });
+
+  const newMetrics =
+    activeSubmissions.length > 0
+      ? await getSocialMetricsUpdates({
+          bounty,
+          submissions: activeSubmissions,
+        })
+      : [];
+
+  const submissionById = new Map(activeSubmissions.map((s) => [s.id, s]));
@@
-    if (!submission || !submission.programEnrollment) {
+    if (!submission) {
       continue;
     }
-
-    const { endsAt } = getEffectiveBountyPeriod({
-      programEnrollment: submission.programEnrollment,
-      bounty,
-    });
-
-    if (isBountyExpired(endsAt)) {
-      continue;
-    }

Also applies to: 103-137

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(ee)/api/cron/bounties/sync-social-metrics/route.ts around
lines 2 - 5, The sync-social-metrics cron currently only filters on endsAt after
calling getSocialMetricsUpdates, which can still query providers for expired
items and include drafts before startsAt. Update the flow in the route handler
and related metric collection logic to first check the effective active window
using getEffectiveBountyPeriod and isBountyExpired before invoking
getSocialMetricsUpdates, and make sure only submissions whose current time falls
within the effective period are passed through for syncing.
apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts-66-70 (1)

66-70: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Return not_found for ineligible bounties.

bad_request confirms the bounty exists to a partner who cannot access it. Keep hidden eligibility failures indistinguishable from missing bounties.

Suggested fix
   if (!isEligible) {
     throw new DubApiError({
-      code: "bad_request",
-      message: "You are not eligible for this bounty.",
+      code: "not_found",
+      message: `Bounty ${bountyId} not found.`,
     });
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/app/`(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts
around lines 66 - 70, The ineligible-bounty branch in the route handler should
not reveal existence via bad_request; update the `if (!isEligible)` path in
`route.ts` to throw a `DubApiError` with `not_found` instead, keeping the
response indistinguishable from a missing bounty while preserving the existing
access-check flow in the same handler.
apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts-61-64 (1)

61-64: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Apply the default group fallback before eligibility checks.

isPartnerEligibleForBounty checks programEnrollment.groupId directly. For partners inheriting program.defaultGroupId, groupId can be null, so default-group bounties are incorrectly hidden.

Suggested fix
-  const isEligible = isPartnerEligibleForBounty({
-    programEnrollment,
+  const effectiveProgramEnrollment = {
+    ...programEnrollment,
+    groupId: programEnrollment.groupId ?? program.defaultGroupId,
+  };
+
+  const isEligible = isPartnerEligibleForBounty({
+    programEnrollment: effectiveProgramEnrollment,
     bounty,
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/app/`(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts
around lines 61 - 64, Apply the default group fallback before calling
isPartnerEligibleForBounty in the bounty route handler. The eligibility check
currently uses programEnrollment.groupId directly, so when that value is null
and the partner should inherit program.defaultGroupId, default-group bounties
get filtered out. Update the programEnrollment object used in the route’s
eligibility flow so groupId falls back to program.defaultGroupId before passing
it into isPartnerEligibleForBounty.
apps/web/scripts/migrations/backfill-group-joined-at.ts-6-12 (1)

6-12: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

This backfill also stamps non-joined enrollments.

The filter only checks groupId/groupJoinedAt, so any still-invited row gets groupJoinedAt = createdAt as if the partner had already joined. That will make relative bounty windows start too early for historical invites; the backfill needs to restrict itself to actually joined/approved enrollments.

Also applies to: 34-35

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/scripts/migrations/backfill-group-joined-at.ts` around lines 6 - 12,
The backfill query in the migration script is too broad and is stamping invited
enrollments as joined. Update the `programEnrollment.findMany` filter in the
backfill logic to include only actually joined/approved enrollments, using the
existing enrollment status fields alongside `groupId` and `groupJoinedAt`, so
that `groupJoinedAt` is only set for real joins. Also make the same restriction
wherever the backfill is applied later in the script to keep the selection
consistent.
apps/web/prisma/schema/bounty.prisma-32-47 (1)

32-47: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add the Prisma migration for these schema changes.

The selected files add new Bounty columns plus the BountyPartnerTag table, but there isn't a corresponding Prisma migration in this PR. Shipping the code without the DDL will break every path that reads or writes these fields.

Also applies to: 82-91

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/prisma/schema/bounty.prisma` around lines 32 - 47, Add the missing
Prisma migration for the new Bounty schema changes. The schema updates in Bounty
and the new BountyPartnerTag model need matching DDL, so create a migration that
adds the new columns/tables and any required indexes or constraints. Make sure
the migration is generated from the Prisma models in bounty.prisma and includes
the Bounty and BountyPartnerTag changes so the database stays in sync with the
schema.
apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts-60-67 (1)

60-67: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don’t let the pre-start window silently drop all submissions.

Line 63 still allows processing when the bounty starts in under 10 minutes, but canPartnerSubmitBounty now rejects those same partners as “not started yet.” That path creates no submissions and does not enqueue a retry for the actual start time.

🐛 Proposed direction
-      if (diffMinutes >= 10) {
+      if (diffMinutes > 0) {
         return logAndRespond(
           `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`,
         );
       }

If this route intentionally pre-creates drafts shortly before start, use an eligibility helper that skips only the start-date gate for that pre-start window.

Also applies to: 171-177

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(ee)/api/cron/bounties/create-draft-submissions/route.ts around
lines 60 - 67, The pre-start handling in create-draft-submissions is
inconsistent: the route still processes bounties within 10 minutes of start, but
canPartnerSubmitBounty rejects those partners as not started yet, so no drafts
are created and no retry is scheduled. Update the eligibility logic in the
bounty draft flow (create-draft-submissions/route.ts and the shared
canPartnerSubmitBounty path) so the pre-start window uses a helper that bypasses
only the start-date gate, while preserving the rest of the partner eligibility
checks.
apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts-93-110 (1)

93-110: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Include inherited default-group partners in the enrollment query.

Filtering only groupId in bountyGroupIds excludes enrollments with groupId: null that inherit bounty.program.defaultGroupId. Those partners never reach the later eligibility check.

🐛 Proposed fix
         ...(bountyGroupIds.length > 0 && {
-          groupId: {
-            in: bountyGroupIds,
-          },
+          OR: [
+            {
+              groupId: {
+                in: bountyGroupIds,
+              },
+            },
+            ...(bounty.program.defaultGroupId &&
+            bountyGroupIds.includes(bounty.program.defaultGroupId)
+              ? [{ groupId: null }]
+              : []),
+          ],
         }),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(ee)/api/cron/bounties/create-draft-submissions/route.ts around
lines 93 - 110, The enrollment query in the draft-submissions cron route is
filtering only by the explicit bounty group IDs, which drops partners whose
enrollment has a null groupId but still inherits the bounty program’s default
group. Update the query-building logic in the route’s enrollments lookup to
include these inherited default-group partners alongside the existing groupId
filter, so they can pass into the later eligibility check. Use the surrounding
enrollment query and the bounty program default-group handling in this route to
locate the fix.
apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts-78-83 (1)

78-83: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Normalize default-group enrollments before checking bounty eligibility.

canPartnerSubmitBounty only sees programEnrollment.groupId, so partners with groupId: null do not match bounties assigned to program.defaultGroupId even though line 17 fetches it. Pass an effective group ID into the helper or normalize the enrollment before filtering.

🐛 Proposed fix
       const eligiblePartnerIds = programEnrollments
-        .filter((programEnrollment) =>
-          canPartnerSubmitBounty({
-            programEnrollment,
+        .filter((programEnrollment) =>
+          canPartnerSubmitBounty({
+            programEnrollment: {
+              ...programEnrollment,
+              groupId: programEnrollment.groupId ?? program.defaultGroupId,
+            },
             bounty,
           }),
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts` around lines 78
- 83, Normalize program enrollments before the eligibility filter in
trigger-draft-bounty-submissions.ts so default-group partners can match bounties
assigned to program.defaultGroupId. Update the filtering path around
eligiblePartnerIds and canPartnerSubmitBounty to use an effective group ID
(falling back from programEnrollment.groupId to the program’s default group)
instead of relying only on programEnrollment.groupId. Keep the helper’s call
site aligned with that normalized value so null groupId enrollments are
evaluated correctly.
apps/web/lib/bounty/api/bounty-eligibility.ts-125-138 (1)

125-138: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Account for partners inheriting the default group.

Group eligibility only compares bounty groups to programEnrollment.groupId. Enrollments with groupId: null inherit the program default group, so default-group bounties are treated as ineligible unless callers manually normalize the group first.

🐛 Proposed direction
 type PartnerBountyEligibilityParams = {
+  programDefaultGroupId?: string | null;
   programEnrollment: Pick<
@@
 export function isPartnerEligibleForBounty({
+  programDefaultGroupId,
   programEnrollment,
   bounty,
 }: PartnerBountyEligibilityParams): boolean {
@@
-  const partnerGroupId = programEnrollment.groupId;
+  const partnerGroupId = programEnrollment.groupId ?? programDefaultGroupId;

Then pass program.defaultGroupId from routes/workflows that use this helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/bounty/api/bounty-eligibility.ts` around lines 125 - 138, The
group eligibility check currently only uses programEnrollment.groupId, so
enrollments that inherit the program default group are being rejected for
default-group bounties. Update the eligibility logic in the bounty eligibility
helper so it falls back to the program’s default group when groupId is null,
instead of comparing only partnerGroupId. Also update the routes/workflows that
call this helper to pass program.defaultGroupId through, so the helper can
resolve inherited group membership consistently.
apps/web/lib/bounty/api/bounty-eligibility.ts-148-228 (1)

148-228: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restore enrollment-status gating in the submission eligibility helper.

programEnrollment.status is part of the helper input but is never checked. Since submission flows now delegate to throwIfPartnerCannotSubmitBounty, pending/rejected enrollments can pass if group/tag/date checks match.

🛡️ Proposed fix
 export function canPartnerSubmitBounty({
   programEnrollment,
   bounty,
 }: PartnerBountyEligibilityParams): boolean {
+  if (!["approved", "invited"].includes(programEnrollment.status)) {
+    console.log(`Partner enrollment is not active for bounty ${bounty.id}.`);
+    return false;
+  }
+
   const isEligible = isPartnerEligibleForBounty({
     programEnrollment,
     bounty,
   });
@@
 export function throwIfPartnerCannotSubmitBounty({
   programEnrollment,
   bounty,
 }: PartnerBountyEligibilityParams) {
+  if (!["approved", "invited"].includes(programEnrollment.status)) {
+    throw new DubApiError({
+      code: "forbidden",
+      message: "You are not allowed to submit this bounty.",
+    });
+  }
+
   const isEligible = isPartnerEligibleForBounty({
     programEnrollment,
     bounty,
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/bounty/api/bounty-eligibility.ts` around lines 148 - 228, The
submission eligibility helpers currently ignore program enrollment status even
though PartnerBountyEligibilityParams includes programEnrollment.status. Update
canPartnerSubmitBounty and throwIfPartnerCannotSubmitBounty to gate on the
enrollment status before any bounty checks, so only approved/active enrollments
can proceed. Use the existing helper inputs and symbols like
canPartnerSubmitBounty, throwIfPartnerCannotSubmitBounty, and programEnrollment
to add the missing status validation consistently in both paths.
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/page.tsx-74-77 (1)

74-77: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fetch bounties with the invited partner's tag context.

This page already has a concrete partner enrollment, but getGroupBounties evaluates eligibility with an empty tag set. Tag-scoped bounties that this invited partner is eligible for will never show up here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/app/`(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/page.tsx
around lines 74 - 77, The invited partner’s bounty lookup is missing their tag
context, so tag-scoped eligible bounties are filtered out. Update the
`invite/page.tsx` flow where `getGroupBounties` is called to pass the invited
partner’s tags from the concrete enrollment/partner context instead of an empty
tag set. Use the existing `program`, `group`, and partner enrollment data in
this page to build the tag input for `getGroupBounties`, ensuring the invited
partner’s eligibility is evaluated correctly.
apps/web/lib/api/workflows/execute-move-group-workflow.ts-29-32 (1)

29-32: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Refresh groupMoveDisabledAt from Prisma before applying the move.

Line 63 is now checking the stale flag from the queued context, not the fresh enrollment row. If move rules were disabled after the workflow was queued, this handler can still move the partner anyway. Please select groupMoveDisabledAt in the refresh query and use that value for the skip guard.

Proposed fix
-  const { programId, partnerId, groupId, groupMoveDisabledAt } =
-    programEnrollment;
+  const { programId, partnerId, groupId } = programEnrollment;
@@
       select: {
         groupId: true,
+        groupMoveDisabledAt: true,
       },
     });
@@
-  if (groupMoveDisabledAt) {
+  if (programEnrollmentRefreshed.groupMoveDisabledAt) {
     console.log(
       `Partner ${partnerId} has group move rules disabled. Skipping..`,
     );

Also applies to: 42-63

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/api/workflows/execute-move-group-workflow.ts` around lines 29 -
32, Refresh the `groupMoveDisabledAt` value from Prisma before the move guard in
`executeMoveGroupWorkflow`; the current check is using the stale value from
`context.programEnrollment`. Update the refresh query in this workflow to select
`groupMoveDisabledAt` along with the other enrollment fields, then use the
freshly loaded enrollment row for the skip condition before moving the partner.
apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts-127-130 (1)

127-130: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use the effective bounty start for the net-new check.

These lines now skip the performanceScope === "new" guard whenever bounty.startsAt is null, but this PR also introduced the “when a partner joins” start mode. In that case, pre-existing customers can slip through as net-new. Compare customerFirstSaleAt against the effective start from the bounty-period helper, not the raw bounty.startsAt.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts` around lines
127 - 130, The net-new eligibility check in executeCompleteBountyWorkflow is
using bounty.startsAt directly, which breaks the “when a partner joins” start
mode. Update the performanceScope === "new" guard to compare customerFirstSaleAt
against the effective start returned by the bounty-period helper (the same start
used for the bounty period), and keep the existing null/ordering checks around
that derived value.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx-97-128 (1)

97-128: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't snapshot the toggle state from the initial field value.

Line 97 only reads defaultEnabled on the first render. In the edit flow, if the form is hydrated or reset with existing groupIds / partnerTagIds after mount, this section can stay collapsed while the form still carries those restrictions, so the UI says “All … eligible” even though IDs are selected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx
around lines 97 - 128, The BountyEligibility component is only initializing
local toggle state from defaultEnabled once, so it can drift from the actual
form values after hydration or reset. Update BountyEligibility to derive or sync
enabled from the current field values / props used in the edit flow (for example
the groupIds and partnerTagIds state coming from the parent) instead of
snapshotting only on first render, so the switch and collapsed content always
reflect the real eligibility restrictions.
🟡 Minor comments (7)
apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts-28-31 (1)

28-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the same validation error code as throwIfInvalidGroupIds.

Invalid groupIds and invalid partnerTagIds now go through equivalent validation helpers, but this one returns unprocessable_entity while the existing group path returns bad_request. That makes the same bounty POST/PATCH validation failure surface with two different API codes depending on which selector was wrong.

Suggested fix
     if (invalidPartnerTagIds?.length) {
       throw new DubApiError({
-        code: "unprocessable_entity",
+        code: "bad_request",
         message: `Invalid partner tag IDs detected: ${invalidPartnerTagIds.join(", ")}`,
       });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts` around lines 28 -
31, The validation helper in throwIfInvalidPartnerTagIds uses a different API
error code than throwIfInvalidGroupIds, so align the DubApiError code with the
group-id path. Update the error thrown from throwIfInvalidPartnerTagIds to use
the same bad_request code while keeping the existing invalid PartnerTagIds
message and the same invalidPartnerTagIds check.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx-112-114 (1)

112-114: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Don't render relative end timing as an absolute date.

This fixes the start label for relative bounties, but the preview still appends bounty.endsAt as a fixed date. In the create flow that value is synthesized from endsAfterDays, so the modal can show "When a partner joins → Jul …" even though the real rule is "ends N days after joining."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx
around lines 112 - 114, The bounty preview in confirm-create-bounty-modal still
treats relative end timing as an absolute date. Update the display logic around
the bounty.startsAt/bounty.endsAt rendering so that when the bounty is relative
(derived from endsAfterDays), the modal shows relative wording instead of
formatting bounty.endsAt with formatDate. Use the confirm-create-bounty-modal
component and its existing bounty timing fields to branch between absolute and
relative display.
apps/web/tests/webhooks/index.test.ts-57-68 (1)

57-68: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle nullable bounty timestamps in the webhook test schema.

BountySchema.startsAt is now nullable, but this extension still forces startsAt through z.string(). Relative-start bounty webhooks with startsAt: null will fail test parsing even though the payload is valid. endsAt should be treated the same way for consistency.

Suggested fix
 const bountyWebhookEventSchemaExtended = BountySchema.extend({
-  startsAt: z.string().transform((str) => new Date(str)),
-  endsAt: z.string().transform((str) => (str ? new Date(str) : null)),
+  startsAt: z
+    .string()
+    .nullable()
+    .transform((str) => (str ? new Date(str) : null)),
+  endsAt: z
+    .string()
+    .nullable()
+    .transform((str) => (str ? new Date(str) : null)),
   submissionsOpenAt: z
     .string()
     .nullable()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/tests/webhooks/index.test.ts` around lines 57 - 68, The webhook test
schema is still treating bounty timestamps as required strings, so nullable
payloads fail parsing. Update bountyWebhookEventSchemaExtended to match
BountySchema by allowing startsAt to be nullable before transforming it, and
apply the same nullable handling to endsAt for consistency. Keep the existing
transform pattern in the schema used by the webhook tests so relative-start
bounty events with null timestamps parse correctly.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx-157-157 (1)

157-157: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Replace font-regular with font-normal.

font-regular is not a valid Tailwind utility in this repo, so these labels won't get the intended 400 weight. Based on learnings, this codebase requires font-normal for normal font weight.

Also applies to: 174-174, 206-206

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx
at line 157, The label text in bounty-info.tsx is using an invalid Tailwind
class for normal weight; update the affected className entries in the bounty
info markup to use font-normal instead of font-regular. Make the same
replacement in the related label elements referenced in the diff, including the
sections around the bounty info rows so the intended 400 font weight is applied
consistently.

Source: Learnings

apps/web/lib/bounty/PARTNER_PROFILE_BOUNTY_ROUTES.md-293-301 (1)

293-301: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add language identifiers to these fenced blocks.

markdownlint is already flagging these three flow-diagram fences. Adding a language like text will keep the new doc lint-clean.

Also applies to: 305-317, 321-328

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/bounty/PARTNER_PROFILE_BOUNTY_ROUTES.md` around lines 293 - 301,
The fenced flow-diagram blocks in PARTNER_PROFILE_BOUNTY_ROUTES.md are missing
language identifiers, causing markdownlint failures. Update the affected fenced
blocks around the route flow diagrams to include a valid language tag such as
text, and make the same change for the other flagged diagram fences in the
document so the markdown remains lint-clean.

Source: Linters/SAST tools

apps/web/lib/bounty/PARTNER_PROFILE_BOUNTY_ROUTES.md-54-60 (1)

54-60: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document that partnerTags are stripped too.

This route description still says the response only strips groups, but the partner-facing schema now omits partnerTags as well. The doc should match the actual response contract here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/bounty/PARTNER_PROFILE_BOUNTY_ROUTES.md` around lines 54 - 60,
Update the bounty route behavior documentation to reflect the actual response
contract: in the route description for the partner profile bounty flow, note
that both `groups` and `partnerTags` are stripped from the response. Keep the
rest of the listed behavior unchanged and make the wording match the
partner-facing schema used by this route.
apps/web/tests/bounties/index.test.ts-121-130 (1)

121-130: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Register the cleanup before the assertions.

If the create call succeeds but one of the assertions throws, onTestFinished never gets registered and the bounty stays behind for later tests. Move the cleanup right after the POST and guard it on bounty?.id.

Suggested change
   const { status, data: bounty } = await http.post<Bounty>({
     path: "/bounties",
     body: {
       ...submissionBounty,
       groupIds: [E2E_PARTNER_GROUP.id],
       startMode: "relative",
       endsAfterDays: 14,
       endsAt: null,
     },
   });
+
+  if (bounty?.id) {
+    onTestFinished(async () => {
+      await h.deleteBounty(bounty.id);
+    });
+  }

   expect(status).toEqual(200);
   expect(bounty).toMatchObject({
     id: expect.any(String),
     startMode: "relative",
     endsAfterDays: 14,
     endsAt: null,
   });
-
-  onTestFinished(async () => {
-    await h.deleteBounty(bounty.id);
-  });
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/tests/bounties/index.test.ts` around lines 121 - 130, Register the
cleanup immediately after the bounty is created in the test so it always runs
even if later assertions fail. In the relevant test around the POST result, move
the onTestFinished cleanup before the expect calls and guard the delete with
bounty?.id so the bounty is removed whenever creation succeeds. Use the existing
bounty and h.deleteBounty references to locate and update the test flow.

@steven-tey steven-tey changed the title Limit bounty eligibility to selected partner tags & dynamic bounty start date FEAT: Dynamic bounty start date Jul 9, 2026
@devkiran

Copy link
Copy Markdown
Collaborator Author

Closing this PR in favor of #4187

@devkiran devkiran closed this Jul 21, 2026
@devkiran
devkiran deleted the limit-bounties-partner-tags branch July 22, 2026 04:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants