-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Follow-up improvements to partner groups and imports #2754
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
… update related functions accordingly
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Rate limit exceeded@steven-tey has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 10 minutes and 59 seconds before requesting another review. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📒 Files selected for processing (4)
WalkthroughAdds a PartnerStack "import-groups" step and group-aware partner import, implements bulk ban/archive partner actions with new Zod schemas and related side effects (audit, notifications, link/commission updates), makes Program.defaultFolderId/defaultGroupId required in Prisma/Zod, wires the new cron route action, and applies minor partner-action fixes. Changes
Sequence Diagram(s)sequenceDiagram
participant Starter as start-partnerstack-import
participant Queue as partnerStackImporter.queue
participant Cron as /api/cron/import/partnerstack
participant ImportG as import-groups
participant PS as PartnerStackApi
participant DB as Prisma
participant ImportP as import-partners
Starter->>Queue: enqueue { action: "import-groups", importId, programId, userId }
Cron->>ImportG: handle payload.action == "import-groups"
ImportG->>DB: fetch program (workspaceId)
ImportG->>PS: listGroups()
PS-->>ImportG: groups[]
loop for each non-default group
ImportG->>DB: upsert partnerGroup (programId, slug)
end
ImportG->>Queue: enqueue { action: "import-partners", ... }
Cron->>ImportP: handle payload.action == "import-partners"
ImportP->>PS: fetch partners (batched)
ImportP->>DB: upsert partners (use groupMap, resolve countries)
ImportP->>DB: delete orphan non-default groups when finished
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches
🧪 Generate unit tests
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
🔭 Outside diff range comments (2)
apps/web/lib/actions/partners/create-program.ts (1)
189-194: Ensure import-groups enqueues the “import-partners” stepI confirmed that the cron route in
apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
handles both"import-groups"and"import-partners"actions. However, I didn’t find anypartnerStackImporter.queueinvocation for"import-partners"in your importer code—specifically in
apps/web/lib/partnerstack/import-groups.ts, which currently only re-queues itself for paging.Please add (or verify) a queue call for
"import-partners"once the final groups batch has been imported. For example, at the end ofimportGroups:// apps/web/lib/partnerstack/import-groups.ts … if (!hasMore) { // after processing all groups, kick off the partners import await partnerStackImporter.queue({ importId: payload.importId, userId: payload.userId, programId: payload.programId, action: "import-partners", }); }Once that’s in place, the two-step flow (groups → partners) will be fully wired up.
apps/web/lib/partnerstack/import-partners.ts (1)
59-66: Guard against unknown group slugs to prevent runtime errorsIf PartnerStack returns a group slug that isn’t yet present in
program.groups(e.g., a new PS group not imported yet),groupMap[slug]isundefined, causing crashes when accessinggroup.idlater. Fall back to the default group when the lookup misses. This aligns with the retrieved learning that a default group always exists.Apply this diff:
- group: groupMap[partner.group?.slug ?? DEFAULT_PARTNER_GROUP.slug], + group: + groupMap[partner.group?.slug ?? DEFAULT_PARTNER_GROUP.slug] || + groupMap[DEFAULT_PARTNER_GROUP.slug],
🧹 Nitpick comments (14)
apps/web/lib/partners/approve-partner-enrollment.ts (1)
55-55: Prefer nullish coalescing over logical OR for group fallbackUse
??instead of||so an empty string isn't treated as “absent” and silently fall back to the default group.Apply:
- groupId: groupId || program.defaultGroupId, + groupId: groupId ?? program.defaultGroupId,Note: If you want to treat empty string as missing, enforce that in the zod schema (e.g., transform
""toundefined) instead of relying on||.apps/web/lib/zod/schemas/partners.ts (2)
621-634: Bulk ban schema looks solid; consider DRYing the partnerIds dedupe patternThe schema reads well: bounded, deduped partner IDs and a reason enum aligned to BAN_PARTNER_REASONS.
Since this dedupe/max/min pattern is repeated across several bulk schemas, consider extracting a reusable helper schema, e.g.:
// near top of file const dedupedIdArraySchema = z.array(z.string()).max(100).min(1).transform((v) => [...new Set(v)]);Then use:
partnerIds: dedupedIdArraySchema
641-648: Bulk archive schema is correct; same DRY opportunity for partnerIdsConsistent bounds and deduping.
If you introduce
dedupedIdArraySchemaas above, you can reuse it here as well to keep schemas consistent and concise.apps/web/lib/actions/partners/bulk-archive-partners.ts (3)
41-42: Clarify the error message when no eligible partners are foundThe message may be misleading if all provided partners are already archived. Make it explicit.
Apply:
- throw new Error("You must provide at least one valid partner ID."); + throw new Error("No eligible partners found to archive for this program.");
45-54: Avoid unnecessary writes: filter updateMany to non-archived enrollmentsThis prevents no-op updates and reduces write load.
Apply:
await prisma.programEnrollment.updateMany({ where: { partnerId: { in: partnerIds, }, programId, + status: { + not: "archived", + }, }, data: { status: "archived", }, });
28-37: Remove unused select field
idfromprogramEnrollmentisn’t used later; drop it to reduce payload.Apply:
- select: { - id: true, - partner: { + select: { + partner: { select: { id: true, name: true, email: true, }, }, },apps/web/lib/actions/partners/bulk-ban-partners.ts (6)
33-36: Prefer enum constant over raw string for status filterUse ProgramEnrollmentStatus.banned for consistency and type-safety instead of the string literal "banned".
- status: { - not: "banned", - }, + status: { + not: ProgramEnrollmentStatus.banned, + },
66-81: Avoid resetting bannedAt/bannedReason for already-banned enrollmentsThe updateMany currently targets all enrollments for the partnerIds, including those already banned. This will overwrite bannedAt and bannedReason on previously banned partners. Align the update scope with the earlier selection (status != banned).
- prisma.programEnrollment.updateMany({ - where: { - ...commonWhere, - }, + prisma.programEnrollment.updateMany({ + where: { + ...commonWhere, + status: { not: ProgramEnrollmentStatus.banned }, + }, data: { status: ProgramEnrollmentStatus.banned, bannedAt: new Date(), bannedReason: parsedInput.reason, groupId: null, clickRewardId: null, leadRewardId: null, saleRewardId: null, discountId: null, }, }),
59-65: Scope updates to the actually eligible partnerIdsToday, commonWhere uses the input partnerIds (which can include non-enrolled or already-banned partners). For clearer intent and fewer no-ops, derive partnerIds from programEnrollments (status != banned) and use that set consistently for updates, cache invalidation, and notifications.
- const commonWhere = { - programId, - partnerId: { - in: partnerIds, - }, - }; + const eligiblePartnerIds = programEnrollments.map((pe) => pe.partner.id); + + const commonWhere = { + programId, + partnerId: { + in: eligiblePartnerIds, + }, + };Note: This aligns DB updates with side-effects already based on programEnrollments.
Also applies to: 27-53
131-146: Include reason in audit logs for better traceabilityAdding the banned reason to the audit description improves investigations and support handling.
- description: `Partner ${partner.id} banned`, + description: `Partner ${partner.id} banned (${parsedInput.reason})`,
149-157: Micro: avoid an extra read for Program fields in the background taskYou already know programId and workspace context. If you have program details at hand earlier (e.g., from a prior step), pass { name, supportEmail } down to avoid this additional query. Not critical—OK as is.
92-101: Use Prisma enum constants for statuses in bulk-ban-partners.tsFor consistency with
ProgramEnrollmentStatus, replace raw string statuses withCommissionStatusandPayoutStatusenum values:
- File:
apps/web/lib/actions/partners/bulk-ban-partners.ts
Lines: 92–101 and 102–111Diff:
-import { ProgramEnrollmentStatus } from "@prisma/client"; +import { ProgramEnrollmentStatus, CommissionStatus, PayoutStatus } from "@prisma/client"; prisma.commission.updateMany({ where: { ...commonWhere, - status: "pending", + status: CommissionStatus.pending, }, data: { - status: "canceled", + status: CommissionStatus.canceled, }, }), prisma.payout.updateMany({ where: { ...commonWhere, - status: "pending", + status: PayoutStatus.pending, }, data: { - status: "canceled", + status: PayoutStatus.canceled, }, }),[optional_refactors_recommended]
apps/web/lib/partnerstack/import-groups.ts (1)
45-51: Upsert should update the group name to avoid stale metadataCurrently
update: {}means a renamed PartnerStack group won’t reflect in our DB. Updatingnameon conflict keeps data consistent while slug remains the stable key.Apply this diff:
await prisma.partnerGroup.upsert({ where: { programId_slug: { programId, slug: group.slug, }, }, create: { id: createId({ prefix: "grp_" }), programId, name: group.name, slug: group.slug, }, - update: {}, + update: { + name: group.name, + }, });apps/web/lib/partnerstack/import-partners.ts (1)
112-123: Make country resolution case-insensitive and normalize codesPartnerStack may send country names/codes with varying casing. Normalizing improves match rate and reduces noisy logs.
Apply this diff:
- // Resolve partner's country: check if it's a valid code first, - // otherwise fall back to lookup by country name because PS returns the name in some cases + // Resolve partner's country: prefer ISO code if present (case-insensitive), + // otherwise fall back to lookup by country name (case-insensitive) const country = partner.address?.country; - const countryCode = country - ? COUNTRY_CODES.includes(country) - ? country - : COUNTRY_NAME_TO_CODE.get(country) ?? null + const countryCode = country + ? (() => { + const raw = country.trim(); + const asCode = raw.toUpperCase(); + if (COUNTRY_CODES.includes(asCode)) { + return asCode; + } + // name lookup is case-insensitive + return COUNTRY_NAME_TO_CODE.get(raw.toLowerCase()) ?? null; + })() : null; - if (country && !countryCode) { - console.log(`Country code not found for country ${country}`); - } + if (country && !countryCode) { + console.warn(`Country code not found for country "${country}"`); + }And make the name-to-code map case-insensitive at definition:
-const COUNTRY_NAME_TO_CODE = new Map( - Object.entries(COUNTRIES).map(([code, name]) => [name, code]), -); +const COUNTRY_NAME_TO_CODE = new Map( + Object.entries(COUNTRIES).map(([code, name]) => [name.toLowerCase(), code]), +);
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (15)
apps/web/app/(ee)/api/cron/import/partnerstack/route.ts(2 hunks)apps/web/lib/actions/partners/archive-partner.ts(1 hunks)apps/web/lib/actions/partners/bulk-archive-partners.ts(1 hunks)apps/web/lib/actions/partners/bulk-ban-partners.ts(1 hunks)apps/web/lib/actions/partners/create-program.ts(1 hunks)apps/web/lib/actions/partners/start-partnerstack-import.ts(1 hunks)apps/web/lib/partners/approve-partner-enrollment.ts(1 hunks)apps/web/lib/partnerstack/api.ts(2 hunks)apps/web/lib/partnerstack/import-groups.ts(1 hunks)apps/web/lib/partnerstack/import-partners.ts(7 hunks)apps/web/lib/partnerstack/schemas.ts(3 hunks)apps/web/lib/partnerstack/types.ts(2 hunks)apps/web/lib/zod/schemas/partners.ts(1 hunks)apps/web/lib/zod/schemas/programs.ts(1 hunks)packages/prisma/schema/program.prisma(1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-14T05:00:23.180Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/api/og/program/route.tsx:63-64
Timestamp: 2025-08-14T05:00:23.180Z
Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.
Applied to files:
apps/web/lib/partnerstack/import-partners.ts
📚 Learning: 2025-08-14T05:17:51.787Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.787Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.
Applied to files:
apps/web/lib/partnerstack/import-partners.ts
🧬 Code Graph Analysis (8)
apps/web/lib/partnerstack/types.ts (1)
apps/web/lib/partnerstack/schemas.ts (1)
partnerStackGroup(25-30)
apps/web/lib/zod/schemas/partners.ts (1)
packages/prisma/client.ts (1)
PartnerBannedReason(10-10)
apps/web/lib/partnerstack/import-groups.ts (4)
apps/web/lib/partnerstack/types.ts (1)
PartnerStackImportPayload(18-20)apps/web/lib/partnerstack/importer.ts (1)
partnerStackImporter(47-47)apps/web/lib/partnerstack/api.ts (1)
PartnerStackApi(19-138)apps/web/lib/api/create-id.ts (1)
createId(57-66)
apps/web/app/(ee)/api/cron/import/partnerstack/route.ts (1)
apps/web/lib/partnerstack/import-groups.ts (1)
importGroups(7-59)
apps/web/lib/actions/partners/bulk-archive-partners.ts (3)
apps/web/lib/actions/safe-action.ts (1)
authActionClient(33-82)apps/web/lib/zod/schemas/partners.ts (1)
bulkArchivePartnersSchema(641-648)apps/web/lib/api/audit-logs/record-audit-log.ts (1)
recordAuditLog(43-63)
apps/web/lib/actions/partners/bulk-ban-partners.ts (4)
apps/web/lib/actions/safe-action.ts (1)
authActionClient(33-82)apps/web/lib/zod/schemas/partners.ts (2)
bulkBanPartnersSchema(621-634)BAN_PARTNER_REASONS(51-58)packages/prisma/client.ts (1)
ProgramEnrollmentStatus(18-18)apps/web/lib/api/audit-logs/record-audit-log.ts (1)
recordAuditLog(43-63)
apps/web/lib/partnerstack/api.ts (2)
apps/web/lib/partnerstack/types.ts (2)
PartnerStackListResponse(12-16)PartnerStackGroup(22-22)apps/web/lib/partnerstack/schemas.ts (1)
partnerStackGroup(25-30)
apps/web/lib/partnerstack/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)apps/web/lib/partnerstack/types.ts (1)
PartnerStackPartner(24-24)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (7)
apps/web/lib/zod/schemas/programs.ts (1)
41-41: defaultGroupId now required — verify migration & callsites
- Confirm
defaultGroupIdis non-nullable inpackages/prisma/schema/program.prisma(String, no?).- Ensure there’s a corresponding Prisma migration (e.g. in
packages/prisma/migrations) that:
• AltersdefaultGroupIdto NOT NULL
• Backfills existing rows with a valid group ID- Verify that migration has been applied in your development and production databases.
- Search for any remaining nullable checks or non-null assertions on
defaultGroupId:
• Found in
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx
defaultValues: { groupId: program?.defaultGroupId || "" }
– ifprogramis always defined, you can simplify toprogram.defaultGroupId.- Remove or refactor any stale
?chains or!assertions arounddefaultGroupIdwhere no longer needed.apps/web/lib/actions/partners/archive-partner.ts (1)
27-30: LGTM: composite key field order is irrelevantReordering the fields within the Prisma composite key object is a no-op. Safe change.
apps/web/lib/actions/partners/start-partnerstack-import.ts (1)
49-54: Kickoff step switched to "import-groups" — aligns with new workflowGood handoff after credentials validation. This should chain into the partners import next.
apps/web/lib/partnerstack/types.ts (2)
6-7: Importing partnerStackGroup schema — OK
22-23: New PartnerStackGroup type export — OKMatches the schema and supports API listGroups().
apps/web/app/(ee)/api/cron/import/partnerstack/route.ts (1)
5-5: LGTM: New "import-groups" action is correctly wired into the cron route
- The import added and the switch-case branch are correct and align with the updated import flow (groups → partners).
Also applies to: 26-28
apps/web/lib/partnerstack/schemas.ts (1)
4-4: Enum addition for "import-groups" fits the new flowThe new import step integrates well with the multi-step importer and keeps validation centralized.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🔭 Outside diff range comments (1)
apps/web/lib/partnerstack/import-partners.ts (1)
189-192: Do not override enrollment status to "approved" on updateThis will unintentionally unban or unarchive partners that were bulk-banned/archived elsewhere in this PR. Only set status on create; preserve it on update.
Minimal change:
- update: { - status: "approved", - }, + update: { + // Preserve existing status; do not override to "approved" + },If your Prisma version disallows empty updates, consider updating only the group/reward fields (if you want to keep them in sync) while omitting the status field. Alternatively, conditionally update based on current status (e.g., skip updates when status in ["banned", "archived"]).
🧹 Nitpick comments (3)
apps/web/lib/partnerstack/import-partners.ts (3)
16-19: Normalize COUNTRY_NAME_TO_CODE to be case-insensitivePartnerStack may return country names with varying casing/whitespace. Pre-normalizing the map keys avoids misses.
-const COUNTRY_NAME_TO_CODE = new Map( - Object.entries(COUNTRIES).map(([code, name]) => [name, code]), -); +const COUNTRY_NAME_TO_CODE = new Map( + Object.entries(COUNTRIES).map(([code, name]) => [name.toLowerCase(), code]), +);
32-34: Type groupMap for safety and better inferenceExplicit typing helps TS catch undefined access later and keeps createPartner’s
group: PartnerGroupcontract honest.-const groupMap = Object.fromEntries( - program.groups.map((group) => [group.slug, group]), -); +const groupMap: Record<string, PartnerGroup> = Object.fromEntries( + program.groups.map((group) => [group.slug, group] as const), +);
144-151: Normalize/trim country input and prefer structured warningHandle case/whitespace for both codes and names to improve resolution. Also prefer warn-level logging (or structured logging if available).
- const country = partner.address?.country; - const countryCode = country - ? COUNTRY_CODES.includes(country) - ? country - : COUNTRY_NAME_TO_CODE.get(country) ?? null - : null; + const rawCountry = partner.address?.country?.trim(); + const countryCode = rawCountry + ? COUNTRY_CODES.includes(rawCountry.toUpperCase()) + ? rawCountry.toUpperCase() + : COUNTRY_NAME_TO_CODE.get(rawCountry.toLowerCase()) ?? null + : null; - if (country && !countryCode) { - console.log(`Country code not found for country ${country}`); + if (rawCountry && !countryCode) { + console.warn(`Country code not found for country "${rawCountry}"`); }Optionally, replace console.warn with logImportError to keep a consistent error stream:
await logImportError({ ...commonImportLogInputs, code: "INVALID_COUNTRY", message: `Unable to resolve country "${rawCountry}"`, });Also applies to: 153-155
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
apps/web/lib/partnerstack/import-groups.ts(1 hunks)apps/web/lib/partnerstack/import-partners.ts(8 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/lib/partnerstack/import-groups.ts
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-14T05:00:23.180Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/api/og/program/route.tsx:63-64
Timestamp: 2025-08-14T05:00:23.180Z
Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.
Applied to files:
apps/web/lib/partnerstack/import-partners.ts
📚 Learning: 2025-08-14T05:17:51.787Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.787Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.
Applied to files:
apps/web/lib/partnerstack/import-partners.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (5)
apps/web/lib/partnerstack/import-partners.ts (5)
2-3: Typed Prisma/util imports look goodUsing PartnerGroup/Program and COUNTRY_CODES is appropriate for the new logic.
118-124: createPartner signature updated to require a group: OKRequiring PartnerGroup here is fine given the earlier fallback. Ensure the call-site fix in Line 64 lands to avoid undefined.
157-170: Prisma upsert with empty update: please verify; consider a minimal, safe updateUsing
update: { /* do nothing */ }may be rejected by Prisma (depending on version/generator) because update data can’t be empty. If that’s the case, make the update a no-op that doesn’t overwrite user-managed fields.Proposed minimal update that’s effectively non-destructive:
- update: { - // do nothing - }, + update: { + // Keep existing values; only set country if resolvable + country: countryCode ?? undefined, + },If Prisma accepts an empty update in your setup, feel free to keep it empty and add a comment that it’s intentional.
183-188: Propagating group-linked reward/discount IDs on create looks correctThis aligns enrollment with the assigned group at the time of creation.
76-106: Confirm deletion scope for empty partner groups
The cleanup logic inapps/web/lib/partnerstack/import-partners.ts(lines 76–106) deletes every non-default group with no partners. Because thePartnerGroupmodel has no source/provider or externalId flag, it will also remove any user-created or future-planned empty groups.Please confirm intended behavior:
- If you only want to delete groups created by this import, you’ll need to extend the schema (e.g., add a
sourceorexternalIdfield) and populate it during import, then filter on that field here.- Otherwise, document that all empty groups (except the default) will be purged and ensure this aligns with product requirements.
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🧹 Nitpick comments (3)
apps/web/lib/actions/partners/create-program.ts (3)
60-124: Avoid external I/O (logo upload) inside the DB transaction
storage.upload(...)runs inside the transaction. If the transaction rolls back after the upload, you’ll leak an orphaned file in R2. Keep DB transactions short and deterministic; do I/O outside and compensate on failure.Here’s a minimal restructuring pattern:
// Before starting the transaction: const programId = createId({ prefix: "prog_" }); const logoUrl = uploadedLogo ? (await storage.upload(`programs/${programId}/logo_${nanoid(7)}`, uploadedLogo)).url : null; try { const program = await prisma.$transaction(async (tx) => { // ... use programId and logoUrl here ... // tx.folder.upsert(...) // tx.program.create({ data: { id: programId, ...(logoUrl && { logo: logoUrl }), ... } }) // tx.partnerGroup.upsert(...) // tx.project.update(...) return programData; }); // continue with queueing import, waitUntil, redirect, etc. } catch (e) { // Best-effort cleanup if transaction failed after upload if (logoUrl) { await storage.delete(logoUrl.replace(`${R2_URL}/`, "")); } throw e; }This preserves the deterministic transaction boundary and prevents storage leaks on rollback.
63-83: Consider ensuring creator access on an existing 'Partner Links' folder
tx.folder.upsert(..., update: {})won’t adjust permissions if the folder already exists. If a prior run (or another actor) created the folder without this user, the creator might lack access to the default folder referenced by the new program.Options:
- In the
updatebranch, try to connect (or connectOrCreate if applicable) the current user with an appropriate role.- Alternatively, check membership and add it in a follow-up write within the transaction if missing.
If the product guarantees a single program creation path per workspace so the create branch always runs, this can be ignored.
148-166: Unconditional foldersUsage increment may drift if folder already existed
foldersUsage: { increment: 1 }runs regardless of whether the upsert actually created the folder. If the folder previously existed, the usage metric could overcount.If usage is meant to reflect actual folder creations, only increment when the upsert creates a new record. Since Prisma’s
upsertdoesn’t return a created/updated flag, a common pattern is:
findUniqueto detect existence,createif missing (and increment usage),- otherwise reuse the existing folder without incrementing.
If your domain guarantees the folder doesn’t pre-exist at program creation time, feel free to skip this.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (3)
apps/web/lib/actions/partners/create-program.ts(2 hunks)apps/web/lib/zod/schemas/programs.ts(1 hunks)packages/prisma/schema/program.prisma(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/prisma/schema/program.prisma
🧰 Additional context used
🧬 Code Graph Analysis (1)
apps/web/lib/actions/partners/create-program.ts (1)
apps/web/lib/api/create-id.ts (1)
createId(57-66)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (2)
apps/web/lib/actions/partners/create-program.ts (2)
60-83: Good call consolidating creation into a single transactionWrapping folder upsert + program creation + related setup in one transaction is a solid improvement for consistency and failure-atomicity.
193-194: Integration of “import-groups” action verifiedI’ve confirmed that the new action string is fully wired up:
- partnerStackImportSteps schema includes
"import-groups"- Both start-partnerstack-import and create-program actions use
action: "import-groups"- apps/web/lib/partnerstack/import-groups.ts implements
importGroups(...)and enqueues the next step (import-partners)- apps/web/app/(ee)/api/cron/import/partnerstack/route.ts handles
case "import-groups": await importGroups(payload);No further changes needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🔭 Outside diff range comments (1)
apps/web/lib/partnerstack/import-partners.ts (1)
189-191: Keep enrollments in sync with PartnerStack group mappings on updatesUpsert’s update branch only flips status to "approved". If a returning partner’s group changed in PartnerStack, we won’t realign groupId and reward/discount fields, causing inconsistent incentives. Mirror the create branch fields in update.
await prisma.programEnrollment.upsert({ where: { partnerId_programId: { partnerId, programId: program.id, }, }, create: { programId: program.id, partnerId, status: "approved", groupId: group.id, clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, discountId: group.discountId, }, update: { - status: "approved", + status: "approved", + groupId: group.id, + clickRewardId: group.clickRewardId, + leadRewardId: group.leadRewardId, + saleRewardId: group.saleRewardId, + discountId: group.discountId, }, });Also applies to: 180-188
♻️ Duplicate comments (3)
apps/web/lib/zod/schemas/programs.ts (1)
40-41: Align Zod nullability with Prisma for defaultFolderId (create + update schemas)Prisma now requires Program.defaultFolderId to be non-null. Keeping Zod as nullable risks admitting invalid payloads that fail at DB time. Tighten both create and update schemas.
Apply this diff:
- defaultFolderId: z.string().nullable(), + defaultFolderId: z.string(), @@ - defaultFolderId: z.string().nullable(), + defaultFolderId: z.string(),If you intentionally accept null at the API edge, ensure server-side normalization strips it before persistence to avoid constraint violations.
Also applies to: 61-61
apps/web/lib/partnerstack/api.ts (1)
66-75: Use PAGE_LIMIT + URLSearchParams; add explicit return type; support cursor param for consistencyCurrent implementation hardcodes
limit=100, omits an explicit return type, and doesn’t allow passing astarting_aftercursor like other list methods. For consistency and to enable pagination by callers, align this withlistPartners/listCustomers/listCommissions.Apply this diff:
- async listGroups() { - const { - data: { items }, - } = - await this.fetch<PartnerStackListResponse<PartnerStackGroup>>( - `/groups?limit=100`, - ); - - return partnerStackGroup.array().parse(items); - } + async listGroups({ startingAfter }: { startingAfter?: string }): Promise<PartnerStackGroup[]> { + const searchParams = new URLSearchParams(); + searchParams.append("limit", PAGE_LIMIT.toString()); + if (startingAfter) { + searchParams.append("starting_after", startingAfter); + } + const { + data: { items }, + } = await this.fetch<PartnerStackListResponse<PartnerStackGroup>>( + `/groups?${searchParams.toString()}`, + ); + return partnerStackGroup.array().parse(items); + }apps/web/lib/partnerstack/import-partners.ts (1)
64-65: Fix: Fallback when group slug is unknown (prevents runtime crash)If PartnerStack returns a slug not present in groupMap, the current lookup yields undefined and createPartner will crash when accessing group.id. Fallback to the default group when the slug isn’t found.
- group: groupMap[partner.group?.slug ?? DEFAULT_PARTNER_GROUP.slug], + // Fallback to default group if slug is unknown or missing + group: + groupMap[partner.group?.slug ?? ""] ?? + groupMap[DEFAULT_PARTNER_GROUP.slug],
🧹 Nitpick comments (10)
apps/web/lib/partners/approve-partner-enrollment.ts (2)
49-56: Prefer nullish coalescing for group resolution; keep types narrowedUsing || treats empty strings as falsy. Given the guard above, use ?? to only fall back when groupId is nullish. If TS complains about possible undefined, precompute a narrowed variable.
Apply this diff:
- groupId: groupId || program.defaultGroupId, + groupId: groupId ?? program.defaultGroupId,Optionally, narrow once and reuse (outside the selected lines):
// Align the guard to nullish checks if you adopt ?? (optional): if (groupId == null && program.defaultGroupId == null) { throw new Error("No group ID provided and no default group ID found."); } const resolvedGroupId = (groupId ?? program.defaultGroupId) as string; const group = await getGroupOrThrow({ programId, groupId: resolvedGroupId, includeRewardsAndDiscount: true, });
69-124: Make enrollment + link update atomic with a Prisma transactionThe enrollment update and link update run independently. If one succeeds and the other fails, you can end up in a partially-approved state. Prefer a single transaction to guarantee atomicity.
Example refactor (simplified):
const { programEnrollment, updatedLink } = await prisma.$transaction(async (tx) => { const programEnrollment = await tx.programEnrollment.update({ // ...same where/include data: { // ...same data }, include: { partner: { include: { users: { where: { notificationPreferences: { applicationApproved: true } }, include: { user: true }, }, }, }, }, }); const updatedLink = link ? await tx.link.update({ where: { id: link.id }, data: { programId, partnerId, folderId: program.defaultFolderId, }, include: { tags: { select: { tag: true } } }, }) : null; return { programEnrollment, updatedLink }; });packages/prisma/schema/program.prisma (1)
34-35: Consider adding explicit relations + onDelete guardrails for defaultsTo prevent dangling IDs and accidental deletes, model these as relations with explicit onDelete behavior. Optional, but improves integrity and DX.
Example schema sketch (adjust names/behaviors to your models):
model Program { // ... defaultFolderId String defaultFolder Folder @relation(fields: [defaultFolderId], references: [id], onUpdate: Cascade, onDelete: Restrict) defaultGroupId String defaultGroup PartnerGroup @relation(fields: [defaultGroupId], references: [id], onUpdate: Cascade, onDelete: Restrict) @@index([defaultFolderId]) @@index([defaultGroupId]) }Note: This aligns with the learning that a default partner group always exists in Dub, so making defaultGroupId required is consistent with your domain model.
apps/web/lib/actions/partners/create-program.ts (1)
62-80: Pre-existing folder may lack membership for the creating userYou add the user as owner only on the create path. If "Partner Links" already exists, the user won’t be attached via users.create. If that’s a requirement, consider a post-upsert connectOrCreate to ensure access for the current user.
apps/web/lib/partnerstack/import-groups.ts (1)
28-55: Handle pagination and keep group names in sync on upsert
- If a workspace has > PAGE_LIMIT groups, the current single call will miss additional pages.
- Upsert
update: {}won’t refresh a group’s name if it changes in PartnerStack; recommend syncing the name.Suggested refactor: iterate through pages using
starting_after(using the last item’skey) and update the name on conflict.- const groups = await partnerStackApi.listGroups(); - - if (groups.length > 0) { - for (const group of groups) { - // Default group is already created in the program creation - if (group.default) { - continue; - } - - await prisma.partnerGroup.upsert({ - where: { - programId_slug: { - programId, - slug: group.slug, - }, - }, - create: { - id: createId({ prefix: "grp_" }), - programId, - name: group.name, - slug: group.slug, - }, - update: {}, - }); - - console.log(`Imported group ${group.name}.`); - } - } + let startingAfter: string | undefined = undefined; + while (true) { + const groups = await partnerStackApi.listGroups({ startingAfter }); + if (groups.length === 0) { + break; + } + for (const group of groups) { + // Default group is already created in the program creation + if (group.default) { + continue; + } + await prisma.partnerGroup.upsert({ + where: { + programId_slug: { + programId, + slug: group.slug, + }, + }, + create: { + id: createId({ prefix: "grp_" }), + programId, + name: group.name, + slug: group.slug, + }, + update: { + // Keep name in sync if it changed in PartnerStack + name: group.name, + }, + }); + console.log(`Imported group ${group.name}.`); + } + startingAfter = groups[groups.length - 1]?.key; + }apps/web/lib/actions/partners/bulk-archive-partners.ts (1)
18-38: Use typed enum for status and avoid redundant writes
- Replace string literals "archived" with ProgramEnrollmentStatus.archived.
- Also filter updateMany to skip already-archived rows to reduce no-op writes.
import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { bulkArchivePartnersSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; +import { ProgramEnrollmentStatus } from "@dub/prisma/client"; import { waitUntil } from "@vercel/functions";const programEnrollments = await prisma.programEnrollment.findMany({ where: { partnerId: { in: partnerIds, }, programId, status: { - not: "archived", + not: ProgramEnrollmentStatus.archived, }, },await prisma.programEnrollment.updateMany({ where: { partnerId: { in: partnerIds, }, programId, + status: { + not: ProgramEnrollmentStatus.archived, + }, }, data: { - status: "archived", + status: ProgramEnrollmentStatus.archived, }, });Also applies to: 44-53, 3-7
apps/web/lib/actions/partners/bulk-ban-partners.ts (3)
66-81: Constrain updateMany to skip already-banned rowsAdding a status filter avoids redundant writes and reduces row-level churn.
await prisma.$transaction([ prisma.programEnrollment.updateMany({ where: { ...commonWhere, + status: { + not: ProgramEnrollmentStatus.banned, + }, }, data: { status: ProgramEnrollmentStatus.banned, bannedAt: new Date(), bannedReason: parsedInput.reason, groupId: null,
14-16: Unify enum import source for consistencyElsewhere in the codebase enums/types come from @dub/prisma/client. Prefer importing ProgramEnrollmentStatus from the same package.
-import { ProgramEnrollmentStatus } from "@prisma/client"; +import { ProgramEnrollmentStatus } from "@dub/prisma/client";
164-185: Guard email-sending with try/catch to isolate failuresNon-critical email failures shouldn’t jeopardize the rest of the background tasks. recordAuditLog already handles its own errors; mirror that for resend.
- await resend.batch.send( - programEnrollments - .filter(({ partner }) => partner.email) - .map(({ partner }) => ({ - from: VARIANT_TO_FROM_MAP.notifications, - to: partner.email!, - subject: `You've been banned from the ${program.name} Partner Program`, - variant: "notifications", - react: PartnerBanned({ - partner: { - name: partner.name, - email: partner.email!, - }, - program: { - name: program.name, - supportEmail: program.supportEmail || "[email protected]", - }, - bannedReason: BAN_PARTNER_REASONS[parsedInput.reason], - }), - })), - ); + try { + await resend.batch.send( + programEnrollments + .filter(({ partner }) => partner.email) + .map(({ partner }) => ({ + from: VARIANT_TO_FROM_MAP.notifications, + to: partner.email!, + subject: `You've been banned from the ${program.name} Partner Program`, + variant: "notifications", + react: PartnerBanned({ + partner: { + name: partner.name, + email: partner.email!, + }, + program: { + name: program.name, + supportEmail: program.supportEmail || "[email protected]", + }, + bannedReason: BAN_PARTNER_REASONS[parsedInput.reason], + }), + })), + ); + } catch (err) { + console.error("Failed to send ban emails", err); + }apps/web/lib/partnerstack/import-partners.ts (1)
32-34: Type groupMap to catch missing slugs at compile timeTyping groupMap surfaces unknown slug lookups during development and reduces runtime surprises.
- const groupMap = Object.fromEntries( - program.groups.map((group) => [group.slug, group]), - ); + const groupMap = program.groups.reduce<Record<string, PartnerGroup>>( + (acc, group) => { + acc[group.slug] = group; + return acc; + }, + {}, + );
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (16)
apps/web/app/(ee)/api/cron/import/partnerstack/route.ts(2 hunks)apps/web/lib/actions/partners/archive-partner.ts(1 hunks)apps/web/lib/actions/partners/bulk-archive-partners.ts(1 hunks)apps/web/lib/actions/partners/bulk-ban-partners.ts(1 hunks)apps/web/lib/actions/partners/create-program.ts(2 hunks)apps/web/lib/actions/partners/start-partnerstack-import.ts(1 hunks)apps/web/lib/actions/partners/update-program.ts(0 hunks)apps/web/lib/partners/approve-partner-enrollment.ts(1 hunks)apps/web/lib/partnerstack/api.ts(2 hunks)apps/web/lib/partnerstack/import-groups.ts(1 hunks)apps/web/lib/partnerstack/import-partners.ts(8 hunks)apps/web/lib/partnerstack/schemas.ts(3 hunks)apps/web/lib/partnerstack/types.ts(2 hunks)apps/web/lib/zod/schemas/partners.ts(1 hunks)apps/web/lib/zod/schemas/programs.ts(1 hunks)packages/prisma/schema/program.prisma(1 hunks)
💤 Files with no reviewable changes (1)
- apps/web/lib/actions/partners/update-program.ts
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-08-14T05:00:23.180Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/api/og/program/route.tsx:63-64
Timestamp: 2025-08-14T05:00:23.180Z
Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.
Applied to files:
packages/prisma/schema/program.prismaapps/web/lib/partnerstack/import-partners.ts
📚 Learning: 2025-08-16T11:14:00.629Z
Learnt from: devkiran
PR: dubinc/dub#2754
File: apps/web/lib/partnerstack/schemas.ts:47-52
Timestamp: 2025-08-16T11:14:00.629Z
Learning: The PartnerStack API always includes the `group` field in partner responses, so the schema should use `.nullable()` rather than `.nullish()` since the field is never omitted/undefined.
Applied to files:
apps/web/lib/partnerstack/schemas.tsapps/web/lib/partnerstack/import-partners.ts
📚 Learning: 2025-08-14T05:17:51.787Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.787Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.
Applied to files:
apps/web/lib/partnerstack/import-partners.ts
🧬 Code Graph Analysis (9)
apps/web/lib/actions/partners/bulk-archive-partners.ts (3)
apps/web/lib/actions/safe-action.ts (1)
authActionClient(33-82)apps/web/lib/zod/schemas/partners.ts (1)
bulkArchivePartnersSchema(641-648)apps/web/lib/api/audit-logs/record-audit-log.ts (1)
recordAuditLog(43-63)
apps/web/lib/partnerstack/types.ts (1)
apps/web/lib/partnerstack/schemas.ts (1)
partnerStackGroup(25-30)
apps/web/lib/partnerstack/import-groups.ts (4)
apps/web/lib/partnerstack/types.ts (1)
PartnerStackImportPayload(18-20)apps/web/lib/partnerstack/importer.ts (1)
partnerStackImporter(47-47)apps/web/lib/partnerstack/api.ts (1)
PartnerStackApi(19-138)apps/web/lib/api/create-id.ts (1)
createId(57-66)
apps/web/app/(ee)/api/cron/import/partnerstack/route.ts (1)
apps/web/lib/partnerstack/import-groups.ts (1)
importGroups(7-61)
apps/web/lib/actions/partners/bulk-ban-partners.ts (4)
apps/web/lib/actions/safe-action.ts (1)
authActionClient(33-82)apps/web/lib/zod/schemas/partners.ts (2)
bulkBanPartnersSchema(621-634)BAN_PARTNER_REASONS(51-58)packages/prisma/client.ts (1)
ProgramEnrollmentStatus(18-18)apps/web/lib/api/audit-logs/record-audit-log.ts (1)
recordAuditLog(43-63)
apps/web/lib/zod/schemas/partners.ts (1)
packages/prisma/client.ts (1)
PartnerBannedReason(10-10)
apps/web/lib/partnerstack/api.ts (2)
apps/web/lib/partnerstack/types.ts (2)
PartnerStackListResponse(12-16)PartnerStackGroup(22-22)apps/web/lib/partnerstack/schemas.ts (1)
partnerStackGroup(25-30)
apps/web/lib/partnerstack/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)apps/web/lib/partnerstack/types.ts (1)
PartnerStackPartner(24-24)
apps/web/lib/actions/partners/create-program.ts (1)
apps/web/lib/api/create-id.ts (1)
createId(57-66)
🔇 Additional comments (15)
apps/web/lib/partners/approve-partner-enrollment.ts (2)
77-85: Double-check updating createdAt on approvalSetting createdAt during an update usually overwrites the original creation timestamp. If this is intended to track approval time, consider a dedicated approvedAt field or rely on updatedAt.
Potential fix if unintended:
- createdAt: new Date(),
111-114: Confirm defaultFolderId is guaranteed non-nullfolderId is set to program.defaultFolderId. Given recent schema changes, this should be non-null at runtime, but ensure the Prisma type reflects that to avoid TS drift and accidental null writes.
apps/web/lib/actions/partners/create-program.ts (1)
189-194: Switch to PartnerStack "import-groups" step looks correctThis aligns with the new two-phase import flow (groups → partners) and matches the updated schemas and cron route.
apps/web/lib/partnerstack/schemas.ts (3)
3-10: New import step "import-groups" is consistent with the staged import pipelineEnum addition is straightforward and unblocks the group-first import.
25-30: partnerStackGroup schema LGTMFields and types match expected API shape for groups.
47-51: group field on partner schema uses .nullable() — matches PartnerStack contractThis aligns with the stated API behavior (group present but possibly null). Optional chaining downstream remains fine.
apps/web/lib/actions/partners/start-partnerstack-import.ts (1)
49-54: Queuing "import-groups" to kick off the PartnerStack flow is correctCredentials are set first, connection is tested, and the new staged action is queued. Looks good.
apps/web/lib/actions/partners/archive-partner.ts (1)
27-30: No-op composite key field order — safe changeSwapping the field order inside the composite unique key selector is a no-op in Prisma; keys are matched by name, not order. The update will behave identically. LGTM.
apps/web/lib/partnerstack/types.ts (1)
22-23: Good addition: exported PartnerStackGroup typeExporting the
PartnerStackGrouptype derived from the Zod schema keeps types cohesive with validation. This aligns with existing patterns in this file.apps/web/app/(ee)/api/cron/import/partnerstack/route.ts (1)
26-28: New "import-groups" action wiring looks correct
- Import added and switch case correctly invokes
importGroups(payload)before other steps.- Schema-validated payload used consistently.
No issues spotted here.
apps/web/lib/actions/partners/bulk-archive-partners.ts (1)
56-76: Background audit logging via waitUntil looks solidGood use of waitUntil to decouple side effects from the request lifecycle; batching audit log writes per partner is appropriate here.
apps/web/lib/zod/schemas/partners.ts (2)
621-634: Bulk ban schema looks correct and robust
- Deduplication + bounds on partnerIds.
- reason constrained to BAN_PARTNER_REASONS keys with correct typing against PartnerBannedReason.
No changes requested.
641-648: Bulk archive schema LGTM
- Matches the ban schema’s partnerIds constraints and deduplication.
- Aligns with action input.
apps/web/lib/partnerstack/import-partners.ts (2)
144-155: Country code resolution is thoughtful and resilient
- Code-vs-name resolution with COUNTRY_CODES and COUNTRY_NAME_TO_CODE handles PartnerStack variability well.
- Logging on unresolved countries is helpful; consider using your centralized logger if available.
Also applies to: 16-18
76-106: Confirm Prisma relation and refine orphan-group deletion
Double-check that the
partnersfield in thewhere: { partners: { none: {} } }filter matches the actual relation name on thePartnerGroupmodel in your Prisma schema. If the relation is named differently (for example,programEnrollmentsormemberships), update the clause accordingly:// before slug: { not: DEFAULT_PARTNER_GROUP.slug },partners: { none: {} },
programEnrollments: { none: {} }, // ← use the actual relation field
To avoid removing intentionally-empty groups, narrow the deletion scope. For instance:
- Only delete groups tagged or flagged as “import-generated” (e.g., add a
source = "PartnerStack"field during import).- Or record created group IDs in this run and delete only those with no partners.
- Example pattern:
// Tag groups on creation await prisma.partnerGroup.create({ data: { …, source: "PartnerStack" } }); // Later cleanup await prisma.partnerGroup.deleteMany({ where: { source: "PartnerStack", partners: { none: {} }, slug: { not: DEFAULT_PARTNER_GROUP.slug }, }, });This will ensure you only remove true “orphan” groups from the current import without affecting legitimate empty buckets.
Summary by CodeRabbit
New Features
Improvements