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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ POSTGRES_PRISMA_URL=
POSTGRES_PRISMA_URL_NON_POOLING=
# This variable is from Vercel Storage Blob
BLOB_READ_WRITE_TOKEN=
VERCEL_BLOB_HOST=vercel-storage.com

# Google client id and secret for authentication
GOOGLE_CLIENT_ID=
Expand Down
9 changes: 5 additions & 4 deletions components/documents/add-document-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -405,19 +405,20 @@ export function AddDocumentModal({
}

// Try to validate the Notion page URL
let validateNotionPageURL = parsePageId(notionLink);
let validateNotionPageId = parsePageId(notionLink);

// If parsePageId fails, try to get page ID from slug
if (validateNotionPageURL === null) {
if (validateNotionPageId === null) {
try {
validateNotionPageURL = await getNotionPageIdFromSlug(notionLink);
const pageId = await getNotionPageIdFromSlug(notionLink);
validateNotionPageId = pageId || undefined;
} catch (slugError) {
toast.error("Please enter a valid Notion link to proceed.");
return;
}
}

if (!validateNotionPageURL) {
if (!validateNotionPageId) {
toast.error("Please enter a valid Notion link to proceed.");
return;
}
Expand Down
3 changes: 2 additions & 1 deletion lib/api/documents/process-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ export const processDocument = async ({
// If parsePageId fails, try to get page ID from slug
if (!pageId) {
try {
pageId = await getNotionPageIdFromSlug(key);
const pageIdFromSlug = await getNotionPageIdFromSlug(key);
pageId = pageIdFromSlug || undefined;
} catch (slugError) {
throw new Error("Unable to extract page ID from Notion URL");
}
Expand Down
19 changes: 19 additions & 0 deletions lib/dub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,22 @@ import { Dub } from "dub";
export const dub = new Dub({
token: process.env.DUB_API_KEY,
});

export async function getDubDiscountForExternalUserId(externalId: string) {
try {
const customers = await dub.customers.list({
externalId,
includeExpandedFields: true,
});
const first = customers[0];
const couponId =
process.env.NODE_ENV !== "production" && first?.discount?.couponTestId
? first.discount.couponTestId
: first?.discount?.couponId;

return couponId ? { discounts: [{ coupon: couponId }] } : null;
} catch (err) {
console.warn("Skipping Dub discount due to API error", err);
return null; // degrade gracefully; don't block checkout
}
}
82 changes: 76 additions & 6 deletions lib/notion/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NotionAPI } from "notion-client";
import { getPageContentBlockIds } from "notion-utils";
import { getPageContentBlockIds, parsePageId } from "notion-utils";

import notion from "./index";

Expand Down Expand Up @@ -75,15 +75,86 @@ export const addSignedUrls: NotionAPI["addSignedUrls"] = async ({
}
};

export async function getNotionPageIdFromSlug(url: string) {
/**
* Extracts page ID from custom Notion domain URLs
* For custom domains, the page ID is typically embedded in the URL slug
*/
export function extractPageIdFromCustomNotionUrl(url: string): string | null {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;

// Try robust parser first (handles hyphenated and plain IDs)
const parsed = parsePageId(url) || parsePageId(pathname);
if (parsed) return parsed;

// Fallback: match either plain 32-hex or hyphenated UUID-like Notion ID
const pageIdMatch = pathname.match(
/\b(?:[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\b/i,
);
if (pageIdMatch) return parsePageId(pageIdMatch[0]) ?? pageIdMatch[0];

return null;
} catch {
return null;
}
}

/**
* Check if a URL is potentially a custom Notion domain by attempting to extract a page ID
* and verifying the page exists
*/
export async function isCustomNotionDomain(url: string): Promise<boolean> {
try {
const pageId = extractPageIdFromCustomNotionUrl(url);
if (!pageId) {
return false;
}

// Try to fetch the page to verify it exists and is accessible
await notion.getPage(pageId);
return true;
} catch {
return false;
}
}

export async function getNotionPageIdFromSlug(
url: string,
): Promise<string | null> {
// Parse the URL to extract domain and slug
const urlObj = new URL(url);
const hostname = urlObj.hostname;

const isNotionSo = hostname === "www.notion.so" || hostname === "notion.so";
const isNotionSite = hostname.endsWith(".notion.site");

// notion.so: extract ID from path directly
if (isNotionSo) {
const pageId = parsePageId(url) ?? parsePageId(urlObj.pathname);
if (pageId) return pageId;
throw new Error(`Unable to extract page ID from Notion URL: ${url}`);
}
Comment on lines +122 to +137
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

Normalize hostnames (lowercase, strip trailing dot) before domain-family checks.

Hostnames are case-insensitive and can legally end with a trailing dot. Without normalization, HTTPS://WWW.NOTION.SO. would fail, and mixed-case hosts can bypass checks.

-export async function getNotionPageIdFromSlug(
+export async function getNotionPageIdFromSlug(
   url: string,
 ): Promise<string | null> {
   // Parse the URL to extract domain and slug
   const urlObj = new URL(url);
-  const hostname = urlObj.hostname;
+  const hostname = urlObj.hostname.toLowerCase().replace(/^\.+|\.+$/g, "");

-  const isNotionSo = hostname === "www.notion.so" || hostname === "notion.so";
-  const isNotionSite = hostname.endsWith(".notion.site");
+  const isNotionSo = hostname === "www.notion.so" || hostname === "notion.so";
+  const isNotionSite = hostname.endsWith(".notion.site");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function getNotionPageIdFromSlug(
url: string,
): Promise<string | null> {
// Parse the URL to extract domain and slug
const urlObj = new URL(url);
const hostname = urlObj.hostname;
const isNotionSo = hostname === "www.notion.so" || hostname === "notion.so";
const isNotionSite = hostname.endsWith(".notion.site");
// notion.so: extract ID from path directly
if (isNotionSo) {
const pageId = parsePageId(url) ?? parsePageId(urlObj.pathname);
if (pageId) return pageId;
throw new Error(`Unable to extract page ID from Notion URL: ${url}`);
}
export async function getNotionPageIdFromSlug(
url: string,
): Promise<string | null> {
// Parse the URL to extract domain and slug
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase().replace(/^\.+|\.+$/g, "");
const isNotionSo = hostname === "www.notion.so" || hostname === "notion.so";
const isNotionSite = hostname.endsWith(".notion.site");
// notion.so: extract ID from path directly
if (isNotionSo) {
const pageId = parsePageId(url) ?? parsePageId(urlObj.pathname);
if (pageId) return pageId;
throw new Error(`Unable to extract page ID from Notion URL: ${url}`);
}
🤖 Prompt for AI Agents
In lib/notion/utils.ts around lines 122 to 137, the hostname is used directly to
decide Notion domain families which fails for mixed-case or trailing-dot
hostnames; normalize the hostname by lowercasing it and stripping any trailing
dot before doing comparisons, then use that normalized value for equality checks
("www.notion.so" / "notion.so") and for .endsWith(".notion.site") so URLs like
"HTTPS://WWW.NOTION.SO." or mixed-case hosts match correctly.


// Custom domains (non notion.site, non notion.so)
if (!isNotionSite) {
const pageId = extractPageIdFromCustomNotionUrl(url);
if (pageId) {
// Verify the page exists before returning the ID
try {
await notion.getPage(pageId);
return pageId;
} catch {
throw new Error(`Custom Notion domain page not accessible: ${url}`);
}
}
throw new Error(`Unable to extract page ID from custom domain URL: ${url}`);
}

// Extract domain from hostname (e.g., "domain" from "domain.notion.site")
const domainMatch = hostname.match(/^([^.]+)\.notion\.site$/);
if (!domainMatch) {
throw new Error("Invalid Notion site URL format: ${url}");
throw new Error(`Invalid Notion site URL format: ${url}`);
}

const spaceDomain = domainMatch[1];
Expand All @@ -93,8 +164,7 @@ export async function getNotionPageIdFromSlug(url: string) {
let slug = urlObj.pathname.substring(1) || "";

// Make request to Notion's internal API
const apiUrl =
"https://${spaceDomain}.notion.site/api/v3/getPublicPageDataForDomain";
const apiUrl = `https://${spaceDomain}.notion.site/api/v3/getPublicPageDataForDomain`;
const payload = {
type: "block-space",
name: "page",
Expand All @@ -114,7 +184,7 @@ export async function getNotionPageIdFromSlug(url: string) {

if (!response.ok) {
throw new Error(
"Notion API request failed: ${response.status} ${response.statusText}",
`Notion API request failed: ${response.status} ${response.statusText}`,
);
}

Expand Down
Loading