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 })} />