diff --git a/apps/web/app/(ee)/api/admin/partners/platforms/route.ts b/apps/web/app/(ee)/api/admin/partners/platforms/route.ts
index ce72f66a5e7..c3901376b37 100644
--- a/apps/web/app/(ee)/api/admin/partners/platforms/route.ts
+++ b/apps/web/app/(ee)/api/admin/partners/platforms/route.ts
@@ -18,8 +18,12 @@ const postSchema = z.object({
// POST /api/admin/partners/platforms
export const POST = withAdmin(
async ({ req }) => {
- const { partnerId, platform, identifier: rawIdentifier, postUrl } = postSchema
- .parse(await req.json());
+ const {
+ partnerId,
+ platform,
+ identifier: rawIdentifier,
+ postUrl,
+ } = postSchema.parse(await req.json());
const partner = await prisma.partner.findUnique({
where: { id: partnerId },
diff --git a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts
index 18dbe2e02e5..03f397f523f 100644
--- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts
+++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts
@@ -14,24 +14,39 @@ import {
GroupSchema,
updateGroupSchema,
} from "@/lib/zod/schemas/groups";
+import { booleanQuerySchema } from "@/lib/zod/schemas/misc";
import { prisma } from "@dub/prisma";
import { APP_DOMAIN_WITH_NGROK, constructURLFromUTMParams } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";
+import * as z from "zod/v4";
+
+const getGroupQuerySchema = z.object({
+ includeExpandedFields: booleanQuerySchema.optional(),
+ includeBounties: booleanQuerySchema.optional(),
+});
// GET /api/groups/[groupIdOrSlug] - get information about a group
export const GET = withWorkspace(
- async ({ params, workspace }) => {
+ async ({ params, workspace, searchParams }) => {
+ const { includeExpandedFields, includeBounties } =
+ getGroupQuerySchema.parse(searchParams);
+
const programId = getDefaultProgramIdOrThrow(workspace);
+ const schema =
+ includeExpandedFields || includeBounties
+ ? GroupWithProgramSchema
+ : GroupSchema;
+
const group = await getGroupOrThrow({
programId,
groupId: params.groupIdOrSlug,
- includeExpandedFields: true,
- includeBounties: true,
+ includeExpandedFields,
+ includeBounties,
});
- return NextResponse.json(GroupWithProgramSchema.parse(group));
+ return NextResponse.json(schema.parse(group));
},
{
requiredPermissions: ["groups.read"],
diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/detail.tsx b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/detail.tsx
index f42733cf8b5..1846ec8313d 100644
--- a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/detail.tsx
+++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/detail.tsx
@@ -133,7 +133,7 @@ export function EmbedBountyDetail({
aria-label="Back to bounties"
title="Back to bounties"
onClick={onBack}
- className="bg-bg-subtle flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 hover:bg-bg-emphasis active:scale-95"
+ className="bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95"
>
diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-detail.tsx b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-detail.tsx
index d09de02a10a..7f03544c5f6 100644
--- a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-detail.tsx
+++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-detail.tsx
@@ -41,7 +41,7 @@ export function EmbedBountySubmissionDetail({
aria-label="Back to bounties"
title="Back to bounties"
onClick={onBackToRoot}
- className="bg-bg-subtle flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 hover:bg-bg-emphasis active:scale-95"
+ className="bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95"
>
diff --git a/apps/web/lib/openapi/groups/create-group.ts b/apps/web/lib/openapi/groups/create-group.ts
new file mode 100644
index 00000000000..141c2563a7d
--- /dev/null
+++ b/apps/web/lib/openapi/groups/create-group.ts
@@ -0,0 +1,31 @@
+import { openApiErrorResponses } from "@/lib/openapi/responses";
+import { createGroupSchema, GroupSchema } from "@/lib/zod/schemas/groups";
+import { ZodOpenApiOperationObject } from "zod-openapi";
+
+export const createGroup: ZodOpenApiOperationObject = {
+ operationId: "createGroup",
+ "x-speakeasy-name-override": "create",
+ summary: "Create a group",
+ description:
+ "Create a group for the authenticated workspace's partner program.",
+ requestBody: {
+ content: {
+ "application/json": {
+ schema: createGroupSchema,
+ },
+ },
+ },
+ responses: {
+ "201": {
+ description: "The created group",
+ content: {
+ "application/json": {
+ schema: GroupSchema,
+ },
+ },
+ },
+ ...openApiErrorResponses,
+ },
+ tags: ["Groups"],
+ security: [{ token: [] }],
+};
diff --git a/apps/web/lib/openapi/groups/get-group.ts b/apps/web/lib/openapi/groups/get-group.ts
new file mode 100644
index 00000000000..5d8964b1486
--- /dev/null
+++ b/apps/web/lib/openapi/groups/get-group.ts
@@ -0,0 +1,34 @@
+import { openApiErrorResponses } from "@/lib/openapi/responses";
+import { GroupSchema } from "@/lib/zod/schemas/groups";
+import { ZodOpenApiOperationObject } from "zod-openapi";
+import * as z from "zod/v4";
+
+export const getGroup: ZodOpenApiOperationObject = {
+ operationId: "getGroup",
+ "x-speakeasy-name-override": "get",
+ summary: "Retrieve a group",
+ description:
+ "Retrieve a group by ID or slug for the authenticated workspace's partner program.",
+ requestParams: {
+ path: z.object({
+ groupId: z
+ .string()
+ .describe(
+ "The ID of the group to retrieve. You can also use the slug of the group.",
+ ),
+ }),
+ },
+ responses: {
+ "200": {
+ description: "The group",
+ content: {
+ "application/json": {
+ schema: GroupSchema,
+ },
+ },
+ },
+ ...openApiErrorResponses,
+ },
+ tags: ["Groups"],
+ security: [{ token: [] }],
+};
diff --git a/apps/web/lib/openapi/groups/index.ts b/apps/web/lib/openapi/groups/index.ts
new file mode 100644
index 00000000000..4aa772de0f9
--- /dev/null
+++ b/apps/web/lib/openapi/groups/index.ts
@@ -0,0 +1,14 @@
+import { ZodOpenApiPathsObject } from "zod-openapi";
+import { createGroup } from "./create-group";
+import { getGroup } from "./get-group";
+import { listGroups } from "./list-groups";
+
+export const groupsPaths: ZodOpenApiPathsObject = {
+ "/groups": {
+ post: createGroup,
+ get: listGroups,
+ },
+ "/groups/{groupId}": {
+ get: getGroup,
+ },
+};
diff --git a/apps/web/lib/openapi/groups/list-groups.ts b/apps/web/lib/openapi/groups/list-groups.ts
new file mode 100644
index 00000000000..722f2f48b99
--- /dev/null
+++ b/apps/web/lib/openapi/groups/list-groups.ts
@@ -0,0 +1,31 @@
+import { openApiErrorResponses } from "@/lib/openapi/responses";
+import {
+ getGroupsQuerySchema,
+ GroupSchemaExtended,
+} from "@/lib/zod/schemas/groups";
+import { ZodOpenApiOperationObject } from "zod-openapi";
+import * as z from "zod/v4";
+
+export const listGroups: ZodOpenApiOperationObject = {
+ operationId: "listGroups",
+ "x-speakeasy-name-override": "list",
+ summary: "Retrieve a list of groups",
+ description:
+ "Retrieve a list of groups for the authenticated workspace's partner program.",
+ requestParams: {
+ query: getGroupsQuerySchema,
+ },
+ responses: {
+ "200": {
+ description: "A list of groups",
+ content: {
+ "application/json": {
+ schema: z.array(GroupSchemaExtended),
+ },
+ },
+ },
+ ...openApiErrorResponses,
+ },
+ tags: ["Groups"],
+ security: [{ token: [] }],
+};
diff --git a/apps/web/lib/openapi/index.ts b/apps/web/lib/openapi/index.ts
index 9669bda97cb..beef64a90d4 100644
--- a/apps/web/lib/openapi/index.ts
+++ b/apps/web/lib/openapi/index.ts
@@ -12,6 +12,7 @@ import { domainsPaths } from "./domains";
import { embedTokensPaths } from "./embed-tokens";
import { eventsPath } from "./events";
import { foldersPaths } from "./folders";
+import { groupsPaths } from "./groups";
import { linksPaths } from "./links";
import { partnersPaths } from "./partners";
import { payoutsPaths } from "./payouts";
@@ -52,6 +53,7 @@ export const document = createDocument({
...domainsPaths,
...trackPaths,
...customersPaths,
+ ...groupsPaths,
...partnersPaths,
...commissionsPaths,
...payoutsPaths,
diff --git a/apps/web/lib/swr/use-group.ts b/apps/web/lib/swr/use-group.ts
index 043361224bd..37a88bdd4be 100644
--- a/apps/web/lib/swr/use-group.ts
+++ b/apps/web/lib/swr/use-group.ts
@@ -25,7 +25,7 @@ export default function useGroup(
mutate: mutateGroup,
} = useSWR(
workspaceId && groupIdOrSlug
- ? `/api/groups/${groupIdOrSlug}?${new URLSearchParams({ workspaceId, ...query }).toString()}`
+ ? `/api/groups/${groupIdOrSlug}?${new URLSearchParams({ workspaceId, includeExpandedFields: "true", ...query }).toString()}`
: null,
fetcher,
{
diff --git a/apps/web/lib/zod/schemas/groups.ts b/apps/web/lib/zod/schemas/groups.ts
index 39fd1378d4f..45c3ce1bee3 100644
--- a/apps/web/lib/zod/schemas/groups.ts
+++ b/apps/web/lib/zod/schemas/groups.ts
@@ -36,10 +36,10 @@ export const additionalPartnerLinkSchema = z.object({
.refine((v) => isValidDomainFormatWithLocalhost(v), {
message: "Please enter a valid domain (eg: acme.com or localhost:3000).",
})
- .transform((v) => v.toLowerCase()),
+ .overwrite((v) => v.toLowerCase()),
path: z
.string()
- .transform((v) => v.toLowerCase())
+ .overwrite((v) => v.toLowerCase())
.optional()
.default(""),
validationMode: z.enum([
diff --git a/apps/web/lib/zod/schemas/rewards.ts b/apps/web/lib/zod/schemas/rewards.ts
index c4040ea37ce..e1f25db882d 100644
--- a/apps/web/lib/zod/schemas/rewards.ts
+++ b/apps/web/lib/zod/schemas/rewards.ts
@@ -294,11 +294,10 @@ export const rewardConditionsArraySchema = z
.array(rewardConditionsSchema)
.min(1);
-const decimalToNumber = z
- .any()
- .transform((val) => (val != null && val !== "" ? Number(val) : null))
- .nullable()
- .optional();
+const decimalToNumber = z.preprocess(
+ (val) => (val != null && val !== "" ? Number(val) : null),
+ z.number().nullable().optional(),
+);
export const RewardSchema = z.object({
id: z.string(),
diff --git a/apps/web/tests/partner-groups/index.test.ts b/apps/web/tests/partner-groups/index.test.ts
index 090cf2189cf..861592389bb 100644
--- a/apps/web/tests/partner-groups/index.test.ts
+++ b/apps/web/tests/partner-groups/index.test.ts
@@ -45,6 +45,7 @@ describe.sequential("/groups/**", async () => {
// Fetch the default group to get its default values
const { data: defaultGroup } = await http.get({
path: `/groups/${DEFAULT_PARTNER_GROUP.slug}`,
+ query: { includeExpandedFields: "true" },
});
const groupName = generateRandomName();
@@ -79,9 +80,35 @@ describe.sequential("/groups/**", async () => {
group = data;
});
- test("GET /groups/[groupId] - fetch single group", async () => {
+ test("GET /groups/[groupId] - fetch single group without extended params", async () => {
+ const { status, data } = await http.get({
+ path: `/groups/${group.id}`,
+ });
+
+ expect(status).toEqual(200);
+ expect(() => GroupSchema.parse(data)).not.toThrow();
+ expect(data).not.toHaveProperty("program");
+ expect(data).not.toHaveProperty("applicationFormData");
+ expect(data).not.toHaveProperty("landerData");
+ expect(data).toMatchObject({
+ id: group.id,
+ name: group.name,
+ slug: group.slug,
+ color: group.color,
+ logo: group.logo,
+ wordmark: group.wordmark,
+ brandColor: group.brandColor,
+ holdingPeriodDays: group.holdingPeriodDays,
+ maxPartnerLinks: group.maxPartnerLinks,
+ linkStructure: group.linkStructure,
+ additionalLinks: group.additionalLinks,
+ });
+ });
+
+ test("GET /groups/[groupId] - fetch single group with includeExpandedFields", async () => {
const { status, data } = await http.get({
path: `/groups/${group.id}`,
+ query: { includeExpandedFields: "true" },
});
const {
@@ -95,6 +122,7 @@ describe.sequential("/groups/**", async () => {
} = data;
expect(status).toEqual(200);
+ expect(program).toBeDefined();
expect(fetchedGroup).toStrictEqual({
...group,
utmTemplate: null,
@@ -161,6 +189,7 @@ describe.sequential("/groups/**", async () => {
// Fetch the group to verify moveRules was persisted
const { data: fetchedGroup } = await http.get({
path: `/groups/${group.id}`,
+ query: { includeExpandedFields: "true" },
});
const {
@@ -206,6 +235,7 @@ describe.sequential("/groups/**", async () => {
// Fetch the group to verify moveRules was updated
const { data: fetchedGroup } = await http.get({
path: `/groups/${group.id}`,
+ query: { includeExpandedFields: "true" },
});
const {
@@ -238,6 +268,7 @@ describe.sequential("/groups/**", async () => {
// Fetch the group to verify moveRules was removed
const { data: fetchedGroup } = await http.get({
path: `/groups/${group.id}`,
+ query: { includeExpandedFields: "true" },
});
const {
diff --git a/apps/web/ui/analytics/partner-section.tsx b/apps/web/ui/analytics/partner-section.tsx
index 9a4666713ff..dea1a96e7e4 100644
--- a/apps/web/ui/analytics/partner-section.tsx
+++ b/apps/web/ui/analytics/partner-section.tsx
@@ -259,9 +259,7 @@ export function PartnerSection() {
filterParamKey ? onApplyFilterValues : undefined
}
onImmediateFilter={
- filterParamKey
- ? (val) => onApplyFilterValues([val])
- : undefined
+ filterParamKey ? (val) => onApplyFilterValues([val]) : undefined
}
{...(limit && { limit })}
/>
diff --git a/apps/web/ui/analytics/top-links.tsx b/apps/web/ui/analytics/top-links.tsx
index 1fd3f92f630..f4fd2241fd0 100644
--- a/apps/web/ui/analytics/top-links.tsx
+++ b/apps/web/ui/analytics/top-links.tsx
@@ -252,9 +252,7 @@ export function TopLinks() {
filterParamKey ? onApplyFilterValues : undefined
}
onImmediateFilter={
- filterParamKey
- ? (val) => onApplyFilterValues([val])
- : undefined
+ filterParamKey ? (val) => onApplyFilterValues([val]) : undefined
}
{...(limit && { limit })}
/>