Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/lib/api/links/get-links-count.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { combineTagIds } from "@/lib/api/tags/combine-tag-ids";
import { getLinksCountQuerySchema } from "@/lib/zod/schemas/links";
import { prisma } from "@dub/prisma";
import { z } from "zod";
import { buildLinkFeaturesWhere } from "./utils";

interface GetLinksCountParams extends z.infer<typeof getLinksCountQuerySchema> {
workspaceId: string;
Expand All @@ -22,6 +23,7 @@ export async function getLinksCount({
tenantId,
workspaceId,
folderIds,
linkFeatures,
}: GetLinksCountParams) {
const combinedTagIds = combineTagIds({ tagId, tagIds });

Expand Down Expand Up @@ -71,6 +73,7 @@ export async function getLinksCount({
userId,
}),
...(tenantId && { tenantId }),
...buildLinkFeaturesWhere(linkFeatures),
};

if (groupBy === "tagId") {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/lib/api/links/get-links-for-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { prisma } from "@dub/prisma";
import { z } from "zod";
import { combineTagIds } from "../tags/combine-tag-ids";
import { encodeKeyIfCaseSensitive } from "./case-sensitivity";
import { transformLink } from "./utils";
import { buildLinkFeaturesWhere, transformLink } from "./utils";

export interface GetLinksForWorkspaceProps
extends z.infer<typeof getLinksQuerySchemaExtended> {
Expand Down Expand Up @@ -39,6 +39,7 @@ export async function getLinksForWorkspace({
partnerId,
startDate,
endDate,
linkFeatures,
}: GetLinksForWorkspaceProps) {
const combinedTagIds = combineTagIds({ tagId, tagIds });

Expand Down Expand Up @@ -143,6 +144,7 @@ export async function getLinksForWorkspace({
lte: endDate,
},
}),
...buildLinkFeaturesWhere(linkFeatures),
},
include: {
tags: {
Expand Down
52 changes: 52 additions & 0 deletions apps/web/lib/api/links/utils/build-link-features-where.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Prisma } from "@dub/prisma/client";

export function buildLinkFeaturesWhere(
linkFeatures?: string[],
): Record<string, unknown> | undefined {
if (!linkFeatures || linkFeatures.length === 0) {
return undefined;
}

return {
OR: linkFeatures.map((feature) => {
switch (feature) {
case "conversionTracking":
return { trackConversion: true };
case "customLinkPreview":
return { proxy: true };
case "geoTargeting":
return { geo: { not: Prisma.DbNull } };
case "utmTags":
return {
OR: [
{ utm_source: { not: null } },
{ utm_medium: { not: null } },
{ utm_campaign: { not: null } },
{ utm_term: { not: null } },
{ utm_content: { not: null } },
],
};
case "abTest":
return { testVariants: { not: Prisma.DbNull } };
case "tags":
return { tags: { some: {} } };
case "comments":
return { comments: { not: null } };
case "iosTargeting":
return { ios: { not: null } };
case "androidTargeting":
return { android: { not: null } };
case "expiration":
return { expiresAt: { not: null } };
case "password":
return { password: { not: null } };
case "linkCloaking":
return { rewrite: true };
case "searchEngineIndexing":
return { doIndex: true };
default:
return {};
Comment on lines +47 to +48
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Default case returns empty object, which matches all records in an OR clause.

When an unknown feature string is passed, the default case returns {}. In Prisma, an empty object in a where clause matches all records. Combined with OR, this means OR: [{ validCondition }, {}] effectively bypasses the filter entirely.

Consider filtering out invalid features or returning a condition that matches nothing:

🔎 Proposed fix
-  return {
-    OR: linkFeatures.map((feature) => {
+  const conditions = linkFeatures
+    .map((feature) => {
       switch (feature) {
         // ... existing cases ...
         default:
-          return {};
+          return null;
       }
-    }),
-  };
+    })
+    .filter(Boolean);
+
+  if (conditions.length === 0) {
+    return undefined;
+  }
+
+  return { OR: conditions };

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/lib/api/links/utils/build-link-features-where.ts around lines 47-48,
the default branch currently returns an empty object which in Prisma where/OR
will match all records; change this so unknown feature strings do not bypass the
filter by either (A) filtering out unknown/invalid feature values before
constructing the OR array (so they are not included at all), or (B) change the
default return to a condition that matches nothing (for example return { id: {
equals: null } } assuming id is non-nullable) so the OR entry does not make the
whole clause true; implement one of these fixes and ensure callers handle the
filtered/empty OR correctly.

}
}),
};
}
1 change: 1 addition & 0 deletions apps/web/lib/api/links/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./build-link-features-where";
export * from "./check-if-links-have-tags";
export * from "./check-if-links-have-webhooks";
export * from "./key-checks";
Expand Down
1 change: 1 addition & 0 deletions apps/web/lib/swr/use-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default function useLinks(
"tagIds",
"domain",
"userId",
"linkFeatures",
"search",
"page",
"sortBy",
Expand Down
37 changes: 37 additions & 0 deletions apps/web/lib/zod/schemas/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,43 @@ const LinksQuerySchema = z.object({
"DEPRECATED. Filter for links that have at least one tag assigned to them.",
)
.openapi({ deprecated: true }),
linkFeatures: z
.union([z.string(), z.array(z.string())])
.transform((v) => (Array.isArray(v) ? v : v.split(",")))
.optional()
.describe("Filter links by enabled features (comma-separated)")
.openapi({
param: {
style: "form",
explode: false,
},
anyOf: [
{
type: "string",
},
{
type: "array",
items: {
type: "string",
enum: [
"conversionTracking",
"customLinkPreview",
"geoTargeting",
"utmTags",
"abTest",
"tags",
"comments",
"iosTargeting",
"androidTargeting",
"expiration",
"password",
"linkCloaking",
"searchEngineIndexing",
],
},
},
],
}),
Comment on lines +151 to +187
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for linkFeatures usage and filtering implementation
echo "=== Finding linkFeatures references in codebase ==="
rg -n "linkFeatures" --type ts -A 3 -B 1 | head -100

echo -e "\n=== Searching for feature filtering implementation ==="
rg -n "conversionTracking|customLinkPreview|geoTargeting" --type ts -A 2 -B 1 | head -80

Repository: dubinc/dub

Length of output: 12661


🏁 Script executed:

#!/bin/bash
# Get full implementation of build-link-features-where.ts
echo "=== Full build-link-features-where.ts implementation ==="
cat apps/web/lib/api/links/utils/build-link-features-where.ts

echo -e "\n=== Check if there's error handling for invalid features ==="
rg -n "linkFeatures" apps/web/lib/api/links --type ts -A 5 -B 2

Repository: dubinc/dub

Length of output: 5349


Add runtime validation for feature names.

The linkFeatures field accepts any string without validation, but the OpenAPI spec documents a specific set of allowed features. Invalid features silently produce empty filter conditions (the default case returns {}), causing users to receive incorrect results. Add enum validation to enforce the documented contract.

Define allowed features as a constant and validate with Zod:

+const LINK_FEATURES = [
+  "conversionTracking",
+  "customLinkPreview",
+  "geoTargeting",
+  "utmTags",
+  "abTest",
+  "tags",
+  "comments",
+  "iosTargeting",
+  "androidTargeting",
+  "expiration",
+  "password",
+  "linkCloaking",
+  "searchEngineIndexing",
+] as const;
+
 linkFeatures: z
   .union([z.string(), z.array(z.string())])
   .transform((v) => (Array.isArray(v) ? v : v.split(",")))
+  .refine(
+    (arr) => arr.every((feature) => LINK_FEATURES.includes(feature as any)),
+    {
+      message: `Invalid feature. Allowed features: ${LINK_FEATURES.join(", ")}`,
+    }
+  )
   .optional()
   .describe("Filter links by enabled features (comma-separated)")
   .openapi({
     param: {
       style: "form",
       explode: false,
     },
     anyOf: [
       {
         type: "string",
+        enum: LINK_FEATURES,
       },
       {
         type: "array",
         items: {
           type: "string",
-          enum: [
-            "conversionTracking",
-            "customLinkPreview",
-            "geoTargeting",
-            "utmTags",
-            "abTest",
-            "tags",
-            "comments",
-            "iosTargeting",
-            "androidTargeting",
-            "expiration",
-            "password",
-            "linkCloaking",
-            "searchEngineIndexing",
-          ],
+          enum: LINK_FEATURES,
         },
       },
     ],
   }),

Committable suggestion skipped: line range outside the PR's diff.

});

const sortBy = z
Expand Down
164 changes: 160 additions & 4 deletions apps/web/ui/links/use-link-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ import useWorkspaceUsers from "@/lib/swr/use-workspace-users";
import { TagProps } from "@/lib/types";
import { TAGS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/tags";
import { Avatar, BlurImage, Globe, Tag, User, useRouterStuff } from "@dub/ui";
import {
AndroidLogo,
AppleLogo,
Bolt,
CircleCheck,
CircleHalfDottedClock,
DiamondTurnRight,
Flask,
Incognito,
InputPassword,
Page2,
PenWriting,
WindowSearch,
} from "@dub/ui/icons";
import { GOOGLE_FAVICON_URL, nFormatter } from "@dub/utils";
import { useContext, useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
Expand All @@ -31,6 +45,10 @@ export function useLinkFilters() {
folderId: folderId ?? "",
});

const linkFeatures = useLinkFeatureFilterOptions({
folderId: folderId ?? "",
});

const { queryParams, searchParamsObj } = useRouterStuff();

const filters = useMemo(() => {
Expand Down Expand Up @@ -99,22 +117,37 @@ export function useLinkFilters() {
right: nFormatter(count, { full: true }),
})) ?? null,
},
{
key: "linkFeatures",
icon: CircleCheck,
label: "Link feature",
multiple: true,
options: linkFeatures,
},
];
}, [domains, tags, users]);
}, [domains, tags, users, linkFeatures]);

const selectedTagIds = useMemo(
() => searchParamsObj["tagIds"]?.split(",")?.filter(Boolean) ?? [],
[searchParamsObj],
);

const selectedLinkFeatures = useMemo(
() => searchParamsObj["linkFeatures"]?.split(",")?.filter(Boolean) ?? [],
[searchParamsObj],
);

const activeFilters = useMemo(() => {
const { domain, tagIds, userId } = searchParamsObj;
const { domain, tagIds, userId, linkFeatures } = searchParamsObj;
return [
...(domain ? [{ key: "domain", value: domain }] : []),
...(tagIds ? [{ key: "tagIds", value: selectedTagIds }] : []),
...(userId ? [{ key: "userId", value: userId }] : []),
...(linkFeatures
? [{ key: "linkFeatures", value: selectedLinkFeatures }]
: []),
];
}, [searchParamsObj]);
}, [searchParamsObj, selectedTagIds, selectedLinkFeatures]);

const onSelect = (key: string, value: any) => {
if (key === "tagIds") {
Expand All @@ -124,6 +157,13 @@ export function useLinkFilters() {
},
del: "page",
});
} else if (key === "linkFeatures") {
queryParams({
set: {
linkFeatures: selectedLinkFeatures.concat(value).join(","),
},
del: "page",
});
} else {
queryParams({
set: {
Expand All @@ -145,6 +185,18 @@ export function useLinkFilters() {
},
del: "page",
});
} else if (
key === "linkFeatures" &&
!(selectedLinkFeatures.length === 1 && selectedLinkFeatures[0] === value)
) {
queryParams({
set: {
linkFeatures: selectedLinkFeatures
.filter((feature) => feature !== value)
.join(","),
},
del: "page",
});
} else {
queryParams({
del: [key, "page"],
Expand All @@ -154,7 +206,7 @@ export function useLinkFilters() {

const onRemoveAll = () => {
queryParams({
del: ["domain", "tagIds", "userId", "search"],
del: ["domain", "tagIds", "userId", "linkFeatures", "search"],
});
};

Expand Down Expand Up @@ -293,3 +345,107 @@ function useUserFilterOptions({ folderId }: { folderId: string }) {
[users, usersCount],
);
}

const FEATURE_OPTIONS = [
{
value: "conversionTracking",
label: "Conversion Tracking",
icon: Bolt,
},
{
value: "customLinkPreview",
label: "Custom Link Preview",
icon: PenWriting,
},
{
value: "geoTargeting",
label: "Geo Targeting",
icon: Globe,
},
{
value: "utmTags",
label: "UTM Tags",
icon: DiamondTurnRight,
},
{
value: "abTest",
label: "A/B Test",
icon: Flask,
},
{
value: "tags",
label: "Tags",
icon: Tag,
},
{
value: "comments",
label: "Comments",
icon: Page2,
},
{
value: "iosTargeting",
label: "iOS Targeting",
icon: AppleLogo,
},
{
value: "androidTargeting",
label: "Android Targeting",
icon: AndroidLogo,
},
{
value: "expiration",
label: "Expiration",
icon: CircleHalfDottedClock,
},
{
value: "password",
label: "Password",
icon: InputPassword,
},
{
value: "linkCloaking",
label: "Link Cloaking",
icon: Incognito,
},
{
value: "searchEngineIndexing",
label: "Search Engine Indexing",
icon: WindowSearch,
},
] as const;

function useLinkFeatureFilterOptions({ folderId }: { folderId: string }) {
const { showArchived } = useContext(LinksDisplayContext);

const counts = FEATURE_OPTIONS.map((feature) =>
useLinksCount<number>({
query: {
linkFeatures: [feature.value],
showArchived,
folderId,
},
}),
);
Comment on lines +420 to +428
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Hook called inside .map() violates React's Rules of Hooks.

useLinksCount is invoked inside FEATURE_OPTIONS.map(), which violates the Rules of Hooks. Hooks must be called unconditionally at the top level of a component or custom hook—never inside loops, conditions, or nested functions. This can cause unpredictable behavior and state corruption.

Consider restructuring to call the hook once with all features, or use a single aggregated query:

🔎 Proposed fix - use a single query with feature counts

One approach is to add a backend endpoint that returns counts for all features in a single request, or restructure to avoid multiple hook calls:

 function useLinkFeatureFilterOptions({ folderId }: { folderId: string }) {
   const { showArchived } = useContext(LinksDisplayContext);

-  const counts = FEATURE_OPTIONS.map((feature) =>
-    useLinksCount<number>({
-      query: {
-        linkFeatures: [feature.value],
-        showArchived,
-        folderId,
-      },
-    }),
-  );
+  // Option 1: Fetch counts for each feature individually at top level
+  const conversionTrackingCount = useLinksCount<number>({
+    query: { linkFeatures: ["conversionTracking"], showArchived, folderId },
+  });
+  const customLinkPreviewCount = useLinksCount<number>({
+    query: { linkFeatures: ["customLinkPreview"], showArchived, folderId },
+  });
+  // ... repeat for each feature
+
+  const counts = [
+    conversionTrackingCount,
+    customLinkPreviewCount,
+    // ... all other counts
+  ];

   const isLoading = counts.some(({ loading }) => loading);
   const countValues = counts.map(({ data }) => data ?? 0);

Alternatively, consider adding a groupBy: "linkFeature" option to the backend to fetch all feature counts in a single request.

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 Biome (2.1.2)

[error] 421-421: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


const isLoading = counts.some(({ loading }) => loading);
const countValues = counts.map(({ data }) => data ?? 0);

return useMemo(() => {
if (isLoading) return null;

return FEATURE_OPTIONS.map((feature, index) => {
const count = countValues[index];
const Icon = feature.icon;
return {
value: feature.value,
label: feature.label,
icon: <Icon className="h-4 w-4" />,
right: nFormatter(count, { full: true }),
};
}).sort((a, b) => {
const countA = parseInt(a.right?.replace(/,/g, "") || "0");
const countB = parseInt(b.right?.replace(/,/g, "") || "0");
return countB - countA;
});
}, [isLoading, countValues]);
}