diff --git a/apps/web/app/(ee)/api/admin/partner-content/backfill/route.ts b/apps/web/app/(ee)/api/admin/partner-content/backfill/route.ts new file mode 100644 index 00000000000..509db840a90 --- /dev/null +++ b/apps/web/app/(ee)/api/admin/partner-content/backfill/route.ts @@ -0,0 +1,64 @@ +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { parseRequestBody } from "@/lib/api/utils"; +import { withAdmin } from "@/lib/auth"; +import { + createPartnerContentRunStamp, + enqueuePartnerContentEnumerate, + partnerContentIngestionFilterSchema, +} from "@/lib/partner-content-search/ingestion/enqueue"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 10; + +const backfillTriggerSchema = z.object({ + filter: partnerContentIngestionFilterSchema, + runStamp: z.string().min(1).optional(), + dryRun: z.boolean().default(false), + dispatcherDryRun: z.boolean().default(false), +}); + +// POST /api/admin/partner-content/backfill +export const POST = withAdmin( + async ({ req }) => { + try { + const body = backfillTriggerSchema.parse(await parseRequestBody(req)); + const runStamp = body.runStamp ?? createPartnerContentRunStamp(); + + const payload = { + mode: "backfill" as const, + filter: body.filter, + runStamp, + dryRun: body.dispatcherDryRun, + }; + + if (body.dryRun) { + return NextResponse.json({ + success: true, + triggerDryRun: true, + dispatcherDryRun: payload.dryRun, + wouldEnqueue: false, + enumeratePayload: payload, + }); + } + + const qstashResponse = await enqueuePartnerContentEnumerate(payload); + + return NextResponse.json({ + success: true, + triggerDryRun: false, + dispatcherDryRun: payload.dryRun, + wouldEnqueue: true, + runStamp, + enumeratePayload: payload, + qstashResponse, + }); + } catch (error) { + return handleAndReturnErrorResponse(error); + } + }, + { + requiredRoles: ["owner"], + }, +); diff --git a/apps/web/app/(ee)/api/cron/partner-content/embed/route.ts b/apps/web/app/(ee)/api/cron/partner-content/embed/route.ts new file mode 100644 index 00000000000..aa57cfd262f --- /dev/null +++ b/apps/web/app/(ee)/api/cron/partner-content/embed/route.ts @@ -0,0 +1,224 @@ +import { qstash } from "@/lib/cron"; +import { withCron } from "@/lib/cron/with-cron"; +import { PARTNER_CONTENT_SEARCH_MODELS } from "@/lib/partner-content-search/constants"; +import { refreshPartnerContentItemChunkCounts } from "@/lib/partner-content-search/ingestion/chunk-counts"; +import { + createPartnerContentDeduplicationId, + enqueueEmbedJobsForPartnerPlatform, + getPartnerContentUrl, + parsePartnerContentCronPayload, + PARTNER_CONTENT_EMBED_FLOW_CONTROL, + PARTNER_CONTENT_SEARCH_ROUTES, + partnerContentEmbedPayloadSchema, +} from "@/lib/partner-content-search/ingestion/enqueue"; +import { + embedPartnerContentTexts, + serializeEmbeddingForVector, + VoyageApiError, +} from "@/lib/partner-content-search/voyage"; +import { prisma } from "@/lib/prisma"; +import { chunk } from "@dub/utils"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +const EMBED_RATE_LIMIT_RETRY_DELAY_SECONDS = 120; +const EMBED_UPDATE_BATCH_SIZE = 16; + +type UnembeddedChunk = { + id: string; + chunkText: string; +}; + +// POST /api/cron/partner-content/embed +export const POST = withCron(async ({ rawBody }) => { + const payload = parsePartnerContentCronPayload( + partnerContentEmbedPayloadSchema, + rawBody, + ); + if (payload instanceof Response) return payload; + + if (!payload.partnerContentItemId) { + return enqueueEmbedJobsForPartnerPlatform(payload); + } + + const contentItem = await prisma.partnerContentItem.findUnique({ + where: { + id: payload.partnerContentItemId, + }, + select: { + id: true, + partnerId: true, + totalChunkCount: true, + embeddedChunkCount: true, + // need country and platform type for denormalized pre-filter columns + partner: { + select: { + country: true, + }, + }, + partnerPlatform: { + select: { + type: true, + }, + }, + }, + }); + + if (!contentItem) { + return logAndRespond( + `[PartnerContentSearch] Content item ${payload.partnerContentItemId} not found for ${payload.mode} embed run ${payload.runStamp}.`, + { status: 404, logLevel: "warn" }, + ); + } + + if (contentItem.partnerId !== payload.partnerId) { + return logAndRespond( + `[PartnerContentSearch] Content item ${payload.partnerContentItemId} did not match embed payload for ${payload.mode} run ${payload.runStamp}.`, + { status: 400, logLevel: "warn" }, + ); + } + + if (contentItem.totalChunkCount === 0) { + return logAndRespond( + `[PartnerContentSearch] Skipping embed for content item ${contentItem.id}: no chunks for ${payload.mode} run ${payload.runStamp}.`, + ); + } + + const chunks = await prisma.$queryRaw` + SELECT id, chunkText + FROM PartnerContentChunk + WHERE partnerContentItemId = ${contentItem.id} + AND embedding IS NULL + AND embeddingModel = ${PARTNER_CONTENT_SEARCH_MODELS.embedding.id} + ORDER BY source ASC, chunkIndex ASC + LIMIT ${payload.maxChunks} + `; + + if (chunks.length === 0) { + const embeddedChunkCount = await refreshEmbeddedChunkCount(contentItem.id); + + return logAndRespond( + `[PartnerContentSearch] Content item ${contentItem.id} already fully embedded (${embeddedChunkCount}/${contentItem.totalChunkCount}) for ${payload.mode} run ${payload.runStamp}.`, + ); + } + + let embeddings: number[][]; + + try { + embeddings = await embedPartnerContentTexts({ + input: chunks.map(({ chunkText }) => chunkText), + inputType: "document", + }); + } catch (error) { + if (!isVoyageRateLimitError(error)) throw error; + + await qstash.publishJSON({ + url: getPartnerContentUrl(PARTNER_CONTENT_SEARCH_ROUTES.embed), + method: "POST", + body: { + mode: payload.mode, + runStamp: payload.runStamp, + partnerId: payload.partnerId, + partnerContentItemId: contentItem.id, + maxChunks: payload.maxChunks, + }, + flowControl: PARTNER_CONTENT_EMBED_FLOW_CONTROL, + delay: getRateLimitRetryDelaySeconds(contentItem.id), + deduplicationId: createPartnerContentDeduplicationId( + "partner-content-embed-rate-limit-retry", + payload.mode, + payload.runStamp, + contentItem.id, + ), + }); + + return logAndRespond( + `[PartnerContentSearch] Voyage rate-limited embed for content item ${contentItem.id}; retry scheduled for ${payload.mode} run ${payload.runStamp}.`, + ); + } + + if (embeddings.length !== chunks.length) { + throw new Error( + `Voyage returned ${embeddings.length} embeddings for ${chunks.length} chunks.`, + ); + } + + // Denormalized pre-filter values saved with embeddings. Both are ~static + const country = contentItem.partner.country; + const platformType = contentItem.partnerPlatform.type; + + const updates = chunks.map( + (contentChunk, index) => + prisma.$executeRaw` + UPDATE PartnerContentChunk + SET embedding = TO_VECTOR(${serializeEmbeddingForVector(embeddings[index])}), + embeddingModel = ${PARTNER_CONTENT_SEARCH_MODELS.embedding.id}, + country = ${country}, + platformType = ${platformType} + WHERE id = ${contentChunk.id} + `, + ); + + for (const batch of chunk(updates, EMBED_UPDATE_BATCH_SIZE)) { + await Promise.all(batch); + } + + const embeddedChunkCount = await refreshEmbeddedChunkCount(contentItem.id); + const remainingChunkCount = Math.max( + 0, + contentItem.totalChunkCount - embeddedChunkCount, + ); + + if (remainingChunkCount > 0) { + await qstash.publishJSON({ + url: getPartnerContentUrl(PARTNER_CONTENT_SEARCH_ROUTES.embed), + method: "POST", + body: { + mode: payload.mode, + runStamp: payload.runStamp, + partnerId: payload.partnerId, + partnerContentItemId: contentItem.id, + maxChunks: payload.maxChunks, + }, + flowControl: PARTNER_CONTENT_EMBED_FLOW_CONTROL, + deduplicationId: createPartnerContentDeduplicationId( + "partner-content-embed", + payload.mode, + payload.runStamp, + contentItem.id, + embeddedChunkCount, + ), + }); + } + + return logAndRespond( + `[PartnerContentSearch] Embedded ${chunks.length} chunks for content item ${contentItem.id} (${embeddedChunkCount}/${contentItem.totalChunkCount}, ${remainingChunkCount} remaining) on ${payload.mode} run ${payload.runStamp}.`, + ); +}); + +async function refreshEmbeddedChunkCount(partnerContentItemId: string) { + const { embeddedChunkCount } = + await refreshPartnerContentItemChunkCounts(partnerContentItemId); + + return embeddedChunkCount; +} + +function isVoyageRateLimitError(error: unknown) { + return error instanceof VoyageApiError && error.status === 429; +} + +function getRateLimitRetryDelaySeconds(contentItemId: string) { + return ( + EMBED_RATE_LIMIT_RETRY_DELAY_SECONDS + + (getStableNumericHash(contentItemId) % 120) + ); +} + +function getStableNumericHash(value: string) { + return Array.from(value).reduce( + (hash, char) => (hash * 31 + char.charCodeAt(0)) >>> 0, + 0, + ); +} diff --git a/apps/web/app/(ee)/api/cron/partner-content/enumerate/page/route.ts b/apps/web/app/(ee)/api/cron/partner-content/enumerate/page/route.ts new file mode 100644 index 00000000000..df740c1b29d --- /dev/null +++ b/apps/web/app/(ee)/api/cron/partner-content/enumerate/page/route.ts @@ -0,0 +1,111 @@ +import { qstash } from "@/lib/cron"; +import { withCron } from "@/lib/cron/with-cron"; +import { + buildEligiblePartnerPlatformWhere, + createPartnerContentDeduplicationId, + getPartnerContentUrl, + parsePartnerContentCronPayload, + PARTNER_CONTENT_SEARCH_ROUTES, + partnerContentEnumeratePagePayloadSchema, +} from "@/lib/partner-content-search/ingestion/enqueue"; +import { prisma } from "@/lib/prisma"; +import { chunk } from "@dub/utils"; +import { logAndRespond } from "../../../utils"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +// POST /api/cron/partner-content/enumerate/page +export const POST = withCron(async ({ rawBody }) => { + const payload = parsePartnerContentCronPayload( + partnerContentEnumeratePagePayloadSchema, + rawBody, + ); + if (payload instanceof Response) return payload; + + const partners = await prisma.partner.findMany({ + where: { + id: { + in: payload.partnerIds, + }, + }, + select: { + id: true, + platforms: { + where: buildEligiblePartnerPlatformWhere({ + mode: payload.mode, + platforms: payload.filter.platforms, + }), + select: { + id: true, + partnerId: true, + type: true, + identifier: true, + platformId: true, + contentLastFetchedAt: true, + latestContentUrl: true, + }, + orderBy: { + type: "asc", + }, + }, + }, + orderBy: { + id: "asc", + }, + }); + + const partnerPlatforms = partners.flatMap(({ id: partnerId, platforms }) => + platforms.map((platform) => ({ + partnerId, + partnerPlatformId: platform.id, + type: platform.type, + identifier: platform.identifier, + platformId: platform.platformId, + contentLastFetchedAt: platform.contentLastFetchedAt, + latestContentUrl: platform.latestContentUrl, + })), + ); + + if (partnerPlatforms.length === 0) { + return logAndRespond( + `[PartnerContentSearch] No eligible partner platforms found for ${payload.mode} run ${payload.runStamp}.`, + ); + } + + const fetchMessages = partnerPlatforms.map((partnerPlatform) => ({ + url: getPartnerContentUrl(PARTNER_CONTENT_SEARCH_ROUTES.fetch), + method: "POST" as const, + deduplicationId: createPartnerContentDeduplicationId( + "partner-content-fetch", + payload.mode, + payload.runStamp, + partnerPlatform.partnerPlatformId, + ), + body: { + mode: payload.mode, + runStamp: payload.runStamp, + dryRun: payload.dryRun, + forceTranscriptJobs: false, + partnerId: partnerPlatform.partnerId, + partnerPlatformId: partnerPlatform.partnerPlatformId, + platform: partnerPlatform.type, + }, + })); + + if (!payload.dryRun) { + for (const slice of chunk(fetchMessages, 100)) { + await qstash.batchJSON(slice); + } + } + + return logAndRespond( + `[PartnerContentSearch] ${ + payload.dryRun ? "Dry-run enumerated" : "Enqueued" + } ${fetchMessages.length} fetch jobs across ${ + partnerPlatforms.length + } platforms (${partners.length} partners) for ${payload.mode} run ${ + payload.runStamp + }.`, + ); +}); diff --git a/apps/web/app/(ee)/api/cron/partner-content/enumerate/route.ts b/apps/web/app/(ee)/api/cron/partner-content/enumerate/route.ts new file mode 100644 index 00000000000..6b7778f8833 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/partner-content/enumerate/route.ts @@ -0,0 +1,129 @@ +import { qstash } from "@/lib/cron"; +import { withCron } from "@/lib/cron/with-cron"; +import { + buildEligiblePartnerWhere, + createPartnerContentDeduplicationId, + getPartnerContentUrl, + PARTNER_CONTENT_ENUMERATE_PAGE_SIZE, + PARTNER_CONTENT_SEARCH_ROUTES, + parsePartnerContentCronPayload, + partnerContentEnumeratePayloadSchema, +} from "@/lib/partner-content-search/ingestion/enqueue"; +import { prisma } from "@/lib/prisma"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +// POST /api/cron/partner-content/enumerate +export const POST = withCron(async ({ rawBody }) => { + const payload = parsePartnerContentCronPayload( + partnerContentEnumeratePayloadSchema, + rawBody, + ); + if (payload instanceof Response) return payload; + + // One page per invocation, handing the next cursor back to this route — keeps + // each run under maxDuration regardless of scale. + const remainingPartners = + payload.remainingPartners ?? payload.filter.limitPartners; + + const take = Math.min( + PARTNER_CONTENT_ENUMERATE_PAGE_SIZE, + remainingPartners ?? PARTNER_CONTENT_ENUMERATE_PAGE_SIZE, + ); + + const partners = await prisma.partner.findMany({ + where: buildEligiblePartnerWhere(payload), + select: { + id: true, + }, + ...(payload.startingAfter && { + cursor: { + id: payload.startingAfter, + }, + skip: 1, + }), + orderBy: { + id: "asc", + }, + take, + }); + + if (partners.length === 0) { + return logAndRespond( + `[PartnerContentSearch] No${ + payload.startingAfter ? " more" : "" + } eligible partners found for ${payload.mode} run ${payload.runStamp}.`, + ); + } + + const partnerIds = partners.map(({ id }) => id); + const lastPartnerId = partnerIds[partnerIds.length - 1]; + const nextRemainingPartners = + remainingPartners === undefined + ? undefined + : remainingPartners - partnerIds.length; + + // Only continue if this page was full and budget (if any) remains. + const hasMore = + partners.length === take && + (nextRemainingPartners === undefined || nextRemainingPartners > 0); + + const messages = [ + { + url: getPartnerContentUrl(PARTNER_CONTENT_SEARCH_ROUTES.enumeratePage), + method: "POST" as const, + deduplicationId: createPartnerContentDeduplicationId( + "partner-content-enum-page", + payload.mode, + partnerIds[0], + payload.runStamp, + ), + body: { + mode: payload.mode, + filter: payload.filter, + runStamp: payload.runStamp, + dryRun: payload.dryRun, + partnerIds, + }, + }, + // Pick up the next batch on a separate run when there are more partners. + ...(hasMore + ? [ + { + url: getPartnerContentUrl(PARTNER_CONTENT_SEARCH_ROUTES.enumerate), + method: "POST" as const, + deduplicationId: createPartnerContentDeduplicationId( + "partner-content-enumerate", + payload.mode, + payload.runStamp, + lastPartnerId, + ), + body: { + mode: payload.mode, + filter: payload.filter, + runStamp: payload.runStamp, + dryRun: payload.dryRun, + startingAfter: lastPartnerId, + ...(nextRemainingPartners !== undefined && { + remainingPartners: nextRemainingPartners, + }), + }, + }, + ] + : []), + ]; + + if (!payload.dryRun) { + await qstash.batchJSON(messages); + } + + return logAndRespond( + `[PartnerContentSearch] ${ + payload.dryRun ? "Dry-run enumerated" : "Enqueued" + } ${partnerIds.length} partners for ${payload.mode} run ${ + payload.runStamp + }${hasMore ? ` (continuing after ${lastPartnerId})` : " (final page)"}.`, + ); +}); diff --git a/apps/web/app/(ee)/api/cron/partner-content/fetch/route.ts b/apps/web/app/(ee)/api/cron/partner-content/fetch/route.ts new file mode 100644 index 00000000000..02976b984dd --- /dev/null +++ b/apps/web/app/(ee)/api/cron/partner-content/fetch/route.ts @@ -0,0 +1,128 @@ +import { withCron } from "@/lib/cron/with-cron"; +import { + parsePartnerContentCronPayload, + partnerContentFetchPayloadSchema, +} from "@/lib/partner-content-search/ingestion/enqueue"; +import { fetchRecentPlatformContent } from "@/lib/partner-content-search/ingestion/platforms"; +import { + isTranscriptEligibleContentItem, + writeFetchedContentItems, +} from "@/lib/partner-content-search/ingestion/write-fetched-content-items"; +import { prisma } from "@/lib/prisma"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +// POST /api/cron/partner-content/fetch +export const POST = withCron(async ({ rawBody }) => { + const payload = parsePartnerContentCronPayload( + partnerContentFetchPayloadSchema, + rawBody, + ); + if (payload instanceof Response) return payload; + + const partnerPlatform = await prisma.partnerPlatform.findUnique({ + where: { + id: payload.partnerPlatformId, + }, + select: { + id: true, + partnerId: true, + type: true, + identifier: true, + platformId: true, + contentLastFetchedAt: true, + latestContentUrl: true, + verifiedAt: true, + }, + }); + + if (!partnerPlatform) { + return logAndRespond( + `[PartnerContentSearch] Partner platform ${payload.partnerPlatformId} not found for ${payload.mode} run ${payload.runStamp}.`, + { status: 404, logLevel: "warn" }, + ); + } + + if ( + partnerPlatform.partnerId !== payload.partnerId || + partnerPlatform.type !== payload.platform + ) { + return logAndRespond( + `[PartnerContentSearch] Partner platform ${payload.partnerPlatformId} did not match fetch payload for ${payload.mode} run ${payload.runStamp}.`, + { status: 400, logLevel: "warn" }, + ); + } + + if (!partnerPlatform.verifiedAt && !payload.ignoreUnverified) { + return logAndRespond( + `[PartnerContentSearch] Partner platform ${partnerPlatform.id} is unverified; pass ignoreUnverified=true for a manual ${payload.mode} fetch on run ${payload.runStamp}.`, + { status: 400, logLevel: "warn" }, + ); + } + + const fetchedContentItems = await fetchRecentPlatformContent({ + platform: payload.platform, + platformId: partnerPlatform.platformId ?? undefined, + identifier: partnerPlatform.identifier, + }); + + const platformContentIds = fetchedContentItems.map( + ({ platformContentId }) => platformContentId, + ); + + const existingContentItems = + platformContentIds.length === 0 + ? [] + : await prisma.partnerContentItem.findMany({ + where: { + partnerPlatformId: partnerPlatform.id, + platformContentId: { + in: platformContentIds, + }, + }, + select: { + platformContentId: true, + }, + }); + + const existingContentIdSet = new Set( + existingContentItems.map(({ platformContentId }) => platformContentId), + ); + + const newContentItems = fetchedContentItems.filter( + ({ platformContentId }) => !existingContentIdSet.has(platformContentId), + ); + + const latestContentItem = fetchedContentItems[0]; + const contentItemsForTranscriptJobs = payload.forceTranscriptJobs + ? fetchedContentItems.filter(isTranscriptEligibleContentItem) + : newContentItems.filter(isTranscriptEligibleContentItem); + + const { contentItemsCreated, transcriptJobCount, embedJobCount } = + payload.dryRun + ? { contentItemsCreated: 0, transcriptJobCount: 0, embedJobCount: 0 } + : await writeFetchedContentItems({ + mode: payload.mode, + runStamp: payload.runStamp, + dryRun: payload.dryRun, + partnerId: payload.partnerId, + partnerPlatformId: partnerPlatform.id, + platform: payload.platform, + contentItems: newContentItems, + metadataSourceItems: fetchedContentItems, + contentItemsForTranscriptJobs, + latestContentUrl: latestContentItem?.url ?? null, + }); + + return logAndRespond( + `[PartnerContentSearch] ${ + payload.dryRun ? "Dry-run fetched" : "Fetched" + } ${fetchedContentItems.length} items (${newContentItems.length} new, ${ + existingContentItems.length + } existing) for partner platform ${partnerPlatform.id}: created ${contentItemsCreated}, enqueued ${transcriptJobCount} transcript jobs and ${embedJobCount} metadata embed jobs on ${ + payload.mode + } run ${payload.runStamp}.`, + ); +}); diff --git a/apps/web/app/(ee)/api/cron/partner-content/transcript/route.ts b/apps/web/app/(ee)/api/cron/partner-content/transcript/route.ts new file mode 100644 index 00000000000..86959d76d14 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/partner-content/transcript/route.ts @@ -0,0 +1,119 @@ +import { withCron } from "@/lib/cron/with-cron"; +import { + enqueueEmbedJob, + parsePartnerContentCronPayload, + partnerContentTranscriptPayloadSchema, +} from "@/lib/partner-content-search/ingestion/enqueue"; +import { fetchAndWriteTranscriptChunks } from "@/lib/partner-content-search/ingestion/fetch-and-write-transcript-chunks"; +import { prisma } from "@/lib/prisma"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +// POST /api/cron/partner-content/transcript +export const POST = withCron(async ({ rawBody }) => { + const payload = parsePartnerContentCronPayload( + partnerContentTranscriptPayloadSchema, + rawBody, + ); + if (payload instanceof Response) return payload; + + const contentItem = await prisma.partnerContentItem.findUnique({ + where: { + id: payload.partnerContentItemId, + }, + select: { + id: true, + partnerId: true, + partnerPlatformId: true, + platformContentId: true, + url: true, + title: true, + transcriptFetchStatus: true, + }, + }); + + if (!contentItem) { + return logAndRespond( + `[PartnerContentSearch] Content item ${payload.partnerContentItemId} not found for ${payload.mode} run ${payload.runStamp}.`, + { status: 404, logLevel: "warn" }, + ); + } + + if ( + contentItem.partnerId !== payload.partnerId || + contentItem.partnerPlatformId !== payload.partnerPlatformId + ) { + return logAndRespond( + `[PartnerContentSearch] Content item ${payload.partnerContentItemId} did not match transcript payload for ${payload.mode} run ${payload.runStamp}.`, + { status: 400, logLevel: "warn" }, + ); + } + + // QStash delivers at least once. If already fetched+chunked, skip the credit-burning + // re-fetch but still re-enqueue embed (recovers a previously-failed enqueue). + // forceRefetch forces a fresh fetch. + if ( + contentItem.transcriptFetchStatus === "fetched" && + !payload.forceRefetch + ) { + await enqueueEmbedJob({ + mode: payload.mode, + runStamp: payload.runStamp, + partnerId: contentItem.partnerId, + partnerContentItemId: contentItem.id, + }); + + return logAndRespond( + `[PartnerContentSearch] Content item ${contentItem.id} already transcribed; re-enqueued embed for ${payload.mode} run ${payload.runStamp}.`, + ); + } + + let transcriptResult: Awaited< + ReturnType + >; + + try { + transcriptResult = await fetchAndWriteTranscriptChunks({ + ...contentItem, + platform: payload.platform, + }); + } catch (error) { + await prisma.partnerContentItem.update({ + where: { + id: contentItem.id, + }, + data: { + transcriptFetchStatus: "error", + transcriptLastAttemptedAt: new Date(), + }, + }); + + throw error; + } + + if (!transcriptResult.transcriptAvailable) { + await enqueueEmbedJob({ + mode: payload.mode, + runStamp: payload.runStamp, + partnerId: contentItem.partnerId, + partnerContentItemId: contentItem.id, + }); + + return logAndRespond( + `[PartnerContentSearch] No transcript available for content item ${contentItem.id}; re-enqueued embed for existing chunks on ${payload.mode} run ${payload.runStamp}.`, + ); + } + + await enqueueEmbedJob({ + mode: payload.mode, + runStamp: payload.runStamp, + partnerId: contentItem.partnerId, + partnerContentItemId: contentItem.id, + }); + + return logAndRespond( + `[PartnerContentSearch] Transcribed content item ${contentItem.id}: ${transcriptResult.chunkCount} chunks (${transcriptResult.chunksCreated} created), embed enqueued for ${payload.mode} run ${payload.runStamp}.`, + ); +}); diff --git a/apps/web/app/(ee)/api/network/partners/content-search/route.ts b/apps/web/app/(ee)/api/network/partners/content-search/route.ts new file mode 100644 index 00000000000..265f39232b4 --- /dev/null +++ b/apps/web/app/(ee)/api/network/partners/content-search/route.ts @@ -0,0 +1,258 @@ +import { DubApiError } from "@/lib/api/errors"; +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { parseRequestBody } from "@/lib/api/utils"; +import { withWorkspace } from "@/lib/auth"; +import { + PARTNER_CONTENT_SEARCH_MODELS, + PARTNER_CONTENT_SEARCH_PARTNER_LIMIT, +} from "@/lib/partner-content-search/constants"; +import { listPartnerNetworkContent } from "@/lib/partner-content-search/listing"; +import { getPartnerMatchSummaries } from "@/lib/partner-content-search/match-summaries"; +import { getNetworkPartnersById } from "@/lib/partner-content-search/network-partners"; +import { + createContentMatchEvidence, + getEvidenceSource, + getRowRelevanceScore, + sortPartnersByTopicFit, +} from "@/lib/partner-content-search/ranking"; +import { searchPartnerNetworkContent } from "@/lib/partner-content-search/retrieval"; +import { + getCandidateChunkCount, + groupPartnerSearchResults, + toScore, + type PartnerContentSearchRow, +} from "@/lib/partner-content-search/search-utils"; +import { createPartnerContentSearchTimingLogger } from "@/lib/partner-content-search/timing"; +import { prisma } from "@/lib/prisma"; +import { + partnerNetworkContentSearchResponseSchema, + partnerNetworkContentSearchSchema, +} from "@/lib/zod/schemas/partner-network"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 30; + +// POST /api/network/partners/content-search - network discover content search; returns ranked partners with matching chunks +export const POST = withWorkspace( + async ({ workspace, req }) => { + const programId = getDefaultProgramIdOrThrow(workspace); + + const { partnerNetworkEnabledAt } = await prisma.program.findUniqueOrThrow({ + select: { + partnerNetworkEnabledAt: true, + }, + where: { + id: programId, + }, + }); + + if (!partnerNetworkEnabledAt) { + throw new DubApiError({ + code: "forbidden", + message: "Partner network is not enabled for this program.", + }); + } + + const body = partnerNetworkContentSearchSchema.parse( + await parseRequestBody(req), + ); + const candidateChunkCount = getCandidateChunkCount({ + hasQuery: Boolean(body.query), + limit: body.limit, + chunksPerPartner: body.chunksPerPartner, + }); + const logTiming = createPartnerContentSearchTimingLogger({ + workspaceId: workspace.id, + programId, + hasQuery: Boolean(body.query), + queryLength: body.query?.length ?? 0, + platforms: body.platforms ?? null, + reach: body.reach ?? null, + country: body.country ?? null, + starred: body.starred ?? null, + limit: body.limit, + chunksPerPartner: body.chunksPerPartner, + candidateChunkCount, + rerank: body.rerank, + }); + + logTiming("request-parsed"); + + const { rows, reranked, queryVector, cutoffDistance, itemSourceBestDistance } = + body.query + ? await searchPartnerNetworkContent({ + programId, + query: body.query, + platforms: body.platforms, + country: body.country, + partnerIds: body.partnerIds, + starred: body.starred, + limit: candidateChunkCount, + rerank: body.rerank, + logTiming, + }) + : { + rows: await listPartnerNetworkContent({ + programId, + platforms: body.platforms, + country: body.country, + partnerIds: body.partnerIds, + starred: body.starred, + limit: candidateChunkCount, + }), + reranked: false, + queryVector: null, + cutoffDistance: null, + itemSourceBestDistance: undefined, + }; + logTiming("content-rows-loaded", { + rowCount: rows.length, + reranked, + cutoffDistance, + }); + const partnerCandidateLimit = body.query + ? Math.min( + PARTNER_CONTENT_SEARCH_PARTNER_LIMIT, + Math.max(body.limit, body.limit * 3), + ) + : body.limit; + const partnerCandidates = groupPartnerSearchResults({ + rows, + limit: partnerCandidateLimit, + chunksPerPartner: body.chunksPerPartner, + toChunkResult, + dedupeKey: ({ partnerContentItemId }) => partnerContentItemId, + getRowScore: getRowRelevanceScore, + }); + logTiming("partner-candidates-grouped", { + partnerCandidateCount: partnerCandidates.length, + partnerCandidateLimit, + }); + const partnerIdsToHydrate = partnerCandidates.map( + ({ partnerId }) => partnerId, + ); + logTiming("partner-enrichment-start", { + partnerCandidateCount: partnerCandidates.length, + }); + // Match summaries and card hydration share no inputs; run them in parallel + const [matchSummaries, networkPartners] = await Promise.all([ + getPartnerMatchSummaries({ + rows, + partnerIds: partnerIdsToHydrate, + platforms: body.platforms, + queryVector, + cutoffDistance, + itemSourceBestDistance, + logTiming, + }), + getNetworkPartnersById({ + programId, + partnerIds: partnerIdsToHydrate, + platforms: body.platforms, + reach: body.reach, + country: body.country, + }), + ]); + logTiming("partner-enrichment-complete", { + summaryCount: matchSummaries.size, + hydratedPartnerCount: networkPartners.size, + }); + const partners = partnerCandidates + .map((partner) => { + const networkPartner = networkPartners.get(partner.partnerId); + if (!networkPartner) return null; + + return { + ...partner, + partner: networkPartner, + matchSummary: matchSummaries.get(partner.partnerId) ?? null, + }; + }) + .filter(isNonNull); + const sortedPartners = body.query + ? sortPartnersByTopicFit(partners) + : partners; + logTiming("response-ready", { + returnedPartnerCount: Math.min(sortedPartners.length, body.limit), + }); + + return NextResponse.json( + partnerNetworkContentSearchResponseSchema.parse({ + success: true, + query: body.query ?? null, + platforms: body.platforms ?? null, + country: body.country ?? null, + candidateChunkCount, + embeddingModel: PARTNER_CONTENT_SEARCH_MODELS.embedding.id, + reranked, + rerankModel: reranked + ? PARTNER_CONTENT_SEARCH_MODELS.reranker.model + : null, + resultCount: rows.length, + partners: sortedPartners.slice(0, body.limit), + }), + ); + }, + { + requiredPlan: ["enterprise", "advanced"], + }, +); + +function isNonNull(value: T | null): value is T { + return value !== null; +} + +function toChunkResult(row: PartnerContentSearchRow, distance: number) { + return { + chunkId: row.chunkId, + partnerContentItemId: row.partnerContentItemId, + platform: { + type: row.platformType, + identifier: row.platformIdentifier, + }, + content: { + platformContentId: row.platformContentId, + url: row.contentUrl, + type: row.contentType, + title: row.contentTitle, + description: row.contentDescription ?? null, + thumbnailUrl: row.contentThumbnailUrl, + publishedAt: row.contentPublishedAt?.toISOString() ?? null, + durationMs: row.contentDurationMs, + viewCount: + row.contentViewCount != null ? Number(row.contentViewCount) : null, + likeCount: + row.contentLikeCount != null ? Number(row.contentLikeCount) : null, + commentCount: + row.contentCommentCount != null + ? Number(row.contentCommentCount) + : null, + shareCount: + row.contentShareCount != null ? Number(row.contentShareCount) : null, + saveCount: + row.contentSaveCount != null ? Number(row.contentSaveCount) : null, + }, + chunk: { + source: row.chunkSource, + text: row.chunkText, + startMs: row.startMs, + endMs: row.endMs, + }, + distance, + score: getRowRelevanceScore(row), + cosineScore: toScore(distance), + rerankScore: row.rerankScore ?? null, + matchEvidence: createContentMatchEvidence({ + contentType: row.contentType, + transcriptScore: + getEvidenceSource(row.chunkSource) === "transcript" + ? row.rerankScore ?? toScore(distance) + : null, + creatorTextScore: + getEvidenceSource(row.chunkSource) === "creatorText" + ? row.rerankScore ?? toScore(distance) + : null, + }), + }; +} diff --git a/apps/web/app/(ee)/api/network/partners/count/route.ts b/apps/web/app/(ee)/api/network/partners/count/route.ts index 62a4fdf35eb..f7ee03dd2f2 100644 --- a/apps/web/app/(ee)/api/network/partners/count/route.ts +++ b/apps/web/app/(ee)/api/network/partners/count/route.ts @@ -1,6 +1,7 @@ import { DubApiError } from "@/lib/api/errors"; import { partnerNetworkListingParts, + partnerReachWhere, partnerWhereFromListingParts, } from "@/lib/api/network/partner-network-listing-where"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; @@ -30,7 +31,7 @@ export const GET = withWorkspace( }); } - const { partnerIds, status, groupBy, country, starred, platform } = + const { partnerIds, status, groupBy, country, starred, platform, reach } = getNetworkPartnersCountQuerySchema.parse(searchParams); const listingParts = partnerNetworkListingParts({ @@ -41,6 +42,9 @@ export const GET = withWorkspace( const commonWhere = partnerWhereFromListingParts(listingParts); + // Same max-subscribers-in-tier rule as the listing. Applied to discover only. + const reachWhere = partnerReachWhere({ reach, platform }); + const statusWheres = { discover: { programs: { none: { programId } }, @@ -104,6 +108,7 @@ export const GET = withWorkspace( where: { ...commonWhere, ...statusWheres.discover, + ...reachWhere, // reach filter only applies to discover }, }) : undefined, @@ -143,7 +148,12 @@ export const GET = withWorkspace( const countries = await prisma.partner.groupBy({ by: ["country"], _count: true, - where: { ...commonWhere, ...statusWhereForFacet }, + where: { + ...commonWhere, + ...statusWhereForFacet, + // reach filter only applies to discover + ...(!status || status === "discover" ? reachWhere : {}), + }, orderBy: { _count: { country: "desc", diff --git a/apps/web/app/(ee)/api/network/partners/route.ts b/apps/web/app/(ee)/api/network/partners/route.ts index d0c9c8c8d33..2a723ccbe29 100644 --- a/apps/web/app/(ee)/api/network/partners/route.ts +++ b/apps/web/app/(ee)/api/network/partners/route.ts @@ -1,5 +1,6 @@ import { DubApiError } from "@/lib/api/errors"; import { calculatePartnerRanking } from "@/lib/api/network/calculate-partner-ranking"; +import { parseRankedNetworkPartners } from "@/lib/api/network/normalize-ranked-network-partner"; import { partnerNetworkListingWhere } from "@/lib/api/network/partner-network-listing-where"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; @@ -9,9 +10,7 @@ import { NetworkPartnerSchema, getNetworkPartnersQuerySchema, } from "@/lib/zod/schemas/partner-network"; -import { PreferredEarningStructure, SalesChannel } from "@prisma/client"; import { NextResponse } from "next/server"; -import * as z from "zod/v4"; // GET /api/network/partners - get all available partners in the network export const GET = withWorkspace( @@ -51,8 +50,8 @@ export const GET = withWorkspace( pageSize, country, starred, - sortBy, platform, + reach, } = getNetworkPartnersQuerySchema.parse(searchParams); if (status !== "discover") { @@ -116,7 +115,6 @@ export const GET = withWorkspace( similarityScore: sp.similarityScore, })); - console.time("calculatePartnerRanking"); const partners = await calculatePartnerRanking({ programId, partnerIds, @@ -125,40 +123,12 @@ export const GET = withWorkspace( page, pageSize, starred: starred ?? undefined, - sortBy, platform: platform ?? undefined, + reach: reach ?? undefined, similarPrograms, }); - console.timeEnd("calculatePartnerRanking"); - return NextResponse.json( - z.array(NetworkPartnerSchema).parse( - partners.map((partner) => ({ - ...partner, - starredAt: partner.starredAt ? new Date(partner.starredAt) : null, - ignoredAt: partner.ignoredAt ? new Date(partner.ignoredAt) : null, - invitedAt: partner.invitedAt ? new Date(partner.invitedAt) : null, - identityVerificationStatus: - partner.identityVerificationStatus ?? null, - identityVerifiedAt: partner.identityVerifiedAt - ? new Date(partner.identityVerifiedAt) - : null, - categories: partner.categories - ? partner.categories.split(",").map((c: string) => c.trim()) - : [], - preferredEarningStructures: partner.preferredEarningStructures - ? partner.preferredEarningStructures - .split(",") - .map((e: string) => e.trim() as PreferredEarningStructure) - : [], - salesChannels: partner.salesChannels - ? partner.salesChannels - .split(",") - .map((s: string) => s.trim() as SalesChannel) - : [], - })), - ), - ); + return NextResponse.json(parseRankedNetworkPartners(partners)); }, { requiredPlan: ["enterprise", "advanced"], diff --git a/apps/web/app/api/partner-content/thumbnail/route.ts b/apps/web/app/api/partner-content/thumbnail/route.ts new file mode 100644 index 00000000000..a5d1f7b235e --- /dev/null +++ b/apps/web/app/api/partner-content/thumbnail/route.ts @@ -0,0 +1,50 @@ +import { isInstagramCdnUrl } from "@/lib/partner-content-search/thumbnail-url"; +import { fetchWithTimeout } from "@dub/utils"; +import { NextRequest, NextResponse } from "next/server"; + +export const maxDuration = 15; + +const THUMBNAIL_FETCH_TIMEOUT_MS = 5000; + +export async function GET(req: NextRequest) { + const url = req.nextUrl.searchParams.get("url"); + + if (!url || !isInstagramCdnUrl(url)) { + return new NextResponse("Invalid thumbnail URL", { status: 400 }); + } + + let response: Response; + try { + response = await fetchWithTimeout( + url, + { + headers: { + Accept: + "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + "User-Agent": + "Mozilla/5.0 (compatible; DubPartnerContentPreview/1.0; +https://dub.co)", + }, + // Block redirect-based SSRF off the allowlisted host. + redirect: "error", + }, + THUMBNAIL_FETCH_TIMEOUT_MS, + ); + } catch { + return new NextResponse("Failed to fetch thumbnail", { status: 502 }); + } + + const contentType = response.headers.get("content-type") ?? ""; + + if (!response.ok || !response.body || !contentType.startsWith("image/")) { + return new NextResponse("Failed to fetch thumbnail", { status: 502 }); + } + + const headers = new Headers({ + "Cache-Control": "public, max-age=3600, s-maxage=86400", + "Content-Type": contentType, + }); + const contentLength = response.headers.get("content-length"); + if (contentLength) headers.set("Content-Length", contentLength); + + return new NextResponse(response.body, { headers }); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/content-match-row.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/content-match-row.tsx new file mode 100644 index 00000000000..da35c25288b --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/content-match-row.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { PARTNER_CONTENT_SEARCH_LIMITS } from "@/lib/partner-content-search/constants"; +import { type PartnerContentSearchPartner } from "@/lib/swr/use-partner-content-search"; +import { cn } from "@dub/utils"; +import { + contentNoun, + formatDuration, + formatEngagement, + formatMatchPercent, + formatPublishedDate, + formatTimestamp, + getContentHref, + getContentThumbnail, + getContentTitle, +} from "../content-display-utils"; +import { PlatformIcon } from "../platform-icon"; +import { ContentThumbnail } from "./content-thumbnail"; +import { type MatchedContentItem } from "./search-fit-utils"; + +export function ContentMatchSkeletons({ count }: { count: number }) { + return ( + <> + {[...Array(count)].map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} + + ); +} + +export function ContentMatchRow({ item }: { item: MatchedContentItem }) { + const { chunk } = item; + // Only transcript chunks display in UI. + const isTranscriptChunk = chunk.chunk.source !== "metadata"; + const snippet = isTranscriptChunk ? getMatchSnippet(chunk) : null; + const excerpt = snippet ? `"…${snippet.slice(0, 130).trimEnd()}…"` : null; + const thumbnail = getContentThumbnail(chunk); + const baseMeta = [ + formatDuration(chunk.content.durationMs), + formatPublishedDate(item.publishedAt), + ] + .filter(Boolean) + .join(" · "); + const matchLocation = formatMatchLocation(chunk.chunk); + const engagement = formatEngagement(chunk.content); + const score = item.relevance; + const title = getContentTitle(chunk); + + return ( + + {/* Preview image */} + +
+
+ + + {title} + +
+
+ {baseMeta && {baseMeta}} + {matchLocation && ( + <> + {baseMeta && ·} + + {matchLocation} + + + )} + {engagement && ( + <> + {(baseMeta || matchLocation) && ( + · + )} + {engagement} + + )} +
+ {excerpt && ( +

+ {excerpt} +

+ )} +
+
+ + {formatMatchPercent(score)} + + + match + +
+
+ ); +} + +// Where the match was found, for the row meta: "Matched transcript 1:23 - 1:45", +// "Matched transcript" (untimed), or "Matched creator text". +function formatMatchLocation({ + source, + startMs, + endMs, +}: PartnerContentSearchPartner["chunks"][number]["chunk"]) { + if (source === "metadata") return "Matched creator text"; + if (startMs === null && endMs === null) return "Matched transcript"; + if (startMs !== null && endMs !== null) { + return `Matched transcript ${formatTimestamp(startMs)} - ${formatTimestamp(endMs)}`; + } + return `Matched transcript ${formatTimestamp(startMs ?? endMs ?? 0)}`; +} + +function getMatchSnippet(chunk: PartnerContentSearchPartner["chunks"][number]) { + return (chunk.chunk.text ?? "").trim() || null; +} + +// Noun phrase for the rank window (time-based but capped per partner); says so +// explicitly when the cap bites instead of implying full coverage. +export function formatRankWindowPhrase( + summary: PartnerContentSearchPartner["matchSummary"] | undefined, +) { + if (!summary || !summary.recentContentCount) return null; + + const { recentContentCount, oldestPublishedAt, newestPublishedAt } = summary; + const noun = contentNoun(summary.platforms ?? [], recentContentCount); + const oldest = formatMonthYear(oldestPublishedAt); + const newest = formatMonthYear(newestPublishedAt); + const countCapped = + recentContentCount >= PARTNER_CONTENT_SEARCH_LIMITS.recentContentMaxPerPartner; + + if (countCapped) { + return oldest + ? `the ${recentContentCount} most recent ${noun}, back to ${oldest}` + : `the ${recentContentCount} most recent ${noun}`; + } + + if (oldest && newest) { + return oldest === newest + ? `${recentContentCount} ${noun} from ${oldest}` + : `${recentContentCount} ${noun}, ${oldest} – ${newest}`; + } + + return `${recentContentCount} recent ${noun}`; +} + +function formatMonthYear(iso: string | null) { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + + return new Intl.DateTimeFormat(undefined, { + month: "short", + year: "numeric", + }).format(date); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/content-thumbnail.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/content-thumbnail.tsx new file mode 100644 index 00000000000..719b88553b1 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/content-thumbnail.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { PlatformIcon } from "../platform-icon"; + +// In case platform CDN thumbnails expire; fall back to the platform icon on load failure. +export function ContentThumbnail({ + thumbnail, + platform, +}: { + thumbnail: string | null; + platform: string; +}) { + const [hasImageError, setHasImageError] = useState(false); + const thumbnailSrc = hasImageError ? null : thumbnail; + + // Rows recycle as the list re-renders, so clear a prior failure when the source + // changes — otherwise a fresh/valid URL stays masked by an earlier load error. + useEffect(() => { + setHasImageError(false); + }, [thumbnail]); + + return ( +
+ {thumbnailSrc ? ( + setHasImageError(true)} + /> + ) : ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/coverage-summary-bar.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/coverage-summary-bar.tsx new file mode 100644 index 00000000000..c5c6ba94ec9 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/coverage-summary-bar.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { type PartnerContentSearchPartner } from "@/lib/swr/use-partner-content-search"; +import { cn } from "@dub/utils"; + +// Single coverage summary bar from summary counts (strong / partial / no match). +const SEGMENTS = [ + { key: "strong", label: "Strong", color: "bg-[#1D9E75]" }, + { key: "partial", label: "Partial", color: "bg-[#EF9F27]" }, + { key: "none", label: "No match", color: "bg-neutral-300" }, +] as const; + +export function CoverageSummaryBar({ + summary, +}: { + summary: PartnerContentSearchPartner["matchSummary"] | undefined; +}) { + if (!summary || !summary.recentContentCount) return null; + + const { + recentContentCount, + matchedContentCount, + strongMatchedContentCount, + partialMatchedContentCount, + } = summary; + const noMatchCount = Math.max(0, recentContentCount - matchedContentCount); + + const counts: Record<(typeof SEGMENTS)[number]["key"], number> = { + strong: strongMatchedContentCount, + partial: partialMatchedContentCount, + none: noMatchCount, + }; + + return ( +
+
+ {SEGMENTS.filter(({ key }) => counts[key] > 0).map(({ key, color }) => ( +
+ ))} +
+
+ {SEGMENTS.map(({ key, label, color }) => ( + + + {label} {counts[key]} + + ))} +
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/network-invite-control.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/network-invite-control.tsx new file mode 100644 index 00000000000..3fb4d84c8a2 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/network-invite-control.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { NetworkPartnerProps } from "@/lib/types"; +import { Button } from "@dub/ui"; +import { EnvelopeArrowRight } from "@dub/ui/icons"; +import { EmailContent } from "app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-email-preview"; +import { InviteNetworkPartnerSheet } from "app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-network-partner-sheet"; +import { useState } from "react"; + +export function NetworkInviteControl({ + partner, + nested, + onSuccess, +}: { + partner: NetworkPartnerProps; + nested?: boolean; + onSuccess: () => void; +}) { + const [showInviteSheet, setShowInviteSheet] = useState(false); + const [inviteEmailContent, setInviteEmailContent] = + useState(null); + + if (partner.invitedAt || partner.recruitedAt) return null; + + return ( + <> + +
+ ) : null} +
+ ); +} + +function RecentContentRow({ item }: { item: RecentContentItem }) { + const meta = [formatDuration(item.durationMs), formatPublishedDate(item.publishedAt)] + .filter(Boolean) + .join(" · "); + + return ( + + +
+
+ + + {item.title} + +
+
+ {meta && {meta}} + {item.engagement && ( + <> + {meta && ·} + {item.engagement} + + )} +
+
+
+ ); +} + +// One row per content item (chunks repeat per item — keep the first, its content +// fields are identical across its chunks). +function buildRecentItems( + chunks: PartnerContentSearchPartner["chunks"], +): RecentContentItem[] { + const byContentItemId = new Map(); + + for (const chunk of chunks) { + if (byContentItemId.has(chunk.partnerContentItemId)) continue; + byContentItemId.set(chunk.partnerContentItemId, { + contentItemId: chunk.partnerContentItemId, + platform: chunk.platform.type, + title: getContentTitle(chunk), + href: getContentHref(chunk), + thumbnail: getContentThumbnail(chunk), + publishedAt: chunk.content.publishedAt, + durationMs: chunk.content.durationMs, + viewCount: chunk.content.viewCount, + engagement: formatEngagement(chunk.content), + }); + } + + return [...byContentItemId.values()]; +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/search-fit-panel.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/search-fit-panel.tsx new file mode 100644 index 00000000000..61124eb1e66 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/search-fit-panel.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { + PARTNER_CONTENT_SEARCH_TOP_CONTENT, + type PartnerContentTopicFitBand, +} from "@/lib/partner-content-search/constants"; +import { type PartnerContentSearchPartner } from "@/lib/swr/use-partner-content-search"; +import { cn } from "@dub/utils"; +import { contentNoun } from "../content-display-utils"; +import { + ContentMatchRow, + ContentMatchSkeletons, + formatRankWindowPhrase, +} from "./content-match-row"; +import { CoverageSummaryBar } from "./coverage-summary-bar"; +import { RecentContentList } from "./recent-content-panel"; +import { + buildContentRelevanceMap, + buildMatchedContentItems, +} from "./search-fit-utils"; + +const BAND_HEADLINE: Record = { + consistent: "Consistently posts about this topic", + frequent: "Frequently posts about this topic", + occasional: "Occasionally posts about this topic", + "one-off": "Posted about this topic once", + none: "No recent posts about this topic", +}; + +const TOPIC_FIT_BAND_CHIP: Record = { + consistent: "bg-green-50 text-green-700", + frequent: "bg-blue-50 text-blue-700", + occasional: "bg-amber-50 text-amber-700", + "one-off": "bg-neutral-100 text-neutral-600", + none: "bg-neutral-100 text-neutral-500", +}; + +const MAX_QUERY_DISPLAY_LENGTH = 60; + +// The search box is natural-language, so a long query would wrap the headline. Trim +// to a tidy length for inline display — the full query still drives the search. +function truncateQuery(query: string) { + const trimmed = query.trim(); + return trimmed.length > MAX_QUERY_DISPLAY_LENGTH + ? `${trimmed.slice(0, MAX_QUERY_DISPLAY_LENGTH).trimEnd()}…` + : trimmed; +} + +export function SearchFitPanel({ + error, + isLoading, + isRefining = false, + query, + summary: initialSummary, + reranked = false, + searchPartner, + recentChunks, + recentLoading, + recentError, +}: { + error: unknown; + isLoading: boolean; + isRefining?: boolean; + query?: string; + summary?: PartnerContentSearchPartner["matchSummary"]; + reranked?: boolean; + searchPartner?: PartnerContentSearchPartner; + // The partner's full recent content (matched + unmatched) for the "All recent + // content" section — fetched separately via the no-query path. + recentChunks?: PartnerContentSearchPartner["chunks"]; + recentLoading: boolean; + recentError: unknown; +}) { + const summary = initialSummary ?? searchPartner?.matchSummary; + + if (isLoading && !summary) { + return ; + } + + const isLoadingRows = isLoading || isRefining; + // Hold rows until the scoped rerank lands so scores/order don't swap in. + const chunks = isLoadingRows ? [] : (searchPartner?.chunks ?? []); + const relevanceByItemId = buildContentRelevanceMap(chunks, reranked); + const items = buildMatchedContentItems(summary, chunks, relevanceByItemId); + + const topContent = [...items] + .sort((a, b) => b.blendedScore - a.blendedScore) + .slice(0, PARTNER_CONTENT_SEARCH_TOP_CONTENT.topContentCount); + + const band = summary?.band ?? "none"; + const rankWindowPhrase = formatRankWindowPhrase(summary); + const topContentCaption = rankWindowPhrase + ? `Ranked by relevance + reach across ${rankWindowPhrase}.` + : "Ranked by relevance + reach."; + + return ( +
+
+
+
+ + {BAND_HEADLINE[band]} + + + Topic fit {summary?.topicFit ?? "—"} + +
+ {summary && ( +

+ {summary.matchedContentCount} of {summary.recentContentCount} recent{" "} + {contentNoun( + summary.platforms ?? [], + summary.recentContentCount, + )}{" "} + related to{" "} + {query ? ( + + “{truncateQuery(query)}” + + ) : ( + "your search" + )} + . +

+ )} +
+ + +
+ +
+ {/* Top matches — ranked by the relevance + reach blend */} +
+

+ Top relevant content +

+

+ {topContentCaption} + {isRefining && ( + · refining match… + )} +

+
+ +
+ {error ? ( +
+ Failed to load search matches +
+ ) : isLoadingRows ? ( + + ) : topContent.length ? ( + topContent.map((item) => ( + + )) + ) : ( +
+ No matching content found for this partner. +
+ )} +
+ + {/* All recent content — the partner's full feed (matched + unmatched), + newest-first, so a brand can judge their overall output, not just matches */} +
+ +
+
+
+ ); +} + +export function SearchFitPanelSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/search-fit-utils.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/search-fit-utils.ts new file mode 100644 index 00000000000..88b79f50933 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/[partnerId]/search-fit-utils.ts @@ -0,0 +1,108 @@ +import { + getBlendedTopContentScore, + getViewBaseline, +} from "@/lib/partner-content-search/top-content-ranking"; +import { + type PartnerContentMatchEvidence, + type PartnerContentSearchPartner, +} from "@/lib/swr/use-partner-content-search"; + +export const DETAIL_CONTENT_INITIAL_DISPLAY_COUNT = 8; +export const DETAIL_CONTENT_PAGE_COUNT = 8; + +export type MatchedContentItem = { + contentItemId: string; + platform: string; + publishedAt: string | null; + viewCount: number | null; + relevance: number; + blendedScore: number; + matchEvidence: PartnerContentMatchEvidence; + chunk: PartnerContentSearchPartner["chunks"][number]; +}; + +// Per-item relevance straight from the served chunks, on a single scale: rerank +// scores when the search reranked, cosine otherwise. The reranker already clips to +// its window and drops the cosine tail, so the two scales are never blended — and if +// the reranker failed, every row falls back to cosine together. +export function buildContentRelevanceMap( + chunks: PartnerContentSearchPartner["chunks"], + reranked: boolean, +) { + const map = new Map(); + for (const chunk of chunks) { + const score = reranked ? chunk.rerankScore : chunk.cosineScore; + if (score == null) continue; + + const current = map.get(chunk.partnerContentItemId); + if (current == null || score > current) { + map.set(chunk.partnerContentItemId, score); + } + } + return map; +} + +export function buildMatchedContentItems( + summary: PartnerContentSearchPartner["matchSummary"] | undefined, + chunks: PartnerContentSearchPartner["chunks"], + relevanceByItemId: Map, +): MatchedContentItem[] { + const matches = summary?.contentMatches ?? []; + + const bestChunkByContentItemId = new Map< + string, + PartnerContentSearchPartner["chunks"][number] + >(); + for (const chunk of chunks) { + const current = bestChunkByContentItemId.get(chunk.partnerContentItemId); + if (!current || isBetterDisplayChunk(chunk, current)) { + bestChunkByContentItemId.set(chunk.partnerContentItemId, chunk); + } + } + + const baselineViews = getViewBaseline(matches.map((match) => match.viewCount)); + + return matches + .filter((match) => match.matched) + .map((match): MatchedContentItem | null => { + // Skip rows we can't render; matched count in the header still comes from all matched content. + const chunk = bestChunkByContentItemId.get(match.partnerContentItemId); + if (!chunk) return null; + + // Single-scale relevance from the served chunks; no cross-scale fallback. + const relevance = relevanceByItemId.get(match.partnerContentItemId) ?? 0; + + return { + contentItemId: match.partnerContentItemId, + platform: match.platform, + publishedAt: match.publishedAt, + viewCount: match.viewCount, + relevance, + blendedScore: getBlendedTopContentScore({ + relevance, + views: match.viewCount, + baselineViews, + }), + matchEvidence: match.matchEvidence, + chunk, + }; + }) + .filter((item): item is MatchedContentItem => item !== null); +} + +// Prefer transcript chunks for display enrichment (excerpt + timed link). +function isBetterDisplayChunk( + candidate: PartnerContentSearchPartner["chunks"][number], + current: PartnerContentSearchPartner["chunks"][number], +) { + const candidateIsTranscript = candidate.chunk.source !== "metadata"; + const currentIsTranscript = current.chunk.source !== "metadata"; + if (candidateIsTranscript !== currentIsTranscript) return candidateIsTranscript; + return candidate.score > current.score; +} + +export function publishedAtMs(iso: string | null) { + if (!iso) return -Infinity; + const ms = new Date(iso).getTime(); + return Number.isNaN(ms) ? -Infinity : ms; +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/content-display-utils.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/content-display-utils.ts new file mode 100644 index 00000000000..7f12f1e2853 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/content-display-utils.ts @@ -0,0 +1,181 @@ +import type { PartnerContentTopicFitBand } from "@/lib/partner-content-search/constants"; +import { getPartnerContentThumbnailUrl } from "@/lib/partner-content-search/thumbnail-url"; +import type { PartnerContentSearchPartner } from "@/lib/swr/use-partner-content-search"; +import { nFormatter } from "@dub/utils"; + +// Shared display helpers for the network search surfaces (detail page + results +// list): formatters, link/thumbnail resolvers, and the band label map. + +export type ContentSearchChunk = PartnerContentSearchPartner["chunks"][number]; + +export const BAND_LABELS: Record = { + consistent: "Consistent", + frequent: "Frequent", + occasional: "Occasional", + "one-off": "One-off", + none: "No recent match", +}; + +export function formatTimestamp(ms: number) { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +export function formatDuration(durationMs: number | null) { + if (!durationMs || durationMs <= 0) return null; + + const totalSeconds = Math.floor(durationMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; + } + + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +export function formatPublishedDate(publishedAt: string | null) { + if (!publishedAt) return null; + + const date = new Date(publishedAt); + if (Number.isNaN(date.getTime())) return null; + + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(date); +} + +export function formatMatchPercent(score: number) { + return `${Math.round(Math.min(1, Math.max(0, score)) * 100)}%`; +} + +// Coarse "Nd / Nw / Nmo ago" relative label for the last on-topic/published post. +// timeAgo from @dub/utils goes absolute past ~23h, so we roll our own. +export function lastPostedLabel(iso: string | null | undefined) { + if (!iso) return null; + + const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000); + if (days <= 0) return "today"; + if (days < 7) return `${days}d ago`; + if (days < 8 * 7) return `${Math.floor(days / 7)}w ago`; + if (days < 365) return `${Math.floor(days / 30)}mo ago`; + return `${Math.floor(days / 365)}y ago`; +} + +// Platform-aware noun for a partner's content so labels read naturally per type, and +// only fall back to a generic term when the set spans types: YouTube/TikTok → "videos", +// Instagram/X/LinkedIn → "posts", website → "pages", mixed/unknown → "content items". +const PLATFORM_CONTENT_NOUN: Record = { + youtube: { one: "video", many: "videos" }, + tiktok: { one: "video", many: "videos" }, + instagram: { one: "post", many: "posts" }, + twitter: { one: "post", many: "posts" }, + x: { one: "post", many: "posts" }, + linkedin: { one: "post", many: "posts" }, + website: { one: "page", many: "pages" }, +}; + +// Reach + engagement we have for a post, in descending prominence. Shared by the +// matched-content rows and the no-search content rows. +export function formatEngagement(content: ContentSearchChunk["content"]) { + return [ + content.viewCount ? `${nFormatter(content.viewCount)} views` : null, + content.likeCount ? `${nFormatter(content.likeCount)} likes` : null, + content.commentCount ? `${nFormatter(content.commentCount)} comments` : null, + content.shareCount ? `${nFormatter(content.shareCount)} shares` : null, + content.saveCount ? `${nFormatter(content.saveCount)} saves` : null, + ] + .filter(Boolean) + .join(" · "); +} + +export function contentNoun(platforms: string[], count: number) { + const plural = count !== 1; + const manyForms = new Set( + platforms + .map((platform) => PLATFORM_CONTENT_NOUN[platform]?.many) + .filter((noun): noun is string => Boolean(noun)), + ); + + if (manyForms.size === 1) { + const many = [...manyForms][0]; + if (plural) return many; + const entry = Object.values(PLATFORM_CONTENT_NOUN).find( + (noun) => noun.many === many, + ); + return entry?.one ?? "content item"; + } + + return plural ? "content items" : "content item"; +} + +export function getContentThumbnail(chunk: ContentSearchChunk) { + if (chunk.content.thumbnailUrl) { + return getPartnerContentThumbnailUrl(chunk.content.thumbnailUrl); + } + + if (chunk.platform.type === "youtube") { + return `https://i.ytimg.com/vi/${chunk.content.platformContentId}/hqdefault.jpg`; + } + + return null; +} + +export function getContentTitle(chunk: ContentSearchChunk) { + return ( + chunk.content.title?.trim() || + chunk.content.description?.trim().split(/\r?\n/)[0] || + "Untitled content" + ); +} + +export function getContentHref(chunk: ContentSearchChunk) { + if (chunk.platform.type === "instagram") { + return getInstagramContentHref(chunk); + } + + if (chunk.platform.type !== "youtube" || chunk.chunk.startMs === null) { + return chunk.content.url; + } + + try { + const url = new URL(chunk.content.url); + url.searchParams.set("t", `${Math.floor(chunk.chunk.startMs / 1000)}s`); + return url.toString(); + } catch { + return chunk.content.url; + } +} + +export function getInstagramContentHref(chunk: ContentSearchChunk) { + const shortcode = + extractInstagramShortcode(chunk.content.url) || + chunk.content.platformContentId; + + return `https://www.instagram.com/${chunk.content.type === "reel" ? "reel" : "p"}/${shortcode}/`; +} + +export function extractInstagramShortcode(url: string) { + try { + return ( + new URL(url).pathname.match( + /^\/(?:(?:[^/]+)\/)?(?:p|reel|tv)\/([^/?#]+)/, + )?.[1] ?? null + ); + } catch { + return ( + url.match( + /instagram\.com\/(?:(?:[^/]+)\/)?(?:p|reel|tv)\/([^/?#]+)/, + )?.[1] ?? null + ); + } +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-content-search-results.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-content-search-results.tsx new file mode 100644 index 00000000000..9bd2b9e4b25 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-content-search-results.tsx @@ -0,0 +1,564 @@ +"use client"; + +import type { PartnerContentTopicFitBand } from "@/lib/partner-content-search/constants"; +import type { PartnerContentSearchPartner } from "@/lib/swr/use-partner-content-search"; +import { Tooltip } from "@dub/ui"; +import { User } from "@dub/ui/icons"; +import { cn, nFormatter } from "@dub/utils"; +import type { PlatformType } from "@prisma/client"; +import { useEffect, useState } from "react"; +import { + BAND_LABELS, + type ContentSearchChunk, + formatDuration, + formatMatchPercent, + formatPublishedDate, + formatTimestamp, + getContentHref, + getContentThumbnail, + getContentTitle, + lastPostedLabel, +} from "./content-display-utils"; +import { NetworkPartnerCard } from "./network-partner-card"; +import { PLATFORM_ICONS, PlatformIcon } from "./platform-icon"; + +const PLATFORM_LABELS: Partial> = { + youtube: "YouTube", + tiktok: "TikTok", + instagram: "Instagram", +}; + +function platformLabel(platform: PlatformType) { + return PLATFORM_LABELS[platform] ?? "partner"; +} + +function contentLabel(platform?: PlatformType) { + return platform ? platformLabel(platform) : "indexed"; +} + +const TOP_CONTENT_PREVIEW_COUNT = 2; + +type ContentMatch = NonNullable< + PartnerContentSearchPartner["matchSummary"] +>["contentMatches"][number]; +type ContentPreviewItem = { + chunk: ContentSearchChunk; + match?: ContentMatch; +}; + +export function NetworkContentSearchResults({ + error, + hasQuery, + isFetching, + partners, + platform, + onToggleStarred, +}: { + error: unknown; + hasQuery: boolean; + // A search is pending or in flight. Drives the first-search skeletons (no + // results yet) and dims the prior results during a re-search. + isFetching: boolean; + partners?: PartnerContentSearchPartner[]; + platform?: PlatformType; + onToggleStarred?: (partnerId: string, starred: boolean) => void; +}) { + const label = contentLabel(platform); + + if (error) { + return ( +
+ Failed to search partner content +
+ ); + } + + // First search (no results yet): skeletons. On re-search we keep the previous + // results on screen, dimmed, instead — see the grid below. + if (isFetching && !partners) { + return ( +
+ {[...Array(8)].map((_, idx) => ( + } + /> + ))} +
+ ); + } + + if (!partners?.length) { + return ( +
+

+ {hasQuery + ? `No matching ${label} content found` + : `No indexed ${label} content found`} +

+

+ {hasQuery + ? "Try a broader search or select a different platform." + : "Try another platform or index more partner content."} +

+
+ ); + } + + return ( +
+ {partners.map((partner) => ( + onToggleStarred(partner.partnerId, starred) + : undefined + } + bottomContent={ + + } + /> + ))} +
+ ); +} + +function NetworkPartnerContentMatchSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+ {[...Array(TOP_CONTENT_PREVIEW_COUNT)].map((_, index) => ( +
+ ))} +
+
+
+
+
+ ); +} + +function NetworkPartnerContentMatch({ + hasQuery, + partner, + platform, +}: { + hasQuery: boolean; + partner: PartnerContentSearchPartner; + platform?: PlatformType; +}) { + const summary = partner.matchSummary; + const platforms = ( + summary?.topPlatforms?.length + ? summary.topPlatforms + : summary?.platforms ?? [platform].filter(Boolean) + ) as string[]; + + // List mode (no query): no topic fit, so show platforms + last-published recency. + if (!hasQuery) { + return ( +
+
+ + Recent content + + + {formatPublishedWindow(summary)} + +
+
+ + {formatPlatformNames(platforms)} +
+
+ ); + } + + const band = summary?.band ?? "none"; + const styles = BAND_STYLES[band]; + const matchLabel = formatMatchEvidenceLabel(summary); + const lastOnTopic = lastPostedLabel(summary?.lastOnTopicAt); + const previewItems = getContentPreviewItems(partner); + + return ( +
+
+ + Topic fit + + {previewItems.length > 0 && ( + + Top matches + + )} +
+ + {summary?.topicFit ?? 0} + + + {BAND_LABELS[band]} + +
+ + +
+
+ + {matchLabel} + {lastOnTopic && ( + <> + · + + last post {lastOnTopic} + + + )} +
+
+ ); +} + +function getContentPreviewItems( + partner: PartnerContentSearchPartner, +): ContentPreviewItem[] { + const matchByContentItemId = new Map( + partner.matchSummary?.contentMatches.map((match) => [ + match.partnerContentItemId, + match, + ]) ?? [], + ); + const seenContentItems = new Set(); + const items: ContentPreviewItem[] = []; + + for (const chunk of partner.chunks) { + if (seenContentItems.has(chunk.partnerContentItemId)) continue; + + seenContentItems.add(chunk.partnerContentItemId); + items.push({ + chunk, + match: matchByContentItemId.get(chunk.partnerContentItemId), + }); + + if (items.length === TOP_CONTENT_PREVIEW_COUNT) break; + } + + return items; +} + +function ContentPreviewTiles({ items }: { items: ContentPreviewItem[] }) { + if (items.length === 0) return null; + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +function ContentPreviewTile({ item }: { item: ContentPreviewItem }) { + const title = getContentTitle(item.chunk); + + return ( + } + delayDuration={100} + // Content is informational (no links/buttons to hover onto), so skip + // Radix's hoverable "grace area". Otherwise the open tile's grace polygon + // — which spans up to its large content panel — covers the adjacent tile + // and suppresses its tooltip when you move between them. + disableHoverableContent + > + event.stopPropagation()} + className="group block size-11 overflow-hidden rounded-lg border border-neutral-200 bg-neutral-100 shadow-sm transition duration-150 hover:-translate-y-0.5 hover:border-neutral-300 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/20" + aria-label={"Open " + title} + > + + + + ); +} + +function ContentPreviewImage({ + chunk, + className, + fallbackIconClassName = "size-5", + imageClassName, + showPlatformBadge = false, +}: { + chunk: ContentSearchChunk; + className?: string; + fallbackIconClassName?: string; + imageClassName?: string; + showPlatformBadge?: boolean; +}) { + const thumbnail = getContentThumbnail(chunk); + const [hasImageError, setHasImageError] = useState(false); + const thumbnailSrc = hasImageError ? null : thumbnail; + + useEffect(() => { + setHasImageError(false); + }, [thumbnail]); + + return ( +
+ {thumbnailSrc ? ( + setHasImageError(true)} + /> + ) : ( +
+ +
+ )} + {showPlatformBadge && ( + + + + )} +
+ ); +} + +function ContentPreviewTooltip({ item }: { item: ContentPreviewItem }) { + const { chunk, match } = item; + const title = getContentTitle(chunk); + const metadata = [ + formatDuration(chunk.content.durationMs), + formatPublishedDate(chunk.content.publishedAt), + ] + .filter(Boolean) + .join(" · "); + const engagementMetrics = getContentEngagementMetrics(chunk, match); + const matchLocation = formatChunkMatchLocation(chunk); + const matchScore = formatMatchPercent(chunk.rerankScore ?? chunk.score); + + return ( +
+
+ +
+
+ {title} +
+ {metadata && ( +
+ {metadata} +
+ )} +
+
+ +
+ + {matchScore} relevance + + {matchLocation && ( + + {matchLocation} + + )} +
+ + {engagementMetrics.length > 0 && ( +
+ {engagementMetrics.map((metric) => ( +
+ + {metric.value} + + + {metric.label} + +
+ ))} +
+ )} +
+ ); +} + +function getContentEngagementMetrics( + chunk: ContentSearchChunk, + match: ContentMatch | undefined, +) { + return [ + { label: "Views", value: chunk.content.viewCount ?? match?.viewCount }, // views may still fall back to the slim content match + { label: "Likes", value: chunk.content.likeCount }, + { label: "Comments", value: chunk.content.commentCount }, + { label: "Shares", value: chunk.content.shareCount }, + { label: "Saves", value: chunk.content.saveCount }, + ] + .filter((metric): metric is { label: string; value: number } => { + return metric.value != null && metric.value > 0; + }) + .map((metric) => ({ + label: metric.label, + value: nFormatter(metric.value), + })); +} + +function formatChunkMatchLocation(chunk: ContentSearchChunk) { + if (chunk.chunk.source === "metadata") return "Matched creator text"; + if (chunk.chunk.startMs === null && chunk.chunk.endMs === null) { + return "Matched transcript"; + } + if (chunk.chunk.startMs !== null && chunk.chunk.endMs !== null) { + return `Matched transcript ${formatTimestamp( + chunk.chunk.startMs, + )} - ${formatTimestamp(chunk.chunk.endMs)}`; + } + return `Matched transcript ${formatTimestamp( + chunk.chunk.startMs ?? chunk.chunk.endMs ?? 0, + )}`; +} + +// Number + chip colors per band (number color follows the tier). +const BAND_STYLES: Record< + PartnerContentTopicFitBand, + { number: string; chip: string } +> = { + consistent: { + number: "text-green-600", + chip: "border-green-100 bg-green-50 text-green-700", + }, + frequent: { + number: "text-blue-600", + chip: "border-blue-100 bg-blue-50 text-blue-700", + }, + occasional: { + number: "text-amber-600", + chip: "border-amber-100 bg-amber-50 text-amber-700", + }, + "one-off": { + number: "text-neutral-500", + chip: "border-neutral-200 bg-neutral-100 text-neutral-600", + }, + none: { + number: "text-neutral-400", + chip: "border-neutral-200 bg-neutral-100 text-neutral-500", + }, +}; + +function PlatformIcons({ platforms }: { platforms: string[] }) { + const icons = platforms + .map((platform) => PLATFORM_ICONS[platform as PlatformType]) + .filter((icon): icon is typeof User => Boolean(icon)); + + if (!icons.length) { + return ; + } + + return ( + // No text-color override: the brand icons carry their own colors. + + {icons.map((Icon, index) => ( + + ))} + + ); +} + +function formatMatchEvidenceLabel( + summary: PartnerContentSearchPartner["matchSummary"] | undefined, +) { + const matchingPosts = summary?.matchedContentCount ?? 0; + const recentPosts = summary?.recentContentCount ?? 0; + + // Aggregate coverage; splitting by source reads noisy on the compact card. + if (recentPosts > 0) { + return `${matchingPosts} of ${recentPosts} matching`; + } + + return `${matchingPosts} matching ${matchingPosts === 1 ? "post" : "posts"}`; +} + +function formatPlatformNames(platforms: Array) { + const uniquePlatforms = Array.from(new Set(platforms.filter(Boolean))); + + if (uniquePlatforms.length === 0) return "indexed content"; + + return uniquePlatforms + .map((platform) => platformLabel(platform as PlatformType)) + .join(", "); +} + +function formatPublishedWindow( + summary: PartnerContentSearchPartner["matchSummary"], +) { + if (!summary?.oldestPublishedAt || !summary.newestPublishedAt) { + return "recent content"; + } + + const oldest = new Date(summary.oldestPublishedAt); + const newest = new Date(summary.newestPublishedAt); + const monthDiff = Math.max( + 1, + (newest.getFullYear() - oldest.getFullYear()) * 12 + + newest.getMonth() - + oldest.getMonth() + + 1, + ); + + return `past ${monthDiff} ${monthDiff === 1 ? "month" : "months"}`; +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-audience-section.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-audience-section.tsx new file mode 100644 index 00000000000..82abba0244f --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-audience-section.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { NetworkPartnerProps } from "@/lib/types"; +import { cn } from "@dub/utils"; +import { PartnerPlatformDisplayData } from "./network-partner-card-types"; +import { PlatformStatCard } from "./platform-stat-card"; + +export function NetworkPartnerAudienceSection({ + partner, + partnerPlatformsData, +}: { + partner?: NetworkPartnerProps; + partnerPlatformsData: PartnerPlatformDisplayData[] | null; +}) { + return ( +
+ + Audience + + +
+ {partnerPlatformsData + ? partnerPlatformsData.map( + ({ + label, + icon: PlatformIcon, + verified, + stat, + value, + href, + info, + verifiedAt, + }) => ( + + ), + ) + : [...Array(6)].map((_, idx) => ( +
+ ))} +
+
+ ); +} + +export function getPlatformSortOrder({ + verified, + value, +}: { + verified: boolean; + value?: string | null; +}) { + if (verified) return 0; + if (value) return 1; + return 2; +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card-actions.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card-actions.tsx new file mode 100644 index 00000000000..4db3d52a582 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card-actions.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { mutatePrefix } from "@/lib/swr/mutate"; +import usePartnerNetworkInvitesUsage from "@/lib/swr/use-partner-network-invites-usage"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { NetworkPartnerProps } from "@/lib/types"; +import { useTrialLimitActivateModal } from "@/ui/modals/trial-limit-activate-modal"; +import { PartnerStarButton } from "@/ui/partners/partner-star-button"; +import { Button } from "@dub/ui"; +import { isWorkspaceBillingTrialActive } from "@dub/utils"; +import { EmailContent } from "app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-email-preview"; +import { InviteNetworkPartnerSheet } from "app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-network-partner-sheet"; +import { useState } from "react"; + +export function NetworkPartnerCardActions({ + partner, + onToggleStarred, + showInvite, +}: { + partner: NetworkPartnerProps; + onToggleStarred?: (starred: boolean) => void; + showInvite: boolean; +}) { + const { trialEndsAt } = useWorkspace(); + const { openTrialLimitModal, TrialLimitActivateModal } = + useTrialLimitActivateModal(); + const trialActive = isWorkspaceBillingTrialActive(trialEndsAt); + + const [showInviteSheet, setShowInviteSheet] = useState(false); + const [inviteEmailContent, setInviteEmailContent] = + useState(null); + + const { remaining: remainingInvites } = usePartnerNetworkInvitesUsage(); + const atNetworkInviteLimit = remainingInvites === 0; + const disabled = atNetworkInviteLimit && !trialActive; + + const handleInvitePress = () => { + if (trialActive && atNetworkInviteLimit) { + openTrialLimitModal("networkInvites"); + return; + } + setShowInviteSheet(true); + }; + + return ( + <> + + { + mutatePrefix("/api/network/partners"); + }} + {...(trialActive && { + onInviteLimitError: () => openTrialLimitModal("networkInvites"), + })} + /> +
+ {showInvite && ( +
+ + ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card-types.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card-types.ts new file mode 100644 index 00000000000..9e99dac5141 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card-types.ts @@ -0,0 +1,9 @@ +import { PARTNER_PLATFORM_FIELDS } from "@/lib/partners/partner-platforms"; +import type { Icon } from "@dub/ui/icons"; + +export type PartnerPlatformDisplayData = ReturnType< + (typeof PARTNER_PLATFORM_FIELDS)[number]["data"] +> & { + label: string; + icon: Icon; +}; diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card.tsx index e672a3e29ca..d5a03eba925 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card.tsx @@ -1,41 +1,33 @@ "use client"; import { PARTNER_PLATFORM_FIELDS } from "@/lib/partners/partner-platforms"; -import { mutatePrefix } from "@/lib/swr/mutate"; -import usePartnerNetworkInvitesUsage from "@/lib/swr/use-partner-network-invites-usage"; import useWorkspace from "@/lib/swr/use-workspace"; import { NetworkPartnerProps } from "@/lib/types"; -import { useTrialLimitActivateModal } from "@/ui/modals/trial-limit-activate-modal"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; -import { PartnerStarButton } from "@/ui/partners/partner-star-button"; import { TrustedPartnerBadge } from "@/ui/partners/trusted-partner-badge"; -import { - BadgeCheck2Fill, - Button, - DynamicTooltipWrapper, - Tooltip, - UserPlus, - useResizeObserver, - useRouterStuff, -} from "@dub/ui"; -import type { Icon } from "@dub/ui/icons"; +import { UserPlus, useRouterStuff } from "@dub/ui"; import { ChartActivity2, EnvelopeArrowRight, Globe } from "@dub/ui/icons"; import { COUNTRIES, cn, isClickOnInteractiveChild, - isWorkspaceBillingTrialActive, timeAgo, } from "@dub/utils"; -import { EmailContent } from "app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-email-preview"; -import { InviteNetworkPartnerSheet } from "app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-network-partner-sheet"; import { usePathname } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { type ReactNode, useMemo } from "react"; +import { + NetworkPartnerAudienceSection, + getPlatformSortOrder, +} from "./network-partner-audience-section"; +import { NetworkPartnerCardActions } from "./network-partner-card-actions"; +import { ListRow } from "./overflow-pill-list"; export function NetworkPartnerCard({ + bottomContent, partner, onToggleStarred, }: { + bottomContent?: ReactNode; partner?: NetworkPartnerProps; onToggleStarred?: (starred: boolean) => void; }) { @@ -115,7 +107,9 @@ export function NetworkPartnerCard({ "_blank", ); } else { - queryParams({ set: { partnerId: partner.id } }); + queryParams({ + set: { partnerId: partner.id }, + }); } }} > @@ -191,367 +185,13 @@ export function NetworkPartnerCard({
-
- - Audience - - -
- {partnerPlatformsData - ? partnerPlatformsData.map( - ({ - label, - icon: PlatformIcon, - verified, - stat, - value, - href, - info, - verifiedAt, - }) => ( - - ), - ) - : [...Array(6)].map((_, idx) => ( -
- ))} -
-
-
-
- ); -} - -function getPlatformSortOrder({ - verified, - value, -}: { - verified: boolean; - value?: string | null; -}) { - if (verified) return 0; - if (value) return 1; - return 2; -} - -function NetworkPartnerCardActions({ - partner, - onToggleStarred, - showInvite, -}: { - partner: NetworkPartnerProps; - onToggleStarred?: (starred: boolean) => void; - showInvite: boolean; -}) { - const { trialEndsAt } = useWorkspace(); - const { openTrialLimitModal, TrialLimitActivateModal } = - useTrialLimitActivateModal(); - const trialActive = isWorkspaceBillingTrialActive(trialEndsAt); - - const [showInviteSheet, setShowInviteSheet] = useState(false); - const [inviteEmailContent, setInviteEmailContent] = - useState(null); - - const { remaining: remainingInvites } = usePartnerNetworkInvitesUsage(); - const atNetworkInviteLimit = remainingInvites === 0; - const disabled = atNetworkInviteLimit && !trialActive; - - const handleInvitePress = () => { - if (trialActive && atNetworkInviteLimit) { - openTrialLimitModal("networkInvites"); - return; - } - setShowInviteSheet(true); - }; - - return ( - <> - - { - mutatePrefix("/api/network/partners"); - }} - {...(trialActive && { - onInviteLimitError: () => openTrialLimitModal("networkInvites"), - })} - /> -
- {onToggleStarred && ( - - )} - {showInvite && ( -
- - ); -} - -function PlatformStatCard({ - label, - icon: PlatformIcon, - verified, - stat, - value, - info, - verifiedAt, - href, -}: { - label: string; - icon: Icon; - verified: boolean; - stat?: string | null; - value?: string | null; - info?: string[]; - verifiedAt?: Date | null; - href?: string | null; -}) { - const content = ( -
-
- - {verified && ( - - )} -
- - {verified && stat ? stat : "—"} - - {label} -
- ); - - const As = href ? "a" : "div"; - - return ( - - ), - } - : undefined - } - > - e.stopPropagation()} - > - {content} - - - ); -} - -function PlatformStatTooltipContent({ - icon: PlatformIcon, - value, - stat, - info, - verifiedAt, -}: { - icon: Icon; - value?: string | null; - stat?: string | null; - info?: string[]; - verifiedAt?: Date | null; -}) { - return ( -
-
-
- -
-
-
- {value} -
- {(info?.[0] ?? stat) && ( -
- {info?.[0] ?? stat} -
- )} -
-
-
- {verifiedAt ? ( - <> - - Verified {timeAgo(verifiedAt, { withAgo: true })} - - ) : ( - "Not verified" - )} -
-
- ); -} - -function ListRow({ - items, - className, -}: { - items?: { icon?: Icon; label: string }[]; - className?: string; -}) { - const containerRef = useRef(null); - - const [isReady, setIsReady] = useState(false); - - const [shownItems, setShownItems] = useState< - { icon?: Icon; label: string }[] | undefined - >(items); - - useEffect(() => { - if (isReady) return; - - setIsReady(false); - setShownItems(items); - }, [items, isReady]); - - useEffect(() => { - if (!containerRef.current) return; - - if ( - shownItems?.length && - containerRef.current.scrollWidth > containerRef.current.clientWidth - ) { - setIsReady(false); - setShownItems(shownItems?.slice(0, -1)); - } else { - setIsReady(true); - } - }, [shownItems]); - - const entry = useResizeObserver(containerRef); - useEffect(() => { - if (!containerRef.current) return; - - if (containerRef.current.scrollWidth > containerRef.current.clientWidth) - setIsReady(false); - }, [entry]); - - return ( -
-
- {items ? ( - items.length ? ( - <> - {shownItems?.map(({ icon, label }) => ( - - ))} - {(shownItems?.length ?? 0) < items.length && ( - - {items - .filter( - ({ label }) => - !shownItems?.some( - ({ label: shownLabel }) => shownLabel === label, - ), - ) - .map(({ icon, label }) => ( - - ))} -
- } - > -
- +{items.length - (shownItems?.length ?? 0)} -
- - )} - - ) : ( -
- - No categories - -
- ) - ) : ( - [...Array(2)].map((_, idx) => ( -
- )) - )} -
-
- ); -} - -function ListPill({ icon: Icon, label }: { icon?: Icon; label: string }) { - return ( -
- {Icon && } - - {label} -
); } diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-detail-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-detail-sheet.tsx new file mode 100644 index 00000000000..a8c3d3f7f0d --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-detail-sheet.tsx @@ -0,0 +1,89 @@ +"use client"; + +import type { PartnerContentSearchPartner } from "@/lib/swr/use-partner-content-search"; +import { X } from "@/ui/shared/icons"; +import { + Button, + ChevronLeft, + ChevronRight, + Sheet, + useRouterStuff, +} from "@dub/ui"; +import { NetworkPartnerDetailContent } from "./[partnerId]/page-client"; + +type NetworkPartnerDetailSheetProps = { + isOpen: boolean; + onNext?: () => void; + onPrevious?: () => void; + partnerId: string; + partnerStatus: string; + searchPartner?: PartnerContentSearchPartner; + setIsOpen: (open: boolean) => void; +}; + +export function NetworkPartnerDetailSheet({ + isOpen, + onNext, + onPrevious, + partnerId, + partnerStatus, + searchPartner, + setIsOpen, +}: NetworkPartnerDetailSheetProps) { + const { queryParams } = useRouterStuff(); + + return ( + queryParams({ del: "partnerId" })} + contentProps={{ + className: "md:w-[max(min(calc(100vw-334px),1170px),540px)]", + }} + > +
+
+ + Partner network + +
+
+
+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-platform-filter.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-platform-filter.tsx new file mode 100644 index 00000000000..7a6b75e03e1 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-platform-filter.tsx @@ -0,0 +1,172 @@ +import { Popover } from "@dub/ui"; +import { + Globe, + GlobePointer, + Instagram, + LinkedIn, + TikTok, + Twitter, + User, + YouTube, +} from "@dub/ui/icons"; +import { cn } from "@dub/utils"; +import { PlatformType } from "@prisma/client"; +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; +import { + isAllPlatformsSelected, + NETWORK_FILTER_PLATFORMS, +} from "./platform-filter-utils"; + +const PLATFORM_META: Record< + (typeof NETWORK_FILTER_PLATFORMS)[number], + { label: string; icon: typeof User } +> = { + website: { label: "Website", icon: Globe }, + youtube: { label: "YouTube", icon: YouTube }, + twitter: { label: "X", icon: Twitter }, + linkedin: { label: "LinkedIn", icon: LinkedIn }, + instagram: { label: "Instagram", icon: Instagram }, + tiktok: { label: "TikTok", icon: TikTok }, +}; + +export function NetworkPlatformFilter({ + selectedPlatforms, + onChange, + className, +}: { + selectedPlatforms: PlatformType[]; + onChange: (platforms: PlatformType[]) => void; + className?: string; +}) { + const [openPopover, setOpenPopover] = useState(false); + + const selectedSet = new Set(selectedPlatforms); + const isFiltered = !isAllPlatformsSelected(selectedPlatforms); + + const toggle = (platform: PlatformType) => { + if (selectedSet.has(platform)) { + // Never let the user clear the last platform — an empty selection would + // show nothing. Deselecting the last one is a no-op. + if (selectedSet.size === 1) return; + // Remove from the CURRENT selection, not the full list — otherwise a second + // deselect would silently re-add the first. + onChange( + NETWORK_FILTER_PLATFORMS.filter( + (p) => selectedSet.has(p) && p !== platform, + ), + ); + } else { + onChange( + NETWORK_FILTER_PLATFORMS.filter( + (p) => selectedSet.has(p) || p === platform, + ), + ); + } + }; + + return ( + + {NETWORK_FILTER_PLATFORMS.map((platform) => { + const { label, icon: Icon } = PLATFORM_META[platform]; + const isSelected = selectedSet.has(platform); + + return ( + + ); + })} +
+ +
+ } + openPopover={openPopover} + setOpenPopover={setOpenPopover} + align="end" + > + +
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/overflow-pill-list.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/overflow-pill-list.tsx new file mode 100644 index 00000000000..400d13e0e4b --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/overflow-pill-list.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { Tooltip, useResizeObserver } from "@dub/ui"; +import type { Icon } from "@dub/ui/icons"; +import { cn } from "@dub/utils"; +import { useEffect, useRef, useState } from "react"; + +export function ListRow({ + items, + className, +}: { + items?: { icon?: Icon; label: string }[]; + className?: string; +}) { + const containerRef = useRef(null); + + const [isReady, setIsReady] = useState(false); + + const [shownItems, setShownItems] = useState< + { icon?: Icon; label: string }[] | undefined + >(items); + + useEffect(() => { + if (isReady) return; + + setIsReady(false); + setShownItems(items); + }, [items, isReady]); + + useEffect(() => { + if (!containerRef.current) return; + + if ( + shownItems?.length && + containerRef.current.scrollWidth > containerRef.current.clientWidth + ) { + setIsReady(false); + setShownItems(shownItems?.slice(0, -1)); + } else { + setIsReady(true); + } + }, [shownItems]); + + const entry = useResizeObserver(containerRef); + useEffect(() => { + if (!containerRef.current) return; + + if (containerRef.current.scrollWidth > containerRef.current.clientWidth) + setIsReady(false); + }, [entry]); + + return ( +
+
+ {items ? ( + items.length ? ( + <> + {shownItems?.map(({ icon, label }) => ( + + ))} + {(shownItems?.length ?? 0) < items.length && ( + + {items + .filter( + ({ label }) => + !shownItems?.some( + ({ label: shownLabel }) => shownLabel === label, + ), + ) + .map(({ icon, label }) => ( + + ))} +
+ } + > +
+ +{items.length - (shownItems?.length ?? 0)} +
+ + )} + + ) : ( +
+ + No categories + +
+ ) + ) : ( + [...Array(2)].map((_, idx) => ( +
+ )) + )} +
+
+ ); +} + +function ListPill({ icon: Icon, label }: { icon?: Icon; label: string }) { + return ( +
+ {Icon && } + + {label} + +
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/page-client.tsx index 4db6eecc29f..a65ddf60abe 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/page-client.tsx @@ -1,41 +1,38 @@ "use client"; -import { updateDiscoveredPartnerAction } from "@/lib/actions/partners/update-discovered-partner"; import useNetworkPartnersCount from "@/lib/swr/use-network-partners-count"; +import usePartnerContentSearch from "@/lib/swr/use-partner-content-search"; import useWorkspace from "@/lib/swr/use-workspace"; import { NetworkPartnerProps } from "@/lib/types"; import { PARTNER_NETWORK_MAX_PAGE_SIZE } from "@/lib/zod/schemas/partner-network"; -import { NetworkPartnerSheet } from "@/ui/partners/partner-network/network-partner-sheet"; +import { SearchBoxPersisted } from "@/ui/shared/search-box"; import { AnimatedSizeContainer, Button, Filter, PaginationControls, - ToggleGroup, usePagination, useRouterStuff, } from "@dub/ui"; -import { - Globe, - Instagram, - LinkedIn, - Star, - StarFill, - TikTok, - Twitter, - User, - YouTube, -} from "@dub/ui/icons"; +import { Star, StarFill } from "@dub/ui/icons"; import { cn, fetcher } from "@dub/utils"; -import { PlatformType } from "@prisma/client"; -import { useAction } from "next-safe-action/hooks"; -import { useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; import useSWR from "swr"; +import { useDebounce } from "use-debounce"; +import { NetworkContentSearchResults } from "./network-content-search-results"; import { NetworkEmptyState } from "./network-empty-state"; import { NetworkPartnerCard } from "./network-partner-card"; -import { PartnerNetworkSort } from "./partner-network-sort"; +import { NetworkPartnerDetailSheet } from "./network-partner-detail-sheet"; +import { NetworkPlatformFilter } from "./network-platform-filter"; +import { useNetworkDetailSheet } from "./use-network-detail-sheet"; +import { + FILTER_FETCH_DEBOUNCE_MS, + useNetworkPartnerFiltersState, +} from "./use-network-partner-filters-state"; import { usePartnerNetworkFilters } from "./use-partner-network-filters"; +import { + useToggleStarredContentSearch, + useToggleStarredRankedList, +} from "./use-toggle-partner-starred"; const tabs = [ { @@ -52,19 +49,6 @@ const tabs = [ }, ] as const; -const PLATFORM_TOGGLE_OPTIONS: { - value: PlatformType | "all"; - icon: typeof User; -}[] = [ - { value: "all", icon: User }, - { value: "website", icon: Globe }, - { value: "youtube", icon: YouTube }, - { value: "twitter", icon: Twitter }, - { value: "linkedin", icon: LinkedIn }, - { value: "instagram", icon: Instagram }, - { value: "tiktok", icon: TikTok }, -]; - type ProgramPartnerNetworkPageClientProps = { variant?: "default" | "ignored"; }; @@ -75,39 +59,74 @@ export function ProgramPartnerNetworkPageClient({ const { id: workspaceId } = useWorkspace(); const { searchParams, getQueryString, queryParams } = useRouterStuff(); + const { + selectedPlatforms, + platformFilter, + contentSearchPlatforms, + reachFilter, + search, + country, + starred, + updateSearchParams, + onPlatformsChange, + } = useNetworkPartnerFiltersState(); + const status = variant === "ignored" ? "ignored" : tabs.find(({ id }) => id === searchParams.get("tab"))?.id || "discover"; + const isContentSearchMode = + status === "discover" && + search.length > 0 && + contentSearchPlatforms.length > 0; const { data: partnerCounts, error: countError } = useNetworkPartnersCount(); + const { + data: contentSearchResults, + error: contentSearchError, + isPending: isContentSearchPending, + mutate: mutateContentSearch, + } = usePartnerContentSearch({ + enabled: isContentSearchMode, + query: search, + platforms: platformFilter, + reach: reachFilter, + country, + starred, + debounceMs: FILTER_FETCH_DEBOUNCE_MS, + }); + + const partnersKey = + !isContentSearchMode && workspaceId + ? `/api/network/partners${getQueryString( + { + workspaceId, + status, + }, + { + exclude: + variant === "ignored" + ? ["tab", "partnerId", "starred", "search"] + : ["tab", "partnerId", "search"], + }, + )}` + : null; + // Debounce the fetch key so rapid filter toggles coalesce into one request. + const [debouncedPartnersKey] = useDebounce( + partnersKey, + FILTER_FETCH_DEBOUNCE_MS, + ); + const { data: partners, error, mutate: mutatePartners, isValidating, - } = useSWR( - workspaceId && - `/api/network/partners${getQueryString( - { - workspaceId, - status, - }, - { - exclude: - variant === "ignored" - ? ["tab", "partnerId", "starred"] - : ["tab", "partnerId"], - }, - )}`, - fetcher, - { revalidateOnFocus: false, keepPreviousData: true }, - ); - - const { executeAsync: updateDiscoveredPartner } = useAction( - updateDiscoveredPartnerAction, - ); + } = useSWR(debouncedPartnersKey, fetcher, { + revalidateOnFocus: false, + keepPreviousData: true, + }); const { pagination, setPagination } = usePagination( PARTNER_NETWORK_MAX_PAGE_SIZE, @@ -123,48 +142,44 @@ export function ProgramPartnerNetworkPageClient({ } = usePartnerNetworkFilters({ status }); const isStarred = searchParams.get("starred") === "true"; - const selectedPlatform = - (searchParams.get("platform") as PlatformType | null) ?? "all"; + const selectedPartnerId = searchParams.get("partnerId"); - const [detailsSheetState, setDetailsSheetState] = useState< - | { open: false; partnerId: string | null } - | { open: true; partnerId: string } - >({ open: false, partnerId: null }); - - useEffect(() => { - const partnerId = searchParams.get("partnerId"); - if (partnerId) setDetailsSheetState({ open: true, partnerId }); - }, [searchParams]); - - const { currentPartner } = useCurrentPartner({ + const { + detailsSheetState, + setDetailsSheetState, + previousPartnerId, + nextPartnerId, + } = useNetworkDetailSheet({ + selectedPartnerId, + isContentSearchMode, + contentSearchPartners: contentSearchResults?.partners, partners, - partnerId: detailsSheetState.partnerId, - partnerListStatus: status, }); - const [previousPartnerId, nextPartnerId] = useMemo(() => { - if (!partners || !detailsSheetState.partnerId) return [null, null]; - - const currentIndex = partners.findIndex( - ({ id }) => id === detailsSheetState.partnerId, - ); - if (currentIndex === -1) return [null, null]; - - return [ - currentIndex > 0 ? partners[currentIndex - 1].id : null, - currentIndex < partners.length - 1 ? partners[currentIndex + 1].id : null, - ]; - }, [partners, detailsSheetState.partnerId]); + const toggleStarredContentSearch = + useToggleStarredContentSearch(mutateContentSearch); + const toggleStarredRankedList = useToggleStarredRankedList( + mutatePartners, + partners, + ); return (
- {detailsSheetState.partnerId && currentPartner && ( - - setDetailsSheetState((s) => ({ ...s, open }) as any) + setDetailsSheetState((state) => + open && state.partnerId + ? { open: true, partnerId: state.partnerId } + : { open: false, partnerId: state.partnerId }, + ) } - partner={currentPartner} + partnerId={detailsSheetState.partnerId} + partnerStatus={status} + searchPartner={contentSearchResults?.partners.find( + ({ partnerId }) => partnerId === detailsSheetState.partnerId, + )} onPrevious={ previousPartnerId ? () => @@ -202,7 +217,7 @@ export function ProgramPartnerNetworkPageClient({ onClick={() => { queryParams({ set: { tab: tab.id }, - del: ["page", "starred", "sortBy"], + del: ["page", "starred"], }); }} > @@ -224,8 +239,8 @@ export function ProgramPartnerNetworkPageClient({ {status === "discover" && (
-
-
+
+
-
- ({ - value, - label: , - }), - )} - selected={selectedPlatform} - selectAction={(option) => { - if (option === "all") { - queryParams({ - del: ["platform", "page"], - }); - } else { - queryParams({ - set: { platform: option }, - del: "page", - }); - } - }} - /> -
+
+
+
-
{activeFilters.length > 0 && ( @@ -299,7 +297,22 @@ export function ProgramPartnerNetworkPageClient({
)} - {error || countError ? ( + {isContentSearchMode ? ( + 0} + isFetching={isContentSearchPending} + partners={contentSearchResults?.partners} + platform={ + contentSearchPlatforms.length === 1 + ? contentSearchPlatforms[0] + : undefined + } + onToggleStarred={ + variant === "ignored" ? undefined : toggleStarredContentSearch + } + /> + ) : error || countError ? (
Failed to load partners
@@ -319,47 +332,8 @@ export function ProgramPartnerNetworkPageClient({ onToggleStarred={ variant === "ignored" ? undefined - : (starred) => { - mutatePartners( - // @ts-ignore SWR doesn't seem to have proper typing for partial data results w/ `populateCache` - async () => { - const result = await updateDiscoveredPartner({ - workspaceId: workspaceId!, - partnerId: partner.id, - starred, - }); - if (!result?.data) { - toast.error("Failed to star partner"); - throw new Error("Failed to star partner"); - } - - return result.data; - }, - { - optimisticData: (data) => - (data || partners).map((p) => - p.id === partner.id - ? { - ...p, - starredAt: starred - ? new Date() - : null, - } - : p, - ), - populateCache: ( - result: { starredAt: Date | null }, - data, - ) => - (data || partners).map((p) => - p.id === partner.id - ? { ...p, starredAt: result.starredAt } - : p, - ), - revalidate: false, - }, - ); - } + : (starred) => + toggleStarredRankedList(partner.id, starred) } /> )) @@ -391,37 +365,3 @@ export function ProgramPartnerNetworkPageClient({
); } - -/** Gets the current partner from the loaded partners array if available, or a separate fetch if not */ -function useCurrentPartner({ - partners, - partnerId, - partnerListStatus, -}: { - partners?: NetworkPartnerProps[]; - partnerId: string | null; - partnerListStatus: string; -}) { - const { id: workspaceId } = useWorkspace(); - - let currentPartner = partnerId - ? partners?.find(({ id }) => id === partnerId) - : null; - - const fetchPartnerId = - partners && partnerId && !currentPartner ? partnerId : null; - - const { data: fetchedPartners, isLoading } = useSWR( - fetchPartnerId && - `/api/network/partners?workspaceId=${workspaceId}&partnerIds=${fetchPartnerId}&status=${partnerListStatus}`, - fetcher, - { - keepPreviousData: true, - }, - ); - - if (!currentPartner && fetchedPartners?.[0]?.id === partnerId) - currentPartner = fetchedPartners[0]; - - return { currentPartner, isLoading }; -} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/partner-network-sort.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/partner-network-sort.tsx deleted file mode 100644 index a063ba525d9..00000000000 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/partner-network-sort.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { IconMenu, Popover, Tick, useRouterStuff } from "@dub/ui"; -import { ChartActivity2, Megaphone } from "@dub/ui/icons"; -import { cn } from "@dub/utils"; -import { PlatformType } from "@prisma/client"; -import { ChevronDown } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; - -function getPlatformSortLabel(platform: PlatformType) { - switch (platform) { - case "website": - return "Domain Ranking"; - case "youtube": - return "Subscribers"; - default: - return "Followers"; - } -} - -export function PartnerNetworkSort({ - selectedPlatform, - className, -}: { - selectedPlatform: PlatformType | "all"; - className?: string; -}) { - const { queryParams, searchParams } = useRouterStuff(); - const [openPopover, setOpenPopover] = useState(false); - - const sortBy = searchParams.get("sortBy") || "relevance"; - - useEffect(() => { - if (selectedPlatform === "all" && sortBy === "subscribers") { - queryParams({ del: ["sortBy"] }); - } - }, [selectedPlatform, sortBy, queryParams]); - - const sortOptions = useMemo(() => { - const options = [ - { - value: "relevance", - label: "Relevance", - icon: ChartActivity2, - }, - ]; - - if (selectedPlatform !== "all") { - options.push({ - value: "subscribers", - label: getPlatformSortLabel(selectedPlatform), - icon: Megaphone, - }); - } - - return options; - }, [selectedPlatform]); - - const selectedSort = - sortOptions.find((option) => option.value === sortBy) ?? sortOptions[0]; - - return ( - - {sortOptions.map(({ label, value, icon: Icon }) => ( - - ))} -
- } - openPopover={openPopover} - setOpenPopover={setOpenPopover} - > - - - ); -} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/platform-filter-utils.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/platform-filter-utils.ts new file mode 100644 index 00000000000..51907394cd9 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/platform-filter-utils.ts @@ -0,0 +1,50 @@ +import { isPartnerContentSearchPlatform } from "@/lib/partner-content-search/types"; +import type { PlatformType } from "@prisma/client"; + +// Filterable platforms, in display order. All selected = no filter +export const NETWORK_FILTER_PLATFORMS = [ + "youtube", + "instagram", + "tiktok", + "website", + "twitter", + "linkedin", +] as const satisfies readonly PlatformType[]; + +const PLATFORM_SET = new Set(NETWORK_FILTER_PLATFORMS); + +// Parse the comma-separated `platform` query param. Absent/empty means "all +// platforms" (no filter), which we represent as the full list so callers always +// get a concrete selection to render. +export function parseSelectedPlatforms( + param: string | null | undefined, +): PlatformType[] { + if (!param) return [...NETWORK_FILTER_PLATFORMS]; + + const selected = param + .split(",") + .map((value) => value.trim()) + .filter((value): value is PlatformType => PLATFORM_SET.has(value)); + + return selected.length ? selected : [...NETWORK_FILTER_PLATFORMS]; +} + +export function isAllPlatformsSelected(selected: PlatformType[]): boolean { + return selected.length >= NETWORK_FILTER_PLATFORMS.length; +} + +// What to actually send to the API: `undefined` when everything is selected +// (keeps the default ranking path and clean URLs), otherwise the selection. +export function platformFilterParam( + selected: PlatformType[], +): PlatformType[] | undefined { + return isAllPlatformsSelected(selected) ? undefined : selected; +} + +// Semantic content search only covers a subset of platforms; this intersection +// decides whether content search runs at all for the current selection. +export function getContentSearchPlatforms( + selected: PlatformType[], +): PlatformType[] { + return selected.filter(isPartnerContentSearchPlatform); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/platform-icon.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/platform-icon.tsx new file mode 100644 index 00000000000..781bfb25480 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/platform-icon.tsx @@ -0,0 +1,26 @@ +import { Globe, Instagram, LinkedIn, TikTok, Twitter, User, YouTube } from "@dub/ui/icons"; +import { cn } from "@dub/utils"; +import type { PlatformType } from "@prisma/client"; + +// Shared platform → brand icon map for the network search surfaces (detail page + +// results list). Unknown platforms fall back to a generic person icon. +export const PLATFORM_ICONS: Partial> = { + youtube: YouTube, + instagram: Instagram, + tiktok: TikTok, + twitter: Twitter, + linkedin: LinkedIn, + website: Globe, +}; + +export function PlatformIcon({ + platform, + className, +}: { + platform: string; + className?: string; +}) { + const Icon = PLATFORM_ICONS[platform as PlatformType] ?? User; + + return ; +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/platform-stat-card.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/platform-stat-card.tsx new file mode 100644 index 00000000000..71d1ac50484 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/platform-stat-card.tsx @@ -0,0 +1,129 @@ +import { BadgeCheck2Fill, DynamicTooltipWrapper } from "@dub/ui"; +import type { Icon } from "@dub/ui/icons"; +import { cn, timeAgo } from "@dub/utils"; + +export function PlatformStatCard({ + label, + icon: PlatformIcon, + verified, + stat, + value, + info, + verifiedAt, + href, +}: { + label: string; + icon: Icon; + verified: boolean; + stat?: string | null; + value?: string | null; + info?: string[]; + verifiedAt?: Date | null; + href?: string | null; +}) { + const content = ( +
+
+ + {verified && ( + + )} +
+ + {verified && stat ? stat : "—"} + + {label} +
+ ); + + const As = href ? "a" : "div"; + + return ( + + ), + } + : undefined + } + > + e.stopPropagation()} + > + {content} + + + ); +} + +function PlatformStatTooltipContent({ + icon: PlatformIcon, + value, + stat, + info, + verifiedAt, +}: { + icon: Icon; + value?: string | null; + stat?: string | null; + info?: string[]; + verifiedAt?: Date | null; +}) { + return ( +
+
+
+ +
+
+
+ {value} +
+ {(info?.[0] ?? stat) && ( +
+ {info?.[0] ?? stat} +
+ )} +
+
+
+ {verifiedAt ? ( + <> + + Verified {timeAgo(verifiedAt, { withAgo: true })} + + ) : ( + "Not verified" + )} +
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-network-detail-sheet.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-network-detail-sheet.ts new file mode 100644 index 00000000000..bb729d7204a --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-network-detail-sheet.ts @@ -0,0 +1,67 @@ +"use client"; + +import type { PartnerContentSearchPartner } from "@/lib/swr/use-partner-content-search"; +import { NetworkPartnerProps } from "@/lib/types"; +import { useEffect, useMemo, useState } from "react"; + +export function useNetworkDetailSheet({ + selectedPartnerId, + isContentSearchMode, + contentSearchPartners, + partners, +}: { + selectedPartnerId: string | null; + isContentSearchMode: boolean; + contentSearchPartners: PartnerContentSearchPartner[] | undefined; + partners: NetworkPartnerProps[] | undefined; +}) { + const [detailsSheetState, setDetailsSheetState] = useState< + | { open: false; partnerId: string | null } + | { open: true; partnerId: string } + >( + selectedPartnerId + ? { open: true, partnerId: selectedPartnerId } + : { open: false, partnerId: null }, + ); + + useEffect(() => { + if (selectedPartnerId) { + setDetailsSheetState({ open: true, partnerId: selectedPartnerId }); + } else { + setDetailsSheetState({ open: false, partnerId: null }); + } + }, [selectedPartnerId]); + + const sheetPartnerIds = useMemo( + () => + isContentSearchMode + ? contentSearchPartners?.map(({ partnerId }) => partnerId) + : partners?.map(({ id }) => id), + [contentSearchPartners, isContentSearchMode, partners], + ); + + const [previousPartnerId, nextPartnerId] = useMemo(() => { + if (!sheetPartnerIds?.length || !detailsSheetState.partnerId) { + return [null, null]; + } + + const currentIndex = sheetPartnerIds.findIndex( + (id) => id === detailsSheetState.partnerId, + ); + if (currentIndex === -1) return [null, null]; + + return [ + currentIndex > 0 ? sheetPartnerIds[currentIndex - 1] : null, + currentIndex < sheetPartnerIds.length - 1 + ? sheetPartnerIds[currentIndex + 1] + : null, + ]; + }, [detailsSheetState.partnerId, sheetPartnerIds]); + + return { + detailsSheetState, + setDetailsSheetState, + previousPartnerId, + nextPartnerId, + }; +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-network-partner-filters-state.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-network-partner-filters-state.ts new file mode 100644 index 00000000000..ce1a679c01f --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-network-partner-filters-state.ts @@ -0,0 +1,69 @@ +"use client"; + +import { parseReachTiers } from "@/lib/api/network/reach-tiers"; +import { useRouterStuff } from "@dub/ui"; +import { PlatformType } from "@prisma/client"; +import { useMemo } from "react"; +import { + getContentSearchPlatforms, + isAllPlatformsSelected, + parseSelectedPlatforms, + platformFilterParam, +} from "./platform-filter-utils"; + +// Filter changes update the URL instantly (snappy controls) but the data fetch is +// debounced avoiding ranking SQL + Voyage embed+rerank call. +export const FILTER_FETCH_DEBOUNCE_MS = 250; + +// keeps the prior results on screen during the brief wait. +export function useNetworkPartnerFiltersState() { + const { searchParams, queryParams } = useRouterStuff(); + + const selectedPlatforms = useMemo( + () => parseSelectedPlatforms(searchParams.get("platform")), + [searchParams], + ); + const platformFilter = platformFilterParam(selectedPlatforms); + // The content-searchable subset of the selection. Semantic search runs only + // when at least one searchable platform is selected; otherwise we fall back to + // the ranked partner list (filtered to the chosen platforms). + const contentSearchPlatforms = getContentSearchPlatforms(selectedPlatforms); + const selectedReach = useMemo( + () => parseReachTiers(searchParams.get("reach")), + [searchParams], + ); + const reachFilter = selectedReach.length ? selectedReach : undefined; + const search = searchParams.get("search")?.trim() ?? ""; + const country = searchParams.get("country") ?? undefined; + const starred = searchParams.get("starred") === "true"; + + // Filter updates use history.pushState instead of router.push. Params only + // drive client-side SWR — page.tsx doesn't read them — so router.push would + // add an RSC round-trip for no gain. SWR refetches when the URL key changes. + const updateSearchParams = (opts: { + set?: Record; + del?: string | string[]; + }) => { + const newPath = queryParams({ ...opts, getNewPath: true }) as string; + window.history.pushState(null, "", newPath); + }; + + const onPlatformsChange = (platforms: PlatformType[]) => + updateSearchParams( + isAllPlatformsSelected(platforms) + ? { del: ["platform", "page"] } + : { set: { platform: platforms.join(",") }, del: "page" }, + ); + + return { + selectedPlatforms, + platformFilter, + contentSearchPlatforms, + reachFilter, + search, + country, + starred, + updateSearchParams, + onPlatformsChange, + }; +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-partner-network-filters.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-partner-network-filters.tsx index e104d620dec..3dd60bc77db 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-partner-network-filters.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-partner-network-filters.tsx @@ -1,123 +1,155 @@ -import useNetworkPartnersCount from "@/lib/swr/use-network-partners-count"; -import { CountryFlag } from "@/ui/shared/country-flag"; -import { useRouterStuff } from "@dub/ui"; -import { FlagWavy } from "@dub/ui/icons"; -import { COUNTRIES, nFormatter } from "@dub/utils"; -import { useCallback, useMemo } from "react"; - -export function usePartnerNetworkFilters({ - status, -}: { - status: "discover" | "invited" | "recruited" | "ignored"; -}) { - const { searchParamsObj, queryParams } = useRouterStuff(); - - const { data: countriesCount } = useNetworkPartnersCount< - | { - country: string; - _count: number; - }[] - | undefined - >({ - query: { - status, - groupBy: "country", - }, - excludeParams: ["country"], - }); - - const filters = useMemo( - () => [ - { - key: "country", - icon: FlagWavy, - label: "Partner country", - getOptionIcon: (value) => ( - - ), - getOptionLabel: (value) => COUNTRIES[value], - options: - countriesCount - ?.filter(({ country }) => COUNTRIES[country]) - .map(({ country, _count }) => ({ - value: country, - label: COUNTRIES[country], - right: nFormatter(_count, { full: true }), - })) ?? [], - }, - ], - [countriesCount], - ); - - const multiFilters = useMemo(() => ({}), []) as Record; - - const activeFilters = useMemo(() => { - const { country } = searchParamsObj; - - return [ - ...Object.entries(multiFilters) - .map(([key, value]) => ({ key, value })) - .filter(({ value }) => value.length > 0), - - ...(country ? [{ key: "country", value: country }] : []), - ]; - }, [searchParamsObj, multiFilters]); - - const onSelect = useCallback( - (key: string, value: any) => - queryParams({ - set: Object.keys(multiFilters).includes(key) - ? { - [key]: multiFilters[key].concat(value).join(","), - } - : { - [key]: value, - }, - del: "page", - }), - [queryParams, multiFilters], - ); - - const onRemove = useCallback( - (key: string, value: any) => { - if ( - Object.keys(multiFilters).includes(key) && - !(multiFilters[key].length === 1 && multiFilters[key][0] === value) - ) { - queryParams({ - set: { - [key]: multiFilters[key].filter((id) => id !== value).join(","), - }, - del: "page", - }); - } else { - queryParams({ - del: [key, "page"], - }); - } - }, - [queryParams, multiFilters], - ); - - const onRemoveAll = useCallback( - () => - queryParams({ - del: ["country", "starred", "platform", "sortBy"], - }), - [queryParams], - ); - - const isFiltered = - activeFilters.length > 0 || - !!searchParamsObj.platform || - !!searchParamsObj.search; - - return { - filters, - activeFilters, - onSelect, - onRemove, - onRemoveAll, - isFiltered, - }; -} +import { REACH_TIER_KEYS, REACH_TIERS } from "@/lib/api/network/reach-tiers"; +import useNetworkPartnersCount from "@/lib/swr/use-network-partners-count"; +import { CountryFlag } from "@/ui/shared/country-flag"; +import { useRouterStuff } from "@dub/ui"; +import { FlagWavy, User } from "@dub/ui/icons"; +import { COUNTRIES, nFormatter } from "@dub/utils"; +import { useCallback, useMemo } from "react"; + +export function usePartnerNetworkFilters({ + status, +}: { + status: "discover" | "invited" | "recruited" | "ignored"; +}) { + const { searchParamsObj, queryParams } = useRouterStuff(); + + // Apply filter changes via the History API (instant) rather than router.push, + // which would trigger a full RSC navigation per click. See page-client for the + // rationale — all data here is client-side SWR keyed off useSearchParams. + const updateSearchParams = useCallback( + (opts: { set?: Record; del?: string | string[] }) => { + const newPath = queryParams({ ...opts, getNewPath: true }) as string; + window.history.pushState(null, "", newPath); + }, + [queryParams], + ); + + const { data: countriesCount } = useNetworkPartnersCount< + | { + country: string; + _count: number; + }[] + | undefined + >({ + query: { + status, + groupBy: "country", + }, + excludeParams: ["country"], + }); + + const filters = useMemo( + () => [ + { + key: "country", + icon: FlagWavy, + label: "Partner country", + getOptionIcon: (value) => ( + + ), + getOptionLabel: (value) => COUNTRIES[value], + options: + countriesCount + ?.filter(({ country }) => COUNTRIES[country]) + .map(({ country, _count }) => ({ + value: country, + label: COUNTRIES[country], + right: nFormatter(_count, { full: true }), + })) ?? [], + }, + { + key: "reach", + icon: User, + label: "Creator size", + multiple: true, + // Range leads (objective); the muted descriptor sits on the right. + options: REACH_TIER_KEYS.map((tier) => ({ + value: tier, + label: REACH_TIERS[tier].range, + right: REACH_TIERS[tier].descriptor, + })), + }, + ], + [countriesCount], + ); + + const multiFilters = useMemo( + () => ({ + reach: searchParamsObj.reach?.split(",").filter(Boolean) ?? [], + }), + [searchParamsObj.reach], + ) as Record; + + const activeFilters = useMemo(() => { + const { country } = searchParamsObj; + + return [ + // Multi-select filters use the plural `values` shape so the Filter component + // reflects each option's checked state and renders per-value labels/icons. + ...Object.entries(multiFilters) + .map(([key, values]) => ({ key, values })) + .filter(({ values }) => values.length > 0), + + ...(country ? [{ key: "country", value: country }] : []), + ]; + }, [searchParamsObj, multiFilters]); + + const onSelect = useCallback( + (key: string, value: any) => + updateSearchParams({ + set: Object.keys(multiFilters).includes(key) + ? { + [key]: multiFilters[key].concat(value).join(","), + } + : { + [key]: value, + }, + del: "page", + }), + [updateSearchParams, multiFilters], + ); + + const onRemove = useCallback( + (key: string, value: any) => { + if ( + Object.keys(multiFilters).includes(key) && + !(multiFilters[key].length === 1 && multiFilters[key][0] === value) + ) { + updateSearchParams({ + set: { + [key]: multiFilters[key].filter((id) => id !== value).join(","), + }, + del: "page", + }); + } else { + updateSearchParams({ + del: [key, "page"], + }); + } + }, + [updateSearchParams, multiFilters], + ); + + const onRemoveAll = useCallback( + () => + updateSearchParams({ + del: ["country", "starred", "platform", "reach"], + }), + [updateSearchParams], + ); + + const isFiltered = + activeFilters.length > 0 || + !!searchParamsObj.platform || + !!searchParamsObj.reach || + !!searchParamsObj.search; + + return { + filters, + activeFilters, + onSelect, + onRemove, + onRemoveAll, + isFiltered, + }; +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-toggle-partner-starred.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-toggle-partner-starred.ts new file mode 100644 index 00000000000..a7cc20f9c69 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-toggle-partner-starred.ts @@ -0,0 +1,129 @@ +"use client"; + +import { updateDiscoveredPartnerAction } from "@/lib/actions/partners/update-discovered-partner"; +import type { PartnerContentSearchResponse } from "@/lib/swr/use-partner-content-search"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { NetworkPartnerProps } from "@/lib/types"; +import { useAction } from "next-safe-action/hooks"; +import { useCallback } from "react"; +import { toast } from "sonner"; +import type { KeyedMutator } from "swr"; + +// Two optimistic star-toggle hooks for the two cache shapes (content-search's nested +// `partner.starredAt` vs the ranked list's flat `starredAt`). Left unparameterized so +// each cache transform stays verbatim. + +// Content-search cache: find the partner by `partnerId`, update nested `partner.starredAt`. +export function useToggleStarredContentSearch( + mutateContentSearch: KeyedMutator, +) { + const { id: workspaceId } = useWorkspace(); + const { executeAsync: updateDiscoveredPartner } = useAction( + updateDiscoveredPartnerAction, + ); + + return useCallback( + (partnerId: string, starred: boolean) => { + mutateContentSearch( + // @ts-ignore SWR doesn't seem to have proper typing for partial data results w/ `populateCache` + async () => { + const result = await updateDiscoveredPartner({ + workspaceId: workspaceId!, + partnerId, + starred, + }); + if (!result?.data) { + toast.error("Failed to star partner"); + throw new Error("Failed to star partner"); + } + + return result.data; + }, + { + optimisticData: (data) => + data && { + ...data, + partners: data.partners.map((p) => + p.partnerId === partnerId + ? { + ...p, + partner: { + ...p.partner, + starredAt: starred ? new Date() : null, + }, + } + : p, + ), + }, + populateCache: (result: { starredAt: Date | null }, data) => + data && { + ...data, + partners: data.partners.map((p) => + p.partnerId === partnerId + ? { + ...p, + partner: { + ...p.partner, + starredAt: result.starredAt, + }, + } + : p, + ), + }, + revalidate: false, + }, + ); + }, + [mutateContentSearch, updateDiscoveredPartner, workspaceId], + ); +} + +// Ranked-list cache: find the partner by `id`, update flat `starredAt`. `partners` +// is the fallback when the cache is empty (`data || partners`). +export function useToggleStarredRankedList( + mutatePartners: KeyedMutator, + partners: NetworkPartnerProps[] | undefined, +) { + const { id: workspaceId } = useWorkspace(); + const { executeAsync: updateDiscoveredPartner } = useAction( + updateDiscoveredPartnerAction, + ); + + return useCallback( + (partnerId: string, starred: boolean) => { + mutatePartners( + // @ts-ignore SWR doesn't seem to have proper typing for partial data results w/ `populateCache` + async () => { + const result = await updateDiscoveredPartner({ + workspaceId: workspaceId!, + partnerId, + starred, + }); + if (!result?.data) { + toast.error("Failed to star partner"); + throw new Error("Failed to star partner"); + } + + return result.data; + }, + { + optimisticData: (data) => + (data || partners)!.map((p) => + p.id === partnerId + ? { + ...p, + starredAt: starred ? new Date() : null, + } + : p, + ), + populateCache: (result: { starredAt: Date | null }, data) => + (data || partners)!.map((p) => + p.id === partnerId ? { ...p, starredAt: result.starredAt } : p, + ), + revalidate: false, + }, + ); + }, + [mutatePartners, partners, updateDiscoveredPartner, workspaceId], + ); +} diff --git a/apps/web/lib/api/create-id.ts b/apps/web/lib/api/create-id.ts index 3165325eaaf..de8e166a73f 100644 --- a/apps/web/lib/api/create-id.ts +++ b/apps/web/lib/api/create-id.ts @@ -47,6 +47,8 @@ const prefixes = [ "sbl_", // submitted lead "pb_", // partner postback "req_", // api request log + "pci_", // partner content item + "pcc_", // partner content chunk ] as const; // ULID uses base32 encoding diff --git a/apps/web/lib/api/network/calculate-partner-ranking.ts b/apps/web/lib/api/network/calculate-partner-ranking.ts index ec6d6275f04..816b503dd2e 100644 --- a/apps/web/lib/api/network/calculate-partner-ranking.ts +++ b/apps/web/lib/api/network/calculate-partner-ranking.ts @@ -1,8 +1,9 @@ import { prisma } from "@/lib/prisma"; import { getNetworkPartnersQuerySchema } from "@/lib/zod/schemas/partner-network"; import { ACME_PROGRAM_ID } from "@dub/utils"; -import { PlatformType, Prisma } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import * as z from "zod/v4"; +import { reachTiersToRanges } from "./reach-tiers"; type PartnerRankingFilters = z.infer; @@ -11,6 +12,36 @@ export interface PartnerRankingParams extends PartnerRankingFilters { similarPrograms?: Array<{ programId: string; similarityScore: number }>; } +// Raw row from the ranking query: Partner columns (p.*) vary, so the base is an +// index signature; `platforms` is the JSON_ARRAYAGG aggregate parsed below. +type RankedPartnerRawRow = Record & { + id: string; + platforms?: string | unknown[] | null; +}; + +// One platform inside the JSON aggregate. MySQL JSON returns BigInt as numbers and +// DateTime as ISO strings. +type RawPlatformAggregate = { + partnerId: string; + type: string; + identifier: string | null; + verifiedAt: string | null; + platformId: string | null; + subscribers: number | string | null; + posts: number | string | null; + views: number | string | null; +}; + +type SerializedPartnerPlatform = Omit< + RawPlatformAggregate, + "subscribers" | "posts" | "views" | "verifiedAt" +> & { + subscribers: bigint; + posts: bigint; + views: bigint; + verifiedAt: Date | null; +}; + /** * Partner Ranking Algorithm (only used for the "discover" tab) * Ranks partners based on performance in similar programs only. @@ -37,29 +68,11 @@ export interface PartnerRankingParams extends PartnerRankingFilters { * - clickToConversionRate: Average click-to-conversion rate across ALL programs the partner is enrolled in * - lastConversionAt: Most recent conversion date across ALL programs the partner is enrolled in */ -function buildOrderByClause({ - starred, - sortBy, - platform, -}: { - starred?: boolean | null; - sortBy?: "relevance" | "subscribers"; - platform?: PlatformType; -}) { +function buildOrderByClause({ starred }: { starred?: boolean | null }) { if (starred === true) { return Prisma.sql`dp.starredAt DESC`; } - if (sortBy === "subscribers" && platform) { - return Prisma.sql`( - SELECT COALESCE(MAX(pp_sort.subscribers), 0) - FROM PartnerPlatform pp_sort - WHERE pp_sort.partnerId = p.id - AND pp_sort.type = ${platform} - AND pp_sort.verifiedAt IS NOT NULL - ) DESC, p.id ASC`; - } - return Prisma.sql`finalScore DESC, p.id ASC`; } @@ -68,8 +81,8 @@ export async function calculatePartnerRanking({ partnerIds, country, starred, - sortBy = "relevance", platform, + reach, page = 1, pageSize, similarPrograms = [], @@ -89,21 +102,16 @@ export async function calculatePartnerRanking({ conditions.push(Prisma.sql`p.country = ${country}`); } - // Filter by platform type (must have verified platform) - // Combine both filters into a single EXISTS clause so they apply to the same platform - if (platform) { - const platformConditions: Prisma.Sql[] = [ - Prisma.sql`pp_filter.partnerId = p.id`, - Prisma.sql`pp_filter.verifiedAt IS NOT NULL`, - ]; - - platformConditions.push(Prisma.sql`pp_filter.type = ${platform}`); - + // Filter by platform type (must have a verified platform of one of the selected + // types). Inclusion semantics: a partner qualifies if present on any selection. + if (platform && platform.length > 0) { conditions.push( Prisma.sql`EXISTS ( - SELECT 1 - FROM PartnerPlatform pp_filter - WHERE ${Prisma.join(platformConditions, " AND ")} + SELECT 1 + FROM PartnerPlatform pp_filter + WHERE pp_filter.partnerId = p.id + AND pp_filter.verifiedAt IS NOT NULL + AND pp_filter.type IN (${Prisma.join(platform)}) )`, ); } @@ -114,6 +122,35 @@ export async function calculatePartnerRanking({ conditions.push(Prisma.sql`(dp.starredAt IS NULL OR dp.id IS NULL)`); } + // Reach-tier filter: the partner's max subscriber count across the selected + // platforms must fall in a chosen tier (see reach-tiers.ts). + if (reach && reach.length > 0) { + const ranges = reachTiersToRanges(reach); + const reachPlatformScope = + platform && platform.length > 0 + ? Prisma.sql`AND pp_reach.type IN (${Prisma.join(platform)})` + : Prisma.empty; + const rangeConditions = ranges.map(({ min, max }) => + max == null + ? Prisma.sql`reach_r.maxSubscribers >= ${min}` + : Prisma.sql`(reach_r.maxSubscribers >= ${min} AND reach_r.maxSubscribers < ${max})`, + ); + + conditions.push( + Prisma.sql`EXISTS ( + SELECT 1 + FROM ( + SELECT MAX(pp_reach.subscribers) AS maxSubscribers + FROM PartnerPlatform pp_reach + WHERE pp_reach.partnerId = p.id + AND pp_reach.verifiedAt IS NOT NULL + ${reachPlatformScope} + ) reach_r + WHERE ${Prisma.join(rangeConditions, " OR ")} + )`, + ); + } + const whereClause = Prisma.join(conditions, " AND "); // Rank partners with no platforms lower @@ -123,7 +160,7 @@ export async function calculatePartnerRanking({ WHERE pp.partnerId = p.id )`; - const orderByClause = buildOrderByClause({ starred, sortBy, platform }); + const orderByClause = buildOrderByClause({ starred }); const offset = (page - 1) * pageSize; @@ -165,9 +202,10 @@ export async function calculatePartnerRanking({ GROUP BY pe_all.partnerId ) allProgramMetrics ON allProgramMetrics.partnerId = p.id`; - const similarProgramMetricsJoin = - similarPrograms.length > 0 - ? Prisma.sql`LEFT JOIN ( + const hasSimilarPrograms = similarPrograms.length > 0; + + const similarProgramMetricsJoin = hasSimilarPrograms + ? Prisma.sql`LEFT JOIN ( SELECT pe2.partnerId, -- Similarity score: Sum weighted performance (0-50 points, no averaging) @@ -213,15 +251,18 @@ export async function calculatePartnerRanking({ AND pe2.status = 'approved' GROUP BY pe2.partnerId ) similarProgramMetrics ON similarProgramMetrics.partnerId = p.id` - : Prisma.sql`LEFT JOIN ( - SELECT - NULL as partnerId, - NULL as similarityScore, - NULL as programMatchScore - WHERE FALSE - ) similarProgramMetrics ON similarProgramMetrics.partnerId = p.id`; - - const partners = await prisma.$queryRaw>` + : Prisma.empty; + + // No similar programs → omit the join entirely and score these contributions as 0. + // A `FROM`-less `SELECT NULL … WHERE FALSE` dual-derived table joined to another is + // a Vitess planner hazard (vtgate can emit "ERROR 1054 Unknown column"), so we drop + // the join rather than emit a degenerate one. See references/planetscale-mysql.md. + const similarProgramScore = hasSimilarPrograms + ? Prisma.sql`COALESCE(similarProgramMetrics.similarityScore, 0) + + COALESCE(similarProgramMetrics.programMatchScore, 0)` + : Prisma.sql`0`; + + const partners = await prisma.$queryRaw` SELECT p.*, COALESCE(pe.lastConversionAt, allProgramMetrics.lastConversionAt) as lastConversionAt, @@ -246,8 +287,7 @@ export async function calculatePartnerRanking({ CASE WHEN ${hasProfileCheck} THEN 500 ELSE 0 END + -- Trusted partner bonus: 200 points for partners with networkStatus = "trusted" CASE WHEN p.networkStatus = "trusted" THEN 200 ELSE 0 END + - COALESCE(similarProgramMetrics.similarityScore, 0) + - COALESCE(similarProgramMetrics.programMatchScore, 0) + ${similarProgramScore} ) as finalScore FROM ( -- OPTIMIZATION: Filter to discoverable partners FIRST using subquery @@ -344,8 +384,8 @@ export async function calculatePartnerRanking({ LIMIT ${pageSize} OFFSET ${offset} `; - return partners.map((partner: any) => { - let platforms: any[] = []; + return partners.map((partner) => { + let platforms: SerializedPartnerPlatform[] = []; if (partner.platforms) { try { // Handle both string and already-parsed JSON @@ -356,20 +396,27 @@ export async function calculatePartnerRanking({ // Transform platforms to match Prisma types // MySQL JSON returns BigInt as numbers and DateTime as strings - platforms = (Array.isArray(parsedPlatforms) ? parsedPlatforms : []).map( - (platform: any) => ({ - ...platform, - subscribers: platform.subscribers - ? BigInt(platform.subscribers) - : BigInt(0), - posts: platform.posts ? BigInt(platform.posts) : BigInt(0), - views: platform.views ? BigInt(platform.views) : BigInt(0), - verifiedAt: platform.verifiedAt - ? new Date(platform.verifiedAt) - : null, - }), + platforms = ( + Array.isArray(parsedPlatforms) + ? (parsedPlatforms as RawPlatformAggregate[]) + : [] + ).map((platform) => ({ + ...platform, + subscribers: platform.subscribers + ? BigInt(platform.subscribers) + : BigInt(0), + posts: platform.posts ? BigInt(platform.posts) : BigInt(0), + views: platform.views ? BigInt(platform.views) : BigInt(0), + verifiedAt: platform.verifiedAt + ? new Date(platform.verifiedAt) + : null, + })); + } catch (error) { + // A malformed platform aggregate shouldn't silently drop the partner. + console.error( + `[calculatePartnerRanking] Failed to parse platforms JSON for partner ${partner.id}`, + error, ); - } catch { platforms = []; } } diff --git a/apps/web/lib/api/network/normalize-ranked-network-partner.ts b/apps/web/lib/api/network/normalize-ranked-network-partner.ts new file mode 100644 index 00000000000..459c0781868 --- /dev/null +++ b/apps/web/lib/api/network/normalize-ranked-network-partner.ts @@ -0,0 +1,44 @@ +import { NetworkPartnerSchema } from "@/lib/zod/schemas/partner-network"; +import { PreferredEarningStructure, SalesChannel } from "@prisma/client"; +import * as z from "zod/v4"; + +type RankedNetworkPartnerRow = Record & { + starredAt?: Date | string | null; + ignoredAt?: Date | string | null; + invitedAt?: Date | string | null; + identityVerificationStatus?: string | null; + identityVerifiedAt?: Date | string | null; + categories?: string | null; + preferredEarningStructures?: string | null; + salesChannels?: string | null; +}; + +export function parseRankedNetworkPartners( + partners: RankedNetworkPartnerRow[], +) { + return z.array(NetworkPartnerSchema).parse( + partners.map((partner) => ({ + ...partner, + starredAt: toNullableDate(partner.starredAt), + ignoredAt: toNullableDate(partner.ignoredAt), + invitedAt: toNullableDate(partner.invitedAt), + identityVerificationStatus: partner.identityVerificationStatus ?? null, + identityVerifiedAt: toNullableDate(partner.identityVerifiedAt), + categories: splitCommaSeparated(partner.categories), + preferredEarningStructures: splitCommaSeparated( + partner.preferredEarningStructures, + ) as PreferredEarningStructure[], + salesChannels: splitCommaSeparated( + partner.salesChannels, + ) as SalesChannel[], + })), + ); +} + +function toNullableDate(value: Date | string | null | undefined) { + return value ? new Date(value) : null; +} + +function splitCommaSeparated(value: string | null | undefined) { + return value ? value.split(",").map((item) => item.trim()) : []; +} diff --git a/apps/web/lib/api/network/partner-network-listing-where.ts b/apps/web/lib/api/network/partner-network-listing-where.ts index 50f26e95b5a..6390f7ed48a 100644 --- a/apps/web/lib/api/network/partner-network-listing-where.ts +++ b/apps/web/lib/api/network/partner-network-listing-where.ts @@ -1,10 +1,21 @@ -import { PlatformType, Prisma } from "@prisma/client"; +import { PartnerNetworkStatus, PlatformType, Prisma } from "@prisma/client"; +import type { ReachTier } from "./reach-tiers"; +import { reachTiersToRanges } from "./reach-tiers"; + +// Partner network statuses that count as "discoverable" in the network. Ingestion +// only embeds partners in these statuses, and the discover listing + content-search +// hydrate filter on them. Single source of truth for the Prisma call sites; the raw +// $queryRaw paths (e.g. calculatePartnerRanking) keep their own inline literals. +export const DISCOVERABLE_NETWORK_STATUSES: PartnerNetworkStatus[] = [ + "approved", + "trusted", +]; /** Query params shared by `/api/network/partners` and `/count` for listing. */ export type PartnerNetworkListingParams = { partnerIds?: string[]; country?: string; - platform?: PlatformType; + platform?: PlatformType[]; }; export type PartnerNetworkListingParts = { @@ -17,10 +28,10 @@ function listingPlatformSomeFromParams({ }: Pick): | Prisma.PartnerPlatformWhereInput | undefined { - return platform + return platform && platform.length ? { verifiedAt: { not: null }, - ...(platform && { type: platform }), + type: { in: platform }, } : undefined; } @@ -32,7 +43,7 @@ export function partnerNetworkListingParts( return { listingPartnerBase: { - networkStatus: { in: ["approved", "trusted"] }, + networkStatus: { in: DISCOVERABLE_NETWORK_STATUSES }, ...(params.partnerIds && { id: { in: params.partnerIds }, }), @@ -62,3 +73,48 @@ export function partnerNetworkListingWhere( ): Prisma.PartnerWhereInput { return partnerWhereFromListingParts(partnerNetworkListingParts(params)); } + +// Reach-tier filter (see reach-tiers.ts). "some platform >= min AND no scoped +// platform >= max" is the WHERE-clause equivalent of calculatePartnerRanking's max. +export function partnerReachWhere({ + reach, + platform, +}: { + reach?: ReachTier[]; + platform?: PlatformType[]; +}): Prisma.PartnerWhereInput { + if (!reach?.length) return {}; + + const ranges = reachTiersToRanges(reach); + const platformScope: Prisma.PartnerPlatformWhereInput = { + verifiedAt: { not: null }, + ...(platform?.length && { type: { in: platform } }), + }; + + return { + OR: ranges.map(({ min, max }) => ({ + AND: [ + { + platforms: { + some: { + ...platformScope, + subscribers: { gte: BigInt(min) }, + }, + }, + }, + ...(max != null + ? [ + { + platforms: { + none: { + ...platformScope, + subscribers: { gte: BigInt(max) }, + }, + }, + }, + ] + : []), + ], + })), + }; +} diff --git a/apps/web/lib/api/network/reach-tiers.ts b/apps/web/lib/api/network/reach-tiers.ts new file mode 100644 index 00000000000..3784ba6bce3 --- /dev/null +++ b/apps/web/lib/api/network/reach-tiers.ts @@ -0,0 +1,69 @@ +// Audience-size tiers for partner discovery. Reach is the partner's max subscriber +// count across the selected platforms, so the filter composes with topic relevance +// and stays well-defined for any platform selection. + +export const REACH_TIER_KEYS = [ + "nano", + "micro", + "mid", + "macro", + "mega", +] as const; + +export type ReachTier = (typeof REACH_TIER_KEYS)[number]; + +// Display: the numeric `range` leads (objective, no "small = lesser" baggage) and +// the `descriptor` is a muted, all-positive secondary handle. The keys above stay +// as the stable URL/param values. +export const REACH_TIERS: Record< + ReachTier, + { range: string; descriptor: string; min: number; max: number | null } +> = { + nano: { range: "Under 10K", descriptor: "Emerging", min: 0, max: 10_000 }, + micro: { range: "10K – 100K", descriptor: "Rising", min: 10_000, max: 100_000 }, + mid: { + range: "100K – 500K", + descriptor: "Established", + min: 100_000, + max: 500_000, + }, + macro: { + range: "500K – 1M", + descriptor: "Leading", + min: 500_000, + max: 1_000_000, + }, + mega: { range: "1M+", descriptor: "Flagship", min: 1_000_000, max: null }, +}; + +const REACH_TIER_SET = new Set(REACH_TIER_KEYS); + +export function isReachTier(value: string | null | undefined): value is ReachTier { + return Boolean(value && REACH_TIER_SET.has(value)); +} + +// Parse the comma-separated `reach` query param into valid tier keys (in canonical +// order, deduped). Absent/invalid → empty (no reach filter). +export function parseReachTiers(param: string | null | undefined): ReachTier[] { + if (!param) return []; + + const selected = new Set( + param + .split(",") + .map((value) => value.trim()) + .filter(isReachTier), + ); + + return REACH_TIER_KEYS.filter((key) => selected.has(key)); +} + +// Subscriber ranges (min inclusive, max exclusive; null max = unbounded) for the +// selected tiers — one entry per tier, OR'd together by callers. +export function reachTiersToRanges( + tiers: ReachTier[], +): { min: number; max: number | null }[] { + return tiers.map((tier) => { + const { min, max } = REACH_TIERS[tier]; + return { min, max }; + }); +} diff --git a/apps/web/lib/api/scrape-creators/client.ts b/apps/web/lib/api/scrape-creators/client.ts index 660ec79e9e3..6fad792bb02 100644 --- a/apps/web/lib/api/scrape-creators/client.ts +++ b/apps/web/lib/api/scrape-creators/client.ts @@ -1,17 +1,34 @@ +import "server-only"; + +import { logger } from "@/lib/axiom/server"; import { createFetch, createSchema } from "@better-fetch/fetch"; +import { prettyPrint } from "@dub/utils"; import { PlatformType } from "@prisma/client"; import * as z from "zod/v4"; -import { socialContentSchema, socialProfileSchema } from "./schema"; +import { + instagramMediaTranscriptSchema, + instagramUserPostsSchema, + socialContentSchema, + socialProfileSchema, + tiktokProfileVideosSchema, + tiktokTranscriptSchema, + youtubeChannelVideosSchema, + youtubeTranscriptSchema, +} from "./schema"; +// Bound each request so a hung vendor call can't run past the caller's +// (cron/route) maxDuration. Aborts the fetch; the single linear retry still applies. +const SCRAPE_CREATORS_REQUEST_TIMEOUT_MS = 30_000; export const scrapeCreatorsFetch = createFetch({ baseURL: "https://api.scrapecreators.com", + timeout: SCRAPE_CREATORS_REQUEST_TIMEOUT_MS, retry: { type: "linear", attempts: 1, delay: 3000, }, headers: { - "x-api-key": process.env.SCRAPECREATORS_API_KEY!, + "x-api-key": process.env.SCRAPECREATORS_API_KEY ?? "", }, schema: createSchema( { @@ -41,18 +58,89 @@ export const scrapeCreatorsFetch = createFetch({ }), output: socialContentSchema, }, + + // Fetch recent YouTube channel videos + "/v1/youtube/channel-videos": { + method: "get", + query: z + .object({ + channelId: z.string().optional(), + handle: z.string().optional(), + sort: z.enum(["latest", "popular"]).optional(), + continuationToken: z.string().optional(), + includeExtras: z.enum(["true", "false"]).optional(), + }) + .refine((query) => query.channelId || query.handle, { + message: "Either channelId or handle is required.", + }), + output: youtubeChannelVideosSchema, + }, + + // Fetch a YouTube video or short transcript + "/v1/youtube/video/transcript": { + method: "get", + query: z.object({ + url: z.string(), + language: z.string().length(2).optional(), + }), + output: youtubeTranscriptSchema, + }, + + // Fetch recent TikTok profile videos + "/v3/tiktok/profile/videos": { + method: "get", + query: z.object({ + handle: z.string(), + user_id: z.string().optional(), + sort_by: z.enum(["latest", "popular"]).optional(), + max_cursor: z.string().optional(), + region: z.string().optional(), + trim: z.boolean().optional(), + }), + output: tiktokProfileVideosSchema, + }, + + // Fetch a TikTok video transcript + "/v1/tiktok/video/transcript": { + method: "get", + query: z.object({ + url: z.string(), + language: z.string().length(2).optional(), + use_ai_as_fallback: z.enum(["true", "false"]).optional(), + }), + output: tiktokTranscriptSchema, + }, + + // Fetch recent Instagram user posts + "/v2/instagram/user/posts": { + method: "get", + query: z.object({ + handle: z.string(), + next_max_id: z.string().optional(), + trim: z.boolean().optional(), + }), + // Parsed in getInstagramUserPosts so account-unavailable responses can + // be classified as empty results instead of strict-schema 500s. + output: z.unknown(), + }, + + // Fetch an Instagram video/reel transcript + "/v2/instagram/media/transcript": { + method: "get", + query: z.object({ + url: z.string(), + }), + output: instagramMediaTranscriptSchema, + }, }, { strict: true, }, ), onError: ({ error }) => { - console.error("[ScrapeCreators] Error", error); + logger.error("[ScrapeCreators] Error", { + error: prettyPrint(error), + }); + void logger.flush(); }, - // onResponse: async ({ response }) => { - // console.log( - // "[ScrapeCreators] Response", - // prettyPrint(await response.clone().json()), - // ); - // }, }); diff --git a/apps/web/lib/api/scrape-creators/get-instagram-media-transcript.ts b/apps/web/lib/api/scrape-creators/get-instagram-media-transcript.ts new file mode 100644 index 00000000000..9c7e8cd7b86 --- /dev/null +++ b/apps/web/lib/api/scrape-creators/get-instagram-media-transcript.ts @@ -0,0 +1,26 @@ +import { scrapeCreatorsFetch } from "./client"; + +interface GetInstagramMediaTranscriptParams { + url: string; +} + +export async function getInstagramMediaTranscript({ + url, +}: GetInstagramMediaTranscriptParams) { + const { data, error } = await scrapeCreatorsFetch( + "/v2/instagram/media/transcript", + { + query: { + url, + }, + }, + ); + + if (error) { + throw new Error( + "We were unable to retrieve the Instagram transcript from ScrapeCreators.", + ); + } + + return data; +} diff --git a/apps/web/lib/api/scrape-creators/get-instagram-user-posts.ts b/apps/web/lib/api/scrape-creators/get-instagram-user-posts.ts new file mode 100644 index 00000000000..cde3e8fc98b --- /dev/null +++ b/apps/web/lib/api/scrape-creators/get-instagram-user-posts.ts @@ -0,0 +1,80 @@ +import { scrapeCreatorsFetch } from "./client"; +import { instagramUserPostsSchema } from "./schema"; + +interface GetInstagramUserPostsParams { + handle: string; + nextMaxId?: string; + trim?: boolean; +} + +export async function getInstagramUserPosts({ + handle, + nextMaxId, + trim = true, +}: GetInstagramUserPostsParams) { + const { data, error } = await scrapeCreatorsFetch( + "/v2/instagram/user/posts", + { + query: { + handle, + next_max_id: nextMaxId, + trim, + }, + }, + ); + + if (error) { + throw new Error( + "We were unable to retrieve Instagram user posts from ScrapeCreators.", + ); + } + + const parsed = instagramUserPostsSchema.safeParse(data); + + if (!parsed.success && isMissingInstagramUserPostsItems(data)) { + console.warn("[ScrapeCreators] Instagram user posts unavailable", { + handle, + nextMaxId, + issues: parsed.error.issues, + response: summarizeScrapeCreatorsResponse(data), + }); + + return { + items: [], + next_max_id: null, + more_available: false, + }; + } + + if (!parsed.success) { + throw new Error( + `Unexpected Instagram user posts response from ScrapeCreators: ${JSON.stringify({ + issues: parsed.error.issues, + response: summarizeScrapeCreatorsResponse(data), + })}`, + ); + } + + return parsed.data; +} + +function isMissingInstagramUserPostsItems(data: unknown) { + if (!data || typeof data !== "object" || Array.isArray(data)) return false; + return !("items" in data); +} + +function summarizeScrapeCreatorsResponse(data: unknown) { + if (!data || typeof data !== "object" || Array.isArray(data)) return data; + + const response = data as Record; + + return { + keys: Object.keys(response).slice(0, 20), + success: response.success, + error: response.error, + errorStatus: response.errorStatus, + message: response.message, + status: response.status, + statusText: response.statusText, + }; +} diff --git a/apps/web/lib/api/scrape-creators/get-tiktok-profile-videos.ts b/apps/web/lib/api/scrape-creators/get-tiktok-profile-videos.ts new file mode 100644 index 00000000000..fb8479c8812 --- /dev/null +++ b/apps/web/lib/api/scrape-creators/get-tiktok-profile-videos.ts @@ -0,0 +1,36 @@ +import { scrapeCreatorsFetch } from "./client"; + +interface GetTikTokProfileVideosParams { + handle: string; + userId?: string; + maxCursor?: string; + trim?: boolean; +} + +export async function getTikTokProfileVideos({ + handle, + userId, + maxCursor, + trim = true, +}: GetTikTokProfileVideosParams) { + const { data, error } = await scrapeCreatorsFetch( + "/v3/tiktok/profile/videos", + { + query: { + handle, + user_id: userId, + sort_by: "latest", + max_cursor: maxCursor, + trim, + }, + }, + ); + + if (error) { + throw new Error( + "We were unable to retrieve TikTok profile videos from ScrapeCreators.", + ); + } + + return data; +} diff --git a/apps/web/lib/api/scrape-creators/get-tiktok-video-transcript.ts b/apps/web/lib/api/scrape-creators/get-tiktok-video-transcript.ts new file mode 100644 index 00000000000..7808d1662bb --- /dev/null +++ b/apps/web/lib/api/scrape-creators/get-tiktok-video-transcript.ts @@ -0,0 +1,64 @@ +import { scrapeCreatorsFetch } from "./client"; + +interface GetTikTokVideoTranscriptParams { + url: string; + language?: string; + useAiAsFallback?: boolean; +} + +export async function getTikTokVideoTranscript({ + url, + language = "en", + useAiAsFallback = false, +}: GetTikTokVideoTranscriptParams) { + const { data, error } = await scrapeCreatorsFetch( + "/v1/tiktok/video/transcript", + { + query: { + url, + language, + use_ai_as_fallback: useAiAsFallback ? "true" : "false", + }, + }, + ); + + if (error) { + if (isTikTokTranscriptUnavailableError(error)) { + return { + id: null, + url, + transcript: null, + }; + } + + throw new Error( + "We were unable to retrieve the TikTok transcript from ScrapeCreators.", + ); + } + + return data; +} + +function isTikTokTranscriptUnavailableError(error: unknown) { + if (!error || typeof error !== "object") return false; + + const status = + "status" in error && typeof error.status === "number" + ? error.status + : "errorStatus" in error && typeof error.errorStatus === "number" + ? error.errorStatus + : null; + + if (status !== 400) return false; + + const message = + "message" in error && typeof error.message === "string" + ? error.message.toLowerCase() + : ""; + + return ( + message.includes("not a video") || + message.includes("photo carousel") || + message.includes("no transcript") + ); +} diff --git a/apps/web/lib/api/scrape-creators/get-youtube-channel-videos.ts b/apps/web/lib/api/scrape-creators/get-youtube-channel-videos.ts new file mode 100644 index 00000000000..04862c7c57e --- /dev/null +++ b/apps/web/lib/api/scrape-creators/get-youtube-channel-videos.ts @@ -0,0 +1,43 @@ +import { scrapeCreatorsFetch } from "./client"; + +type GetYouTubeChannelVideosParams = ( + | { + channelId: string; + handle?: string; + } + | { + channelId?: string; + handle: string; + } +) & { + continuationToken?: string; + includeExtras?: boolean; +}; + +export async function getYouTubeChannelVideos({ + channelId, + handle, + continuationToken, + includeExtras = false, +}: GetYouTubeChannelVideosParams) { + const { data, error } = await scrapeCreatorsFetch( + "/v1/youtube/channel-videos", + { + query: { + channelId, + handle, + sort: "latest", + continuationToken, + includeExtras: includeExtras ? "true" : "false", + }, + }, + ); + + if (error) { + throw new Error( + "We were unable to retrieve YouTube channel videos from ScrapeCreators.", + ); + } + + return data; +} diff --git a/apps/web/lib/api/scrape-creators/get-youtube-video-transcript.ts b/apps/web/lib/api/scrape-creators/get-youtube-video-transcript.ts new file mode 100644 index 00000000000..ce3598db975 --- /dev/null +++ b/apps/web/lib/api/scrape-creators/get-youtube-video-transcript.ts @@ -0,0 +1,29 @@ +import { scrapeCreatorsFetch } from "./client"; + +interface GetYouTubeVideoTranscriptParams { + url: string; + language?: string; +} + +export async function getYouTubeVideoTranscript({ + url, + language = "en", +}: GetYouTubeVideoTranscriptParams) { + const { data, error } = await scrapeCreatorsFetch( + "/v1/youtube/video/transcript", + { + query: { + url, + language, + }, + }, + ); + + if (error) { + throw new Error( + "We were unable to retrieve the YouTube transcript from ScrapeCreators.", + ); + } + + return data; +} diff --git a/apps/web/lib/api/scrape-creators/schema.ts b/apps/web/lib/api/scrape-creators/schema.ts index 8125409d023..5069da45c05 100644 --- a/apps/web/lib/api/scrape-creators/schema.ts +++ b/apps/web/lib/api/scrape-creators/schema.ts @@ -345,3 +345,179 @@ export const socialContentSchema = z.preprocess( }), ]), ); + +export const youtubeChannelVideosSchema = z.object({ + videos: z.array( + z.object({ + type: z.string(), + id: z.string(), + url: z.string(), + title: z.string().nullish(), + description: z.string().nullish(), + thumbnail: z.string().nullish(), + channel: z + .object({ + title: z.string().nullish(), + thumbnail: z.string().nullish(), + }) + .optional(), + viewCountText: z.string().nullish(), + viewCountInt: z + .number() + .nullish() + .transform((val) => val ?? 0), + likeCountInt: z.number().nullish(), + commentCountInt: z.number().nullish(), + publishedTimeText: z.string().nullish(), + publishedTime: z.string().nullish(), + lengthText: z.string().nullish(), + lengthSeconds: z + .number() + .nullish() + .transform((val) => val ?? 0), + badges: z.array(z.unknown()).optional(), + }), + ), + continuationToken: z.string().nullish(), +}); + +export const youtubeTranscriptSchema = z.object({ + videoId: z.string(), + type: z.string(), + url: z.string(), + transcript: z + .array( + z.object({ + text: z.string(), + startMs: z.string(), + endMs: z.string(), + startTimeText: z.string().nullish(), + }), + ) + .nullable(), + transcript_only_text: z.string().nullish(), + language: z.string().nullish(), +}); + +const urlListSchema = z + .object({ + url_list: z.array(z.string()).nullish(), + }) + .nullish(); + +const stringFromStringOrNumberSchema = z + .union([z.string(), z.number()]) + .nullish() + .transform((value) => (value == null ? value : String(value))); + +const booleanFromBooleanOrNumberSchema = z + .union([z.boolean(), z.number()]) + .nullish() + .transform((value) => { + if (typeof value === "number") { + return value !== 0; + } + + return value; + }); + +export const tiktokProfileVideosSchema = z.object({ + aweme_list: z.array( + z.object({ + aweme_id: z.string().nullish(), + id_str: z.string().nullish(), + desc: z.string().nullish(), + create_time: z.number().nullish(), + create_time_utc: z.string().nullish(), + duration: z.number().nullish(), + url: z.string().nullish(), + statistics: z + .object({ + play_count: z.number().nullish(), + digg_count: z.number().nullish(), + comment_count: z.number().nullish(), + share_count: z.number().nullish(), + collect_count: z.number().nullish(), + }) + .nullish(), + video: z + .object({ + duration: z.number().nullish(), + dynamic_cover: urlListSchema, + cover: urlListSchema, + }) + .nullish(), + }), + ), + max_cursor: stringFromStringOrNumberSchema, + has_more: booleanFromBooleanOrNumberSchema, +}); + +export const tiktokTranscriptSchema = z.object({ + id: z.string().nullish(), + url: z.string(), + transcript: z.string().nullish(), +}); + +const instagramCaptionSchema = z + .object({ + text: z.string().nullish(), + }) + .nullish(); + +const instagramImageVersionsSchema = z + .object({ + candidates: z + .array( + z.object({ + url: z.string(), + }), + ) + .nullish(), + }) + .nullish(); + +export const instagramUserPostsSchema = z.object({ + items: z.array( + z.object({ + id: z.string().nullish(), + pk: z.string().nullish(), + code: z.string().nullish(), + url: z.string().nullish(), + media_type: z.number().nullish(), + product_type: z.string().nullish(), + taken_at: z.number().nullish(), + caption: instagramCaptionSchema, + play_count: z.number().nullish(), + ig_play_count: z.number().nullish(), + like_count: z.number().nullish(), + comment_count: z.number().nullish(), + display_uri: z.string().nullish(), + image_versions2: instagramImageVersionsSchema, + video_versions: z + .array( + z.object({ + url: z.string().nullish(), + }), + ) + .nullish(), + video_duration: z.number().nullish(), + has_audio: z.boolean().nullish(), + }), + ), + next_max_id: z.string().nullish(), + more_available: z.boolean().nullish(), +}); + +export const instagramMediaTranscriptSchema = z.object({ + success: z.boolean().nullish(), + transcripts: z + .array( + z.object({ + id: z.string().nullish(), + shortcode: z.string().nullish(), + text: z.string().nullish(), + }), + ) + .nullish(), +}); diff --git a/apps/web/lib/partner-content-search/chunk-transcript.ts b/apps/web/lib/partner-content-search/chunk-transcript.ts new file mode 100644 index 00000000000..0bfadaf81b2 --- /dev/null +++ b/apps/web/lib/partner-content-search/chunk-transcript.ts @@ -0,0 +1,221 @@ +import { createHash } from "crypto"; +import { PARTNER_CONTENT_SEARCH_LIMITS } from "./constants"; +import { + ChunkTranscriptOptions, + SentenceTimestamp, + TranscriptChunk, + TranscriptSegment, +} from "./types"; + +type SentenceUnit = SentenceTimestamp & { + tokenEstimate: number; +}; + +export function normalizeTranscriptText(text: string) { + return text.replace(/\s+/g, " ").trim(); +} + +export function normalizeTranscriptSegments(segments: TranscriptSegment[]) { + return segments + .map((segment) => ({ + text: normalizeTranscriptText(segment.text), + startMs: segment.startMs ?? null, + endMs: segment.endMs ?? null, + })) + .filter((segment) => segment.text.length > 0); +} + +export function hashTranscript(segments: TranscriptSegment[]) { + const normalized = normalizeTranscriptSegments(segments) + .map( + ({ text, startMs, endMs }) => + `${startMs ?? ""}:${endMs ?? ""}:${text}`, + ) + .join("\n"); + + return hashText(normalized); +} + +export function hashText(text: string) { + return createHash("sha256") + .update(normalizeTranscriptText(text)) + .digest("hex"); +} + +export function estimateTokenCount(text: string) { + const normalized = normalizeTranscriptText(text); + if (!normalized) return 0; + + // Lightweight approximation keeps this dependency-free; swap in a tokenizer later if needed. + const words = normalized.split(/\s+/).length; + return Math.max(1, Math.ceil(words * 1.35)); +} + +export function chunkTranscriptSegments( + segments: TranscriptSegment[], + options: ChunkTranscriptOptions = {}, +): TranscriptChunk[] { + const maxTokens = + options.maxTokens ?? PARTNER_CONTENT_SEARCH_LIMITS.chunkMaxTokens; + const minTokens = + options.minTokens ?? PARTNER_CONTENT_SEARCH_LIMITS.chunkMinTokens; + const overlapTokens = + options.overlapTokens ?? PARTNER_CONTENT_SEARCH_LIMITS.chunkOverlapTokens; + + if (maxTokens <= 0) throw new Error("maxTokens must be greater than 0."); + if (minTokens <= 0) throw new Error("minTokens must be greater than 0."); + if (overlapTokens < 0) throw new Error("overlapTokens cannot be negative."); + if (overlapTokens >= maxTokens) { + throw new Error("overlapTokens must be less than maxTokens."); + } + + const units = normalizeTranscriptSegments(segments).flatMap( + (segment, segmentIndex) => + splitSegmentIntoSentenceUnits(segment, segmentIndex), + ); + + if (units.length === 0) return []; + + const chunks: TranscriptChunk[] = []; + let startIndex = 0; + + while (startIndex < units.length) { + let endIndex = startIndex; + let tokenEstimate = 0; + + while (endIndex < units.length) { + const nextTokens = units[endIndex].tokenEstimate; + const wouldExceedMax = + tokenEstimate > 0 && tokenEstimate + nextTokens > maxTokens; + const meetsMinimum = tokenEstimate >= minTokens; + + if (wouldExceedMax && meetsMinimum) break; + + tokenEstimate += nextTokens; + endIndex++; + + if (tokenEstimate >= maxTokens) break; + } + + if (endIndex === startIndex) endIndex++; + + const chunkUnits = units.slice(startIndex, endIndex); + chunks.push(toTranscriptChunk(chunks.length, chunkUnits)); + + if (endIndex >= units.length) break; + + startIndex = getNextStartIndex({ + units, + startIndex, + endIndex, + overlapTokens, + }); + } + + return chunks; +} + +function splitSegmentIntoSentenceUnits( + segment: Required, + segmentIndex: number, +): SentenceUnit[] { + const sentences = splitIntoSentences(segment.text); + + if (sentences.length === 0) return []; + + const hasTiming = segment.startMs !== null && segment.endMs !== null; + const duration = hasTiming + ? Math.max(0, Number(segment.endMs) - Number(segment.startMs)) + : 0; + + return sentences.map((sentence, index) => { + const startMs = + hasTiming && duration > 0 + ? Math.round( + Number(segment.startMs) + (duration * index) / sentences.length, + ) + : segment.startMs; + const endMs = + hasTiming && duration > 0 + ? Math.round( + Number(segment.startMs) + + (duration * (index + 1)) / sentences.length, + ) + : segment.endMs; + + return { + text: sentence, + startMs, + endMs, + segmentIndex, + tokenEstimate: estimateTokenCount(sentence), + }; + }); +} + +function splitIntoSentences(text: string) { + const normalized = normalizeTranscriptText(text); + if (!normalized) return []; + + const sentences = normalized.match(/[^.!?]+[.!?]+|[^.!?]+$/g) ?? [ + normalized, + ]; + + return sentences.map(normalizeTranscriptText).filter(Boolean); +} + +function toTranscriptChunk( + chunkIndex: number, + sentenceUnits: SentenceUnit[], +): TranscriptChunk { + const sentenceTimestamps = sentenceUnits.map( + ({ tokenEstimate: _tokenEstimate, ...unit }) => unit, + ); + + return { + chunkIndex, + text: sentenceUnits.map(({ text }) => text).join(" "), + tokenEstimate: sentenceUnits.reduce( + (total, unit) => total + unit.tokenEstimate, + 0, + ), + startMs: firstNonNull(sentenceUnits.map(({ startMs }) => startMs)), + endMs: lastNonNull(sentenceUnits.map(({ endMs }) => endMs)), + sentenceTimestamps, + }; +} + +function getNextStartIndex({ + units, + startIndex, + endIndex, + overlapTokens, +}: { + units: SentenceUnit[]; + startIndex: number; + endIndex: number; + overlapTokens: number; +}) { + if (overlapTokens === 0) return endIndex; + + let overlap = 0; + let nextStartIndex = endIndex; + + while (nextStartIndex > startIndex + 1 && overlap < overlapTokens) { + nextStartIndex--; + overlap += units[nextStartIndex].tokenEstimate; + } + + return Math.max(startIndex + 1, Math.min(nextStartIndex, endIndex)); +} + +function firstNonNull(values: Array) { + return values.find((value): value is number => value !== null) ?? null; +} + +function lastNonNull(values: Array) { + for (let index = values.length - 1; index >= 0; index--) { + if (values[index] !== null) return values[index]; + } + return null; +} diff --git a/apps/web/lib/partner-content-search/constants.ts b/apps/web/lib/partner-content-search/constants.ts new file mode 100644 index 00000000000..6ca671cbe57 --- /dev/null +++ b/apps/web/lib/partner-content-search/constants.ts @@ -0,0 +1,81 @@ +// Manually-created PlanetScale VECTOR index on PartnerContentChunk.embedding and its +// distance metric. Keep in sync with the schema @@index(map:) and the FORCE INDEX / +// DISTANCE() calls — vector-index-sync.test.ts guards the drift. +export const PARTNER_CONTENT_CHUNK_VECTOR_INDEX = + "partner_content_chunk_embedding_cosine_idx"; + +export const PARTNER_CONTENT_CHUNK_VECTOR_DISTANCE = "cosine"; + +export const PARTNER_CONTENT_SEARCH_MODELS = { + embedding: { + provider: "voyage", + model: "voyage-4", + dimensions: 1024, + outputDtype: "float", + id: "voyage:voyage-4:1024:float", + }, + reranker: { + provider: "voyage", + model: "rerank-2.5", + }, +} as const; + +export const PARTNER_CONTENT_SEARCH_LIMITS = { + recencyWindowMonths: 12, + contentItemsPerPartnerPlatform: 50, + chunkMinTokens: 400, + chunkMaxTokens: 800, + chunkOverlapTokens: 80, // Cross-chunk context. + recentContentMaxPerPartner: 200, // Detail topic-fit eval cap. + vectorSearchChunkPoolSize: 600, // Raw ANN pool. + rerankerCandidateCount: 150, // Query/rerank cap. + rerankerDocumentCharSafetyLimit: 20_000, // Bug guard. +} as const; + +export const PARTNER_CONTENT_SEARCH_FUSION_WEIGHTS = { + semantic: 0.6, + existingPartnerRank: 0.4, +} as const; + +// Tuning for the aggregate "Topic Fit" card score. Reranker relevance gates which +// recent posts count as on-topic; the score is then coverage-based. +export const PARTNER_CONTENT_SEARCH_TOPIC_FIT = { + rerankMatchThreshold: 0.4, + // topicFit = 100 * coverage^exponent (concave lift, so mid-coverage isn't punished). + coverageCurveExponent: 0.5, + strongMatchScore: 0.7, + minimumMatchedEvidenceStrength: 0.35, + // depthConfidence = 1 - exp(-weightedMatchedContentScore / this); lower saturates faster. + depthSaturationScore: 4, + // Credit kept when on-topic posts are diluted (0 = pure ratio, 1 = ignore dilution). + dilutionForgiveness: 0.25, + creatorTextOnlyVideoWeight: 0.4, + bandThresholds: { + consistent: 0.5, + frequent: 0.25, + }, +} as const; + +// Tuning for the detail-pane "Top content" list only — never feeds Topic Fit, the +// coverage summary, or partner ordering. +export const PARTNER_CONTENT_SEARCH_TOP_CONTENT = { + topContentCount: 5, + relevanceWeight: 0.7, + engagementWeight: 0.3, + // log10-view spread of the logistic mapping views to a [0,1] score vs. the median. + engagementLogSpread: 0.5, +} as const; + +export type PartnerContentTopicFitBand = + | "consistent" + | "frequent" + | "occasional" + | "one-off" + | "none"; + +export const PARTNER_CONTENT_SEARCH_PARTNER_LIMIT = 50; +export const PARTNER_CONTENT_SEARCH_DEFAULT_CHUNKS_PER_PARTNER = 2; +export const PARTNER_CONTENT_SEARCH_MAX_CHUNKS_PER_PARTNER = 50; +export const PARTNER_CONTENT_SEARCH_DETAIL_CHUNKS_PER_PARTNER = 40; +export const PARTNER_CONTENT_SEARCH_VOYAGE_QUERY_TIMEOUT_MS = 10_000; +export const PARTNER_CONTENT_SEARCH_RERANK_TIMEOUT_MS = 4_000; diff --git a/apps/web/lib/partner-content-search/content-matches.ts b/apps/web/lib/partner-content-search/content-matches.ts new file mode 100644 index 00000000000..c92874c93da --- /dev/null +++ b/apps/web/lib/partner-content-search/content-matches.ts @@ -0,0 +1,215 @@ +import { + createContentMatchEvidence, + getEvidenceMatchScore, + getEvidenceSource, + getEvidenceTopicScore, + getMatchedSourceScore, + getSourceScore, + type PartnerContentMatchEvidence, + type SourceScoreByContentItemId, +} from "./ranking"; +import { toScore, type PartnerContentSearchRow } from "./search-utils"; +import type { PartnerRecentContentRow } from "./match-summary-queries"; + +export type PartnerContentItemBestMatch = { + transcriptScore: number | null; + creatorTextScore: number | null; +}; + +export type PartnerContentMatch = { + partnerContentItemId: string; + platform: string; + publishedAt: string | null; + viewCount: number | null; + matched: boolean; + matchScore: number | null; + matchEvidence: PartnerContentMatchEvidence; +}; + +export type QueryContentMatchContext = { + cutoffDistance?: number | null; + recentItemSourceBestDistance: SourceScoreByContentItemId; + rerankByItemSource: SourceScoreByContentItemId; +}; + +export type ListContentMatchContext = { + bestMatchByContentItemId: Map; +}; + +export type ContentMatchContext = + | QueryContentMatchContext + | ListContentMatchContext; + +export function getBestMatchByContentItemId(rows: PartnerContentSearchRow[]) { + const bestMatchByContentItemId = new Map< + string, + PartnerContentItemBestMatch + >(); + + for (const row of rows) { + const score = row.rerankScore ?? toScore(Number(row.distance)); + const evidenceSource = getEvidenceSource(row.chunkSource); + const existing = bestMatchByContentItemId.get(row.partnerContentItemId); + const next = existing ?? { + transcriptScore: null, + creatorTextScore: null, + }; + + if (evidenceSource === "transcript") { + next.transcriptScore = + next.transcriptScore == null + ? score + : Math.max(next.transcriptScore, score); + } else { + next.creatorTextScore = + next.creatorTextScore == null + ? score + : Math.max(next.creatorTextScore, score); + } + + bestMatchByContentItemId.set(row.partnerContentItemId, next); + } + + return bestMatchByContentItemId; +} + +export function getQueryContentMatch({ + row, + context, +}: { + row: PartnerRecentContentRow; + context: QueryContentMatchContext; +}) { + // On-topic gate: rerank score when present, else cosine cutoff (no lexical boost). + const transcriptScore = getMatchedSourceScore({ + rerankScore: getSourceScore( + context.rerankByItemSource, + row.partnerContentItemId, + "transcript", + ), + bestDistance: getSourceScore( + context.recentItemSourceBestDistance, + row.partnerContentItemId, + "transcript", + ), + cutoffDistance: context.cutoffDistance, + }); + const creatorTextScore = getMatchedSourceScore({ + rerankScore: getSourceScore( + context.rerankByItemSource, + row.partnerContentItemId, + "creatorText", + ), + bestDistance: getSourceScore( + context.recentItemSourceBestDistance, + row.partnerContentItemId, + "creatorText", + ), + cutoffDistance: context.cutoffDistance, + }); + + const matchEvidence = createContentMatchEvidence({ + contentType: row.contentType, + transcriptScore, + creatorTextScore, + }); + + return { + matchEvidence, + matchScore: getEvidenceMatchScore(matchEvidence), + }; +} + +export function getListContentMatch({ + row, + context, +}: { + row: PartnerRecentContentRow; + context: ListContentMatchContext; +}) { + const match = context.bestMatchByContentItemId.get(row.partnerContentItemId); + const matchEvidence = createContentMatchEvidence({ + contentType: row.contentType, + transcriptScore: match?.transcriptScore ?? null, + creatorTextScore: match?.creatorTextScore ?? null, + }); + + return { + matchEvidence, + matchScore: getEvidenceMatchScore(matchEvidence), + }; +} + +export function toContentMatch({ + row, + context, +}: { + row: PartnerRecentContentRow; + context: ContentMatchContext; +}): PartnerContentMatch { + const { matchEvidence, matchScore } = + "bestMatchByContentItemId" in context + ? getListContentMatch({ row, context }) + : getQueryContentMatch({ row, context }); + const matched = matchEvidence.sources.length > 0; + + return { + partnerContentItemId: row.partnerContentItemId, + platform: row.platformType, + publishedAt: row.publishedAt?.toISOString() ?? null, + viewCount: row.viewCount != null ? Number(row.viewCount) : null, + matched, + matchScore, + matchEvidence, + }; +} + +// Strong vs partial split for the coverage bar (calibrated score ≥ 0.7). +const STRONG_MATCH_DISPLAY_THRESHOLD = 0.7; + +export function getContentMatchStats(contentMatches: PartnerContentMatch[]) { + const matchedContent = contentMatches.filter((match) => match.matched); + const matchedContentCount = matchedContent.length; + const strongMatchedContentCount = matchedContent.filter( + (match) => (match.matchScore ?? 0) >= STRONG_MATCH_DISPLAY_THRESHOLD, + ).length; + const partialMatchedContentCount = + matchedContentCount - strongMatchedContentCount; + const transcriptMatchedContentCount = contentMatches.filter( + ({ matchEvidence }) => matchEvidence.sources.includes("transcript"), + ).length; + const creatorTextMatchedContentCount = contentMatches.filter( + ({ matchEvidence }) => matchEvidence.sources.includes("creatorText"), + ).length; + const creatorTextOnlyContentCount = contentMatches.filter( + ({ matchEvidence }) => + matchEvidence.primarySource === "creatorText" && + matchEvidence.sources.length === 1, + ).length; + const weightedMatchedContentCount = Number( + contentMatches + .reduce((total, match) => total + match.matchEvidence.weight, 0) + .toFixed(3), + ); + const weightedMatchedContentScore = Number( + contentMatches + .reduce( + (total, match) => + total + (getEvidenceTopicScore(match.matchEvidence) ?? 0), + 0, + ) + .toFixed(3), + ); + + return { + matchedContent, + matchedContentCount, + strongMatchedContentCount, + partialMatchedContentCount, + transcriptMatchedContentCount, + creatorTextMatchedContentCount, + creatorTextOnlyContentCount, + weightedMatchedContentCount, + weightedMatchedContentScore, + }; +} diff --git a/apps/web/lib/partner-content-search/ingestion/chunk-counts.ts b/apps/web/lib/partner-content-search/ingestion/chunk-counts.ts new file mode 100644 index 00000000000..ea29d7fc9a1 --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/chunk-counts.ts @@ -0,0 +1,67 @@ +import { prisma } from "@/lib/prisma"; +import { Prisma } from "@prisma/client"; +import "server-only"; +import { PARTNER_CONTENT_SEARCH_MODELS } from "../constants"; + +type ChunkCounts = { + // COUNT(*) is BIGINT; SUM(...) is DECIMAL in MySQL. + totalChunkCount: bigint; + embeddedChunkCount: Prisma.Decimal | null; +}; + +export async function refreshPartnerContentItemChunkCounts( + partnerContentItemId: string, +) { + const [result] = await prisma.$queryRaw` + SELECT + COUNT(*) AS totalChunkCount, + SUM(CASE WHEN embedding IS NOT NULL THEN 1 ELSE 0 END) AS embeddedChunkCount + FROM PartnerContentChunk + WHERE partnerContentItemId = ${partnerContentItemId} + `; + + const totalChunkCount = Number(result?.totalChunkCount ?? 0); + const embeddedChunkCount = Number(result?.embeddedChunkCount ?? 0); + + await prisma.partnerContentItem.update({ + where: { + id: partnerContentItemId, + }, + data: { + totalChunkCount, + embeddedChunkCount, + embeddingModel: PARTNER_CONTENT_SEARCH_MODELS.embedding.id, + }, + }); + + return { + totalChunkCount, + embeddedChunkCount, + }; +} + +// Bulk variant: recompute + write chunk counts for many items in one round-trip +// (covers items reset to 0 via the correlated subqueries). +export async function refreshPartnerContentItemChunkCountsBulk( + partnerContentItemIds: string[], +) { + if (partnerContentItemIds.length === 0) return; + + await prisma.$executeRaw` + UPDATE PartnerContentItem pci + SET + pci.totalChunkCount = ( + SELECT COUNT(*) + FROM PartnerContentChunk c + WHERE c.partnerContentItemId = pci.id + ), + pci.embeddedChunkCount = ( + SELECT COUNT(*) + FROM PartnerContentChunk c + WHERE c.partnerContentItemId = pci.id + AND c.embedding IS NOT NULL + ), + pci.embeddingModel = ${PARTNER_CONTENT_SEARCH_MODELS.embedding.id} + WHERE pci.id IN (${Prisma.join(partnerContentItemIds)}) + `; +} diff --git a/apps/web/lib/partner-content-search/ingestion/eligibility-where.ts b/apps/web/lib/partner-content-search/ingestion/eligibility-where.ts new file mode 100644 index 00000000000..e2e9fa2db4e --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/eligibility-where.ts @@ -0,0 +1,75 @@ +import type { Prisma } from "@prisma/client"; +import { PartnerContentPlatform } from "../types"; +import type { PartnerContentIngestionMode } from "./payload-schemas"; +import { PARTNER_CONTENT_INCREMENTAL_REFRESH_DAYS } from "./routes"; + +export function getIncrementalRefreshCutoff() { + return new Date( + Date.now() - PARTNER_CONTENT_INCREMENTAL_REFRESH_DAYS * 24 * 60 * 60 * 1000, + ); +} + +// Shared platform-eligibility predicate (verified + incremental-recency rules), +// used by both the enumerate and enumerate/page routes. +export function buildEligiblePartnerPlatformWhere({ + mode, + platforms, +}: { + mode: PartnerContentIngestionMode; + platforms: PartnerContentPlatform[]; +}): Prisma.PartnerPlatformWhereInput { + return { + type: { + in: platforms, + }, + verifiedAt: { + not: null, + }, + ...(mode === "incremental" && { + OR: [ + { + contentLastFetchedAt: null, + }, + { + contentLastFetchedAt: { + lt: getIncrementalRefreshCutoff(), + }, + }, + ], + }), + }; +} + +// Partner-level eligibility for the enumerate route — composes +// buildEligiblePartnerPlatformWhere; gates on approved/trusted status. +export function buildEligiblePartnerWhere({ + mode, + filter, +}: { + mode: PartnerContentIngestionMode; + filter: { + partnerId?: string; + partnerIds?: string[]; + platforms: PartnerContentPlatform[]; + }; +}): Prisma.PartnerWhereInput { + return { + networkStatus: { + in: ["approved", "trusted"], + }, + ...(filter.partnerId && { + id: filter.partnerId, + }), + ...(filter.partnerIds?.length && { + id: { + in: filter.partnerIds, + }, + }), + platforms: { + some: buildEligiblePartnerPlatformWhere({ + mode, + platforms: filter.platforms, + }), + }, + }; +} diff --git a/apps/web/lib/partner-content-search/ingestion/enqueue.ts b/apps/web/lib/partner-content-search/ingestion/enqueue.ts new file mode 100644 index 00000000000..ac674a44ed3 --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/enqueue.ts @@ -0,0 +1,157 @@ +import { qstash } from "@/lib/cron"; +import { prisma } from "@/lib/prisma"; +import { logAndRespond } from "app/(ee)/api/cron/utils"; +import "server-only"; +import type { + PartnerContentEnumeratePayload, + PartnerContentIngestionMode, +} from "./payload-schemas"; +import { + PARTNER_CONTENT_EMBED_FLOW_CONTROL, + PARTNER_CONTENT_SEARCH_ROUTES, + createPartnerContentDeduplicationId, + getPartnerContentUrl, +} from "./routes"; + +// Re-export every symbol previously defined here so existing import paths stay +// valid with zero call-site changes. +export * from "./payload-schemas"; +export * from "./routes"; +export * from "./eligibility-where"; + +export async function enqueuePartnerContentEnumerate( + payload: PartnerContentEnumeratePayload, +) { + return await qstash.publishJSON({ + url: getPartnerContentUrl(PARTNER_CONTENT_SEARCH_ROUTES.enumerate), + method: "POST", + body: payload, + deduplicationId: createPartnerContentDeduplicationId( + "partner-content-enumerate", + payload.mode, + payload.runStamp, + ), + }); +} + +// Enqueue one embed job for an already-chunked item. Throws on publish failure so +// the caller fails the job and QStash retries (else chunks stay unsearchable). +export async function enqueueEmbedJob({ + mode, + runStamp, + partnerId, + partnerContentItemId, +}: { + mode: PartnerContentIngestionMode; + runStamp: string; + partnerId: string; + partnerContentItemId: string; +}) { + try { + await qstash.publishJSON({ + url: getPartnerContentUrl(PARTNER_CONTENT_SEARCH_ROUTES.embed), + method: "POST", + body: { + mode, + runStamp, + partnerId, + partnerContentItemId, + }, + flowControl: PARTNER_CONTENT_EMBED_FLOW_CONTROL, + deduplicationId: createPartnerContentDeduplicationId( + "partner-content-embed", + mode, + runStamp, + partnerContentItemId, + ), + }); + } catch (error) { + // Don't swallow: chunks are already committed, so a dropped embed leaves them + // unsearchable. Rethrow → withCron 500 → QStash retries; forceRefetch=false skips + // the re-fetch and the embed dedup id keeps it idempotent. + console.error("[PartnerContentSearch] Failed to enqueue embed job", { + error, + mode, + runStamp, + partnerId, + partnerContentItemId, + }); + + throw error; + } +} + +// Fan out one embed job per pending content item on a partner platform. +export async function enqueueEmbedJobsForPartnerPlatform({ + mode, + runStamp, + partnerId, + partnerPlatformId, + limitContentItems, + maxChunks, +}: { + mode: PartnerContentIngestionMode; + runStamp: string; + partnerId: string; + partnerPlatformId?: string; + limitContentItems: number; + maxChunks: number; +}) { + if (!partnerPlatformId) { + return logAndRespond( + `[PartnerContentSearch] Embed enqueue requires partnerPlatformId for ${mode} run ${runStamp}.`, + { status: 400, logLevel: "warn" }, + ); + } + + const contentItems = await prisma.partnerContentItem.findMany({ + where: { + partnerId, + partnerPlatformId, + totalChunkCount: { + gt: 0, + }, + }, + select: { + id: true, + totalChunkCount: true, + embeddedChunkCount: true, + }, + orderBy: { + id: "asc", + }, + take: limitContentItems, + }); + + const pendingContentItems = contentItems.filter( + ({ totalChunkCount, embeddedChunkCount }) => + embeddedChunkCount < totalChunkCount, + ); + + const messages = pendingContentItems.map((contentItem) => ({ + url: getPartnerContentUrl(PARTNER_CONTENT_SEARCH_ROUTES.embed), + method: "POST" as const, + flowControl: PARTNER_CONTENT_EMBED_FLOW_CONTROL, + body: { + mode, + runStamp, + partnerId, + partnerContentItemId: contentItem.id, + maxChunks, + }, + deduplicationId: createPartnerContentDeduplicationId( + "partner-content-embed", + mode, + runStamp, + contentItem.id, + ), + })); + + if (messages.length > 0) { + await qstash.batchJSON(messages); + } + + return logAndRespond( + `[PartnerContentSearch] Enqueued ${messages.length} embed jobs (${pendingContentItems.length} pending of ${contentItems.length} inspected) for partner platform ${partnerPlatformId} on ${mode} run ${runStamp}.`, + ); +} diff --git a/apps/web/lib/partner-content-search/ingestion/fetch-and-write-transcript-chunks.ts b/apps/web/lib/partner-content-search/ingestion/fetch-and-write-transcript-chunks.ts new file mode 100644 index 00000000000..abc56a0bafc --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/fetch-and-write-transcript-chunks.ts @@ -0,0 +1,118 @@ +import { createId } from "@/lib/api/create-id"; +import { + chunkTranscriptSegments, + hashTranscript, +} from "@/lib/partner-content-search/chunk-transcript"; +import { PARTNER_CONTENT_SEARCH_MODELS } from "@/lib/partner-content-search/constants"; +import { refreshPartnerContentItemChunkCounts } from "@/lib/partner-content-search/ingestion/chunk-counts"; +import { fetchPlatformTranscriptSegments } from "@/lib/partner-content-search/ingestion/platforms"; +import type { PartnerContentPlatform } from "@/lib/partner-content-search/types"; +import { prisma } from "@/lib/prisma"; + +// Fetch a content item's transcript, (re)chunk it, and replace its transcript chunks +// in one transaction. Returns a summary; marks notAvailable when there's no transcript. +export async function fetchAndWriteTranscriptChunks(contentItem: { + id: string; + partnerId: string; + url: string; + platform: PartnerContentPlatform; +}) { + const transcriptSegments = await fetchPlatformTranscriptSegments({ + platform: contentItem.platform, + url: contentItem.url, + }); + const normalizedTranscript = transcriptSegments + .map(({ text }) => text.trim()) + .filter(Boolean) + .join(" "); + const transcriptHash = + transcriptSegments.length > 0 ? hashTranscript(transcriptSegments) : null; + + if (!transcriptHash) { + await prisma.partnerContentItem.update({ + where: { + id: contentItem.id, + }, + data: { + transcriptFetchStatus: "notAvailable", + transcriptLastAttemptedAt: new Date(), + normalizedTranscript: null, + transcriptHash: null, + transcriptHasTimestamps: false, + }, + }); + + await prisma.partnerContentChunk.deleteMany({ + where: { + partnerContentItemId: contentItem.id, + source: "transcript", + }, + }); + + await refreshPartnerContentItemChunkCounts(contentItem.id); + + return { + transcriptAvailable: false, + segmentCount: 0, + normalizedTranscriptLength: 0, + transcriptHash: null, + chunkCount: 0, + chunksCreated: 0, + }; + } + + const chunks = chunkTranscriptSegments(transcriptSegments); + const transcriptHasTimestamps = transcriptSegments.some( + ({ startMs, endMs }) => startMs !== null || endMs !== null, + ); + const embeddingModel = PARTNER_CONTENT_SEARCH_MODELS.embedding.id; + + const [, deletedChunks, createChunksResult] = await prisma.$transaction([ + prisma.partnerContentItem.update({ + where: { + id: contentItem.id, + }, + data: { + transcriptFetchStatus: "fetched", + transcriptLastAttemptedAt: new Date(), + normalizedTranscript, + transcriptHash, + transcriptHasTimestamps, + embeddingModel, + lastFetchedAt: new Date(), + }, + }), + prisma.partnerContentChunk.deleteMany({ + where: { + partnerContentItemId: contentItem.id, + source: "transcript", + }, + }), + prisma.partnerContentChunk.createMany({ + data: chunks.map((chunk) => ({ + id: createId({ prefix: "pcc_" }), + partnerContentItemId: contentItem.id, + partnerId: contentItem.partnerId, + source: "transcript", + chunkIndex: chunk.chunkIndex, + chunkText: chunk.text, + startMs: chunk.startMs, + endMs: chunk.endMs, + textHash: transcriptHash, + embeddingModel, + })), + }), + ]); + + await refreshPartnerContentItemChunkCounts(contentItem.id); + + return { + transcriptAvailable: true, + segmentCount: transcriptSegments.length, + normalizedTranscriptLength: normalizedTranscript.length, + transcriptHash, + chunkCount: chunks.length, + chunksDeleted: deletedChunks.count, + chunksCreated: createChunksResult.count, + }; +} diff --git a/apps/web/lib/partner-content-search/ingestion/normalize-content.ts b/apps/web/lib/partner-content-search/ingestion/normalize-content.ts new file mode 100644 index 00000000000..a0df9816ab7 --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/normalize-content.ts @@ -0,0 +1,331 @@ +import { + instagramMediaTranscriptSchema, + instagramUserPostsSchema, + tiktokProfileVideosSchema, + youtubeChannelVideosSchema, +} from "@/lib/api/scrape-creators/schema"; +import { TranscriptSegment } from "@/lib/partner-content-search/types"; +import * as z from "zod/v4"; + +type YouTubeChannelVideo = z.infer< + typeof youtubeChannelVideosSchema +>["videos"][number]; +type TikTokProfileVideo = z.infer< + typeof tiktokProfileVideosSchema +>["aweme_list"][number]; +type InstagramUserPost = z.infer< + typeof instagramUserPostsSchema +>["items"][number]; +type InstagramMediaTranscript = z.infer; + +export type NormalizedPartnerContentItem = { + platformContentId: string; + url: string; + contentType: string; + title: string | null; + description: string | null; + thumbnailUrl: string | null; + publishedAt: Date | null; + durationMs: number | null; + viewCount: number | null; + likeCount: number | null; + commentCount: number | null; + shareCount: number | null; + saveCount: number | null; + transcriptEligible?: boolean; +}; + +export function normalizeYouTubeChannelVideo( + video: YouTubeChannelVideo, +): NormalizedPartnerContentItem | null { + if (!video.id || !video.url) return null; + + return { + platformContentId: video.id, + url: video.url, + contentType: video.type || "video", + title: video.title ?? null, + description: video.description ?? null, + thumbnailUrl: video.thumbnail ?? null, + publishedAt: parseDate(video.publishedTime), + durationMs: + typeof video.lengthSeconds === "number" + ? Math.max(0, video.lengthSeconds * 1000) + : null, + viewCount: + typeof video.viewCountInt === "number" + ? Math.max(0, video.viewCountInt) + : null, + likeCount: normalizeMetricCount(video.likeCountInt), + commentCount: normalizeMetricCount(video.commentCountInt), + shareCount: null, + saveCount: null, + }; +} + +export function normalizeYouTubeTranscriptSegments( + transcript: Array<{ text: string; startMs: string; endMs: string }> | null, +): TranscriptSegment[] { + return (transcript ?? []) + .map((segment) => ({ + text: segment.text, + startMs: parseMs(segment.startMs), + endMs: parseMs(segment.endMs), + })) + .filter((segment) => segment.text.trim().length > 0); +} + +export function normalizeTikTokProfileVideo( + video: TikTokProfileVideo, + handle: string, +): NormalizedPartnerContentItem | null { + const platformContentId = video.aweme_id ?? video.id_str; + if (!platformContentId) return null; + + const url = + video.url ?? `https://www.tiktok.com/@${handle}/video/${platformContentId}`; + const durationMs = normalizeTikTokDurationMs( + video.video?.duration ?? video.duration, + ); + + return { + platformContentId, + url, + contentType: "video", + title: video.desc ?? null, + description: video.desc ?? null, + thumbnailUrl: + firstUrl(video.video?.dynamic_cover?.url_list) ?? + firstUrl(video.video?.cover?.url_list), + publishedAt: + parseDate(video.create_time_utc) ?? + (typeof video.create_time === "number" + ? new Date(video.create_time * 1000) + : null), + durationMs, + viewCount: + typeof video.statistics?.play_count === "number" + ? Math.max(0, video.statistics.play_count) + : null, + likeCount: normalizeMetricCount(video.statistics?.digg_count), + commentCount: normalizeMetricCount(video.statistics?.comment_count), + shareCount: normalizeMetricCount(video.statistics?.share_count), + saveCount: normalizeMetricCount(video.statistics?.collect_count), + transcriptEligible: durationMs === null || durationMs <= 120_000, + }; +} + +export function normalizeTikTokTranscriptSegments( + transcript: string | null | undefined, +): TranscriptSegment[] { + return parseWebVttTranscript(transcript ?? ""); +} + +export function normalizeInstagramUserPost( + post: InstagramUserPost, +): NormalizedPartnerContentItem | null { + const shortcode = post.code ?? extractInstagramShortcode(post.url); + const platformContentId = shortcode ?? post.pk ?? post.id; + if (!platformContentId) return null; + + const contentType = getInstagramContentType(post); + const url = getInstagramContentUrl({ + contentType, + fallbackUrl: post.url, + shortcode, + }); + if (!url) return null; + + const caption = post.caption?.text?.trim() || null; + const durationMs = + typeof post.video_duration === "number" + ? Math.max(0, Math.round(post.video_duration * 1000)) + : null; + + return { + platformContentId, + url, + contentType, + title: normalizeCaptionTitle(caption), + description: caption, + thumbnailUrl: + post.display_uri ?? firstUrl(post.image_versions2?.candidates), + publishedAt: + typeof post.taken_at === "number" ? new Date(post.taken_at * 1000) : null, + durationMs, + viewCount: + typeof post.play_count === "number" + ? Math.max(0, post.play_count) + : typeof post.ig_play_count === "number" + ? Math.max(0, post.ig_play_count) + : null, + likeCount: normalizeMetricCount(post.like_count), + commentCount: normalizeMetricCount(post.comment_count), + shareCount: null, + saveCount: null, + transcriptEligible: + isInstagramVideoContent(post) && + (durationMs === null || durationMs <= 120_000), + }; +} + +export function normalizeInstagramTranscriptSegments( + transcriptResponse: InstagramMediaTranscript, +): TranscriptSegment[] { + return (transcriptResponse.transcripts ?? []) + .map(({ text }) => ({ + text: text ?? "", + startMs: null, + endMs: null, + })) + .filter((segment) => segment.text.trim().length > 0); +} + +function parseDate(value?: string | null) { + if (!value) return null; + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function parseMs(value: string) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizeTikTokDurationMs(value?: number | null) { + if (typeof value !== "number") return null; + const duration = Math.max(0, value); + + return duration > 0 && duration < 1000 ? duration * 1000 : duration; +} + +function normalizeMetricCount(value?: number | null) { + return typeof value === "number" ? Math.max(0, value) : null; +} + +function firstUrl(values?: Array<{ url?: string | null } | string> | null) { + const first = values?.[0]; + if (!first) return null; + + return typeof first === "string" ? first : first.url ?? null; +} + +function getInstagramContentType(post: InstagramUserPost) { + if (post.product_type === "clips" || post.url?.includes("/reel/")) { + return "reel"; + } + + switch (post.media_type) { + case 1: + return "image"; + case 2: + return "video"; + case 8: + return "carousel"; + default: + return "post"; + } +} + +function isInstagramVideoContent(post: InstagramUserPost) { + return ( + post.product_type === "clips" || + post.media_type === 2 || + Boolean(post.video_versions?.length) + ); +} + +function extractInstagramShortcode(url?: string | null) { + if (!url) return null; + + try { + const pathname = new URL(url).pathname; + const shortcode = pathname.match( + /^\/(?:(?:[^/]+)\/)?(?:p|reel|tv)\/([^/?#]+)/, + )?.[1]; + return shortcode || null; + } catch { + const shortcode = url.match( + /instagram\.com\/(?:(?:[^/]+)\/)?(?:p|reel|tv)\/([^/?#]+)/, + )?.[1]; + return shortcode || null; + } +} + +function getInstagramContentUrl({ + contentType, + fallbackUrl, + shortcode, +}: { + contentType: string; + fallbackUrl?: string | null; + shortcode?: string | null; +}) { + if (shortcode) { + return `https://www.instagram.com/${contentType === "reel" ? "reel" : "p"}/${shortcode}/`; + } + + return fallbackUrl ?? null; +} + +function normalizeCaptionTitle(caption: string | null) { + if (!caption) return null; + + const firstLine = caption.split(/\r?\n/)[0]?.trim(); + if (!firstLine) return null; + + return firstLine.length > 140 + ? `${firstLine.slice(0, 137).trimEnd()}...` + : firstLine; +} + +function parseWebVttTranscript(transcript: string): TranscriptSegment[] { + const lines = transcript.replace(/\r\n/g, "\n").split("\n"); + const segments: TranscriptSegment[] = []; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index].trim(); + if (!line.includes("-->")) continue; + + const [start, rest] = line.split("-->"); + const end = rest?.trim().split(/\s+/)[0]; + const startMs = parseWebVttTimestamp(start.trim()); + const endMs = end ? parseWebVttTimestamp(end) : null; + const textLines: string[] = []; + + index++; + while (index < lines.length && lines[index].trim().length > 0) { + textLines.push(lines[index].trim()); + index++; + } + + const text = textLines.join(" ").trim(); + if (text) { + segments.push({ + text, + startMs, + endMs, + }); + } + } + + return segments; +} + +function parseWebVttTimestamp(value: string) { + const parts = value.split(":"); + if (parts.length < 2 || parts.length > 3) return null; + + const secondsPart = parts.pop(); + const minutesPart = parts.pop(); + const hoursPart = parts.pop(); + + const seconds = Number.parseFloat(secondsPart ?? ""); + const minutes = Number.parseInt(minutesPart ?? "", 10); + const hours = hoursPart ? Number.parseInt(hoursPart, 10) : 0; + + if (![seconds, minutes, hours].every(Number.isFinite)) return null; + + return Math.round(((hours * 60 + minutes) * 60 + seconds) * 1000); +} diff --git a/apps/web/lib/partner-content-search/ingestion/payload-schemas.ts b/apps/web/lib/partner-content-search/ingestion/payload-schemas.ts new file mode 100644 index 00000000000..ec3b0e35751 --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/payload-schemas.ts @@ -0,0 +1,114 @@ +import * as z from "zod/v4"; +import { logAndRespond } from "app/(ee)/api/cron/utils"; +import { PARTNER_CONTENT_SEARCH_PLATFORMS } from "../types"; +import { PARTNER_CONTENT_ENUMERATE_PAGE_SIZE } from "./routes"; + +export const partnerContentIngestionModeSchema = z.enum([ + "incremental", + "backfill", +]); + +export type PartnerContentIngestionMode = z.infer< + typeof partnerContentIngestionModeSchema +>; + +export const partnerContentIngestionFilterSchema = z + .object({ + partnerId: z.string().optional(), + partnerIds: z.array(z.string()).max(500).optional(), + platforms: z + .array(z.enum(PARTNER_CONTENT_SEARCH_PLATFORMS)) + .min(1) + .default([...PARTNER_CONTENT_SEARCH_PLATFORMS]), + limitPartners: z.number().int().positive().max(100_000).optional(), + }) + .prefault({}) + .refine((filter) => !(filter.partnerId && filter.partnerIds?.length), { + message: "Use either partnerId or partnerIds, not both.", + }); + +export const partnerContentEnumeratePayloadSchema = z.object({ + mode: partnerContentIngestionModeSchema, + filter: partnerContentIngestionFilterSchema, + runStamp: z.string().min(1), + dryRun: z.boolean().default(false), + // Cursor for self-continued enumeration; absent on the initial dispatch. + startingAfter: z.string().optional(), + // Partner budget carried across continuation hops; omitted = unbounded. + remainingPartners: z.number().int().nonnegative().optional(), +}); + +export const partnerContentEnumeratePagePayloadSchema = z.object({ + mode: partnerContentIngestionModeSchema, + filter: partnerContentIngestionFilterSchema, + runStamp: z.string().min(1), + dryRun: z.boolean().default(false), + partnerIds: z + .array(z.string()) + .min(1) + .max(PARTNER_CONTENT_ENUMERATE_PAGE_SIZE), +}); + +export const partnerContentFetchPayloadSchema = z.object({ + mode: partnerContentIngestionModeSchema, + runStamp: z.string().min(1), + dryRun: z.boolean().default(false), + forceTranscriptJobs: z.boolean().default(false), + // Escape hatch for linked-but-unverified platforms; backfills stay verified-only. + ignoreUnverified: z.boolean().default(false), + partnerId: z.string().min(1), + partnerPlatformId: z.string().min(1), + platform: z.enum(PARTNER_CONTENT_SEARCH_PLATFORMS), +}); + +export const partnerContentTranscriptPayloadSchema = z.object({ + mode: partnerContentIngestionModeSchema, + runStamp: z.string().min(1), + dryRun: z.boolean().default(false), + // Re-fetch even if already fetched. Off by default so QStash redelivery doesn't + // re-burn ScrapeCreators credits. + forceRefetch: z.boolean().default(false), + partnerId: z.string().min(1), + partnerPlatformId: z.string().min(1), + partnerContentItemId: z.string().min(1), + platform: z.enum(PARTNER_CONTENT_SEARCH_PLATFORMS), +}); + +export const partnerContentEmbedPayloadSchema = z + .object({ + mode: partnerContentIngestionModeSchema, + runStamp: z.string().min(1), + partnerId: z.string().min(1), + partnerPlatformId: z.string().min(1).optional(), + partnerContentItemId: z.string().min(1).optional(), + limitContentItems: z.number().int().positive().max(500).default(100), + maxChunks: z.number().int().positive().max(128).default(96), + }) + .refine( + ({ partnerPlatformId, partnerContentItemId }) => + Boolean(partnerPlatformId) !== Boolean(partnerContentItemId), + { + message: "Use exactly one of partnerPlatformId or partnerContentItemId.", + }, + ); + +export type PartnerContentEnumeratePayload = z.infer< + typeof partnerContentEnumeratePayloadSchema +>; + +// Parse a cron payload. A malformed body is permanent, so respond 400 (not a +// withCron 500 + alert). Returns the payload, or a Response to return directly. +export function parsePartnerContentCronPayload( + schema: T, + rawBody: string, +): z.infer | Response { + try { + return schema.parse(JSON.parse(rawBody)) as z.infer; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return logAndRespond( + `[PartnerContentSearch] Ignoring invalid cron payload: ${message}`, + { status: 400, logLevel: "warn" }, + ); + } +} diff --git a/apps/web/lib/partner-content-search/ingestion/platforms/index.ts b/apps/web/lib/partner-content-search/ingestion/platforms/index.ts new file mode 100644 index 00000000000..a0cb2d46663 --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/platforms/index.ts @@ -0,0 +1,38 @@ +import "server-only"; + +import type { PartnerContentPlatform } from "@/lib/partner-content-search/types"; +import { instagramPartnerContentService } from "./instagram"; +import type { PartnerContentPlatformServiceRegistry } from "./types"; +import { tiktokPartnerContentService } from "./tiktok"; +import { youtubePartnerContentService } from "./youtube"; + +export const partnerContentPlatformServices = { + youtube: youtubePartnerContentService, + tiktok: tiktokPartnerContentService, + instagram: instagramPartnerContentService, +} satisfies PartnerContentPlatformServiceRegistry; + +export function fetchRecentPlatformContent({ + platform, + platformId, + identifier, +}: { + platform: PartnerContentPlatform; + platformId?: string; + identifier: string; +}) { + return partnerContentPlatformServices[platform].fetchRecentContent({ + platformId, + identifier, + }); +} + +export function fetchPlatformTranscriptSegments({ + platform, + url, +}: { + platform: PartnerContentPlatform; + url: string; +}) { + return partnerContentPlatformServices[platform].fetchTranscript({ url }); +} diff --git a/apps/web/lib/partner-content-search/ingestion/platforms/instagram.ts b/apps/web/lib/partner-content-search/ingestion/platforms/instagram.ts new file mode 100644 index 00000000000..1e93530f111 --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/platforms/instagram.ts @@ -0,0 +1,66 @@ +import "server-only"; + +import { getInstagramMediaTranscript } from "@/lib/api/scrape-creators/get-instagram-media-transcript"; +import { getInstagramUserPosts } from "@/lib/api/scrape-creators/get-instagram-user-posts"; +import { PARTNER_CONTENT_SEARCH_LIMITS } from "@/lib/partner-content-search/constants"; +import { + type NormalizedPartnerContentItem, + normalizeInstagramTranscriptSegments, + normalizeInstagramUserPost, +} from "@/lib/partner-content-search/ingestion/normalize-content"; +import type { PartnerContentPlatformService } from "./types"; +import { + getOldestPublishedAt, + getRecencyCutoff, + MAX_CONTENT_PAGES, + normalizeSocialHandle, +} from "./utils"; + +export const instagramPartnerContentService: PartnerContentPlatformService = { + async fetchRecentContent({ identifier }) { + const maxItems = + PARTNER_CONTENT_SEARCH_LIMITS.contentItemsPerPartnerPlatform; + const recencyCutoff = getRecencyCutoff(); + const contentItems: NormalizedPartnerContentItem[] = []; + const handle = normalizeSocialHandle(identifier); + let nextMaxId: string | undefined; + let page = 0; + + while (contentItems.length < maxItems && page < MAX_CONTENT_PAGES) { + const response = await getInstagramUserPosts({ + handle, + nextMaxId, + }); + + const normalizedPosts = response.items + .map(normalizeInstagramUserPost) + .filter((item): item is NormalizedPartnerContentItem => item !== null); + + const recentPosts = normalizedPosts.filter( + ({ publishedAt }) => !publishedAt || publishedAt >= recencyCutoff, + ); + + contentItems.push(...recentPosts); + + const oldestPublishedAt = getOldestPublishedAt(normalizedPosts); + + if ( + !response.next_max_id || + response.more_available === false || + (oldestPublishedAt && oldestPublishedAt < recencyCutoff) + ) { + break; + } + + nextMaxId = response.next_max_id ?? undefined; + page++; + } + + return contentItems.slice(0, maxItems); + }, + + async fetchTranscript({ url }) { + const transcriptResponse = await getInstagramMediaTranscript({ url }); + return normalizeInstagramTranscriptSegments(transcriptResponse); + }, +}; diff --git a/apps/web/lib/partner-content-search/ingestion/platforms/tiktok.ts b/apps/web/lib/partner-content-search/ingestion/platforms/tiktok.ts new file mode 100644 index 00000000000..bacec3cc54f --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/platforms/tiktok.ts @@ -0,0 +1,67 @@ +import "server-only"; + +import { getTikTokProfileVideos } from "@/lib/api/scrape-creators/get-tiktok-profile-videos"; +import { getTikTokVideoTranscript } from "@/lib/api/scrape-creators/get-tiktok-video-transcript"; +import { PARTNER_CONTENT_SEARCH_LIMITS } from "@/lib/partner-content-search/constants"; +import { + type NormalizedPartnerContentItem, + normalizeTikTokProfileVideo, + normalizeTikTokTranscriptSegments, +} from "@/lib/partner-content-search/ingestion/normalize-content"; +import type { PartnerContentPlatformService } from "./types"; +import { + getOldestPublishedAt, + getRecencyCutoff, + MAX_CONTENT_PAGES, + normalizeSocialHandle, +} from "./utils"; + +export const tiktokPartnerContentService: PartnerContentPlatformService = { + async fetchRecentContent({ platformId: userId, identifier }) { + const maxItems = + PARTNER_CONTENT_SEARCH_LIMITS.contentItemsPerPartnerPlatform; + const recencyCutoff = getRecencyCutoff(); + const contentItems: NormalizedPartnerContentItem[] = []; + const handle = normalizeSocialHandle(identifier); + let maxCursor: string | undefined; + let page = 0; + + while (contentItems.length < maxItems && page < MAX_CONTENT_PAGES) { + const response = await getTikTokProfileVideos({ + handle, + userId, + maxCursor, + }); + + const normalizedVideos = response.aweme_list + .map((video) => normalizeTikTokProfileVideo(video, handle)) + .filter((item): item is NormalizedPartnerContentItem => item !== null); + + const recentVideos = normalizedVideos.filter( + ({ publishedAt }) => !publishedAt || publishedAt >= recencyCutoff, + ); + + contentItems.push(...recentVideos); + + const oldestPublishedAt = getOldestPublishedAt(normalizedVideos); + + if ( + !response.max_cursor || + response.has_more === false || + (oldestPublishedAt && oldestPublishedAt < recencyCutoff) + ) { + break; + } + + maxCursor = response.max_cursor ?? undefined; + page++; + } + + return contentItems.slice(0, maxItems); + }, + + async fetchTranscript({ url }) { + const transcriptResponse = await getTikTokVideoTranscript({ url }); + return normalizeTikTokTranscriptSegments(transcriptResponse.transcript); + }, +}; diff --git a/apps/web/lib/partner-content-search/ingestion/platforms/types.ts b/apps/web/lib/partner-content-search/ingestion/platforms/types.ts new file mode 100644 index 00000000000..4abc37d67a3 --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/platforms/types.ts @@ -0,0 +1,28 @@ +import type { NormalizedPartnerContentItem } from "@/lib/partner-content-search/ingestion/normalize-content"; +import type { + PartnerContentPlatform, + TranscriptSegment, +} from "@/lib/partner-content-search/types"; + +export type FetchRecentContentInput = { + platformId?: string; + identifier: string; +}; + +export type FetchTranscriptInput = { + url: string; +}; + +export type PartnerContentPlatformService = { + fetchRecentContent: ( + input: FetchRecentContentInput, + ) => Promise; + fetchTranscript: ( + input: FetchTranscriptInput, + ) => Promise; +}; + +export type PartnerContentPlatformServiceRegistry = Record< + PartnerContentPlatform, + PartnerContentPlatformService +>; diff --git a/apps/web/lib/partner-content-search/ingestion/platforms/utils.ts b/apps/web/lib/partner-content-search/ingestion/platforms/utils.ts new file mode 100644 index 00000000000..aeab892dc1c --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/platforms/utils.ts @@ -0,0 +1,25 @@ +import { PARTNER_CONTENT_SEARCH_LIMITS } from "@/lib/partner-content-search/constants"; +import type { NormalizedPartnerContentItem } from "@/lib/partner-content-search/ingestion/normalize-content"; + +export const MAX_CONTENT_PAGES = 3; + +export function normalizeSocialHandle(handle: string) { + return handle.replace(/^@/, ""); +} + +export function getOldestPublishedAt( + contentItems: NormalizedPartnerContentItem[], +) { + return contentItems + .map(({ publishedAt }) => publishedAt) + .filter((date): date is Date => date !== null) + .sort((a, b) => a.getTime() - b.getTime())[0]; +} + +export function getRecencyCutoff() { + const cutoff = new Date(); + cutoff.setMonth( + cutoff.getMonth() - PARTNER_CONTENT_SEARCH_LIMITS.recencyWindowMonths, + ); + return cutoff; +} diff --git a/apps/web/lib/partner-content-search/ingestion/platforms/youtube.ts b/apps/web/lib/partner-content-search/ingestion/platforms/youtube.ts new file mode 100644 index 00000000000..705e94ccf06 --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/platforms/youtube.ts @@ -0,0 +1,66 @@ +import "server-only"; + +import { getYouTubeChannelVideos } from "@/lib/api/scrape-creators/get-youtube-channel-videos"; +import { getYouTubeVideoTranscript } from "@/lib/api/scrape-creators/get-youtube-video-transcript"; +import { PARTNER_CONTENT_SEARCH_LIMITS } from "@/lib/partner-content-search/constants"; +import { + type NormalizedPartnerContentItem, + normalizeYouTubeChannelVideo, + normalizeYouTubeTranscriptSegments, +} from "@/lib/partner-content-search/ingestion/normalize-content"; +import type { PartnerContentPlatformService } from "./types"; +import { + getOldestPublishedAt, + getRecencyCutoff, + MAX_CONTENT_PAGES, + normalizeSocialHandle, +} from "./utils"; + +export const youtubePartnerContentService: PartnerContentPlatformService = { + async fetchRecentContent({ platformId: channelId, identifier }) { + const maxItems = + PARTNER_CONTENT_SEARCH_LIMITS.contentItemsPerPartnerPlatform; + const recencyCutoff = getRecencyCutoff(); + const contentItems: NormalizedPartnerContentItem[] = []; + const handle = channelId ? undefined : normalizeSocialHandle(identifier); + let continuationToken: string | undefined; + let page = 0; + + while (contentItems.length < maxItems && page < MAX_CONTENT_PAGES) { + const response = await getYouTubeChannelVideos({ + ...(channelId ? { channelId } : { handle: handle! }), + continuationToken, + includeExtras: false, + }); + + const normalizedVideos = response.videos + .map(normalizeYouTubeChannelVideo) + .filter((item): item is NormalizedPartnerContentItem => item !== null); + + const recentVideos = normalizedVideos.filter( + ({ publishedAt }) => !publishedAt || publishedAt >= recencyCutoff, + ); + + contentItems.push(...recentVideos); + + const oldestPublishedAt = getOldestPublishedAt(normalizedVideos); + + if ( + !response.continuationToken || + (oldestPublishedAt && oldestPublishedAt < recencyCutoff) + ) { + break; + } + + continuationToken = response.continuationToken ?? undefined; + page++; + } + + return contentItems.slice(0, maxItems); + }, + + async fetchTranscript({ url }) { + const transcriptResponse = await getYouTubeVideoTranscript({ url }); + return normalizeYouTubeTranscriptSegments(transcriptResponse.transcript); + }, +}; diff --git a/apps/web/lib/partner-content-search/ingestion/routes.ts b/apps/web/lib/partner-content-search/ingestion/routes.ts new file mode 100644 index 00000000000..7e0db5f2854 --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/routes.ts @@ -0,0 +1,45 @@ +import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; + +// Partners per enumerate page (default 500). Override via env to exercise the +// self-continuation chain locally without seeding 500+ partners. +export const PARTNER_CONTENT_ENUMERATE_PAGE_SIZE = (() => { + const raw = process.env.PARTNER_CONTENT_ENUMERATE_PAGE_SIZE; + const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; + return Number.isInteger(parsed) && parsed > 0 ? parsed : 500; +})(); +export const PARTNER_CONTENT_INCREMENTAL_REFRESH_DAYS = 7; + +export const PARTNER_CONTENT_EMBED_FLOW_CONTROL = { + key: "partner-content-embed-voyage", + parallelism: 20, + rate: 600, + period: "1m", +} as const; + +export const PARTNER_CONTENT_SEARCH_ROUTES = { + enumerate: "/api/cron/partner-content/enumerate", + enumeratePage: "/api/cron/partner-content/enumerate/page", + fetch: "/api/cron/partner-content/fetch", + transcript: "/api/cron/partner-content/transcript", + embed: "/api/cron/partner-content/embed", +} as const; + +export type PartnerContentSearchRoute = + (typeof PARTNER_CONTENT_SEARCH_ROUTES)[keyof typeof PARTNER_CONTENT_SEARCH_ROUTES]; + +export function createPartnerContentRunStamp() { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +export function getPartnerContentUrl(route: PartnerContentSearchRoute) { + return `${APP_DOMAIN_WITH_NGROK}${route}`; +} + +export function createPartnerContentDeduplicationId( + ...parts: Array +) { + return parts + .filter((part): part is string | number => part !== undefined) + .map((part) => String(part).replace(/[^a-zA-Z0-9_-]/g, "-")) + .join("-"); +} diff --git a/apps/web/lib/partner-content-search/ingestion/write-fetched-content-items.ts b/apps/web/lib/partner-content-search/ingestion/write-fetched-content-items.ts new file mode 100644 index 00000000000..b9accc57fd3 --- /dev/null +++ b/apps/web/lib/partner-content-search/ingestion/write-fetched-content-items.ts @@ -0,0 +1,373 @@ +import "server-only"; + +import { createId } from "@/lib/api/create-id"; +import { logger } from "@/lib/axiom/server"; +import { qstash } from "@/lib/cron"; +import { hashText } from "@/lib/partner-content-search/chunk-transcript"; +import { PARTNER_CONTENT_SEARCH_MODELS } from "@/lib/partner-content-search/constants"; +import { refreshPartnerContentItemChunkCountsBulk } from "@/lib/partner-content-search/ingestion/chunk-counts"; +import { + createPartnerContentDeduplicationId, + getPartnerContentUrl, + PARTNER_CONTENT_EMBED_FLOW_CONTROL, + PARTNER_CONTENT_SEARCH_ROUTES, + type PartnerContentIngestionMode, +} from "@/lib/partner-content-search/ingestion/enqueue"; +import type { NormalizedPartnerContentItem } from "@/lib/partner-content-search/ingestion/normalize-content"; +import type { PartnerContentPlatform } from "@/lib/partner-content-search/types"; +import { prisma } from "@/lib/prisma"; +import type { Prisma } from "@prisma/client"; + +export function isTranscriptEligibleContentItem( + item: NormalizedPartnerContentItem, +) { + return item.transcriptEligible !== false; +} + +export async function writeFetchedContentItems({ + mode, + runStamp, + dryRun, + partnerId, + partnerPlatformId, + platform, + contentItems, + metadataSourceItems, + contentItemsForTranscriptJobs, + latestContentUrl, +}: { + mode: PartnerContentIngestionMode; + runStamp: string; + dryRun: boolean; + partnerId: string; + partnerPlatformId: string; + platform: PartnerContentPlatform; + contentItems: NormalizedPartnerContentItem[]; + metadataSourceItems: NormalizedPartnerContentItem[]; + contentItemsForTranscriptJobs: NormalizedPartnerContentItem[]; + latestContentUrl: string | null; +}) { + const dedupedMetadataSourceItems = dedupeContentItems(metadataSourceItems); + const metadataPlatformContentIds = dedupedMetadataSourceItems.map( + ({ platformContentId }) => platformContentId, + ); + const transcriptJobPlatformContentIds = contentItemsForTranscriptJobs.map( + ({ platformContentId }) => platformContentId, + ); + + const [createResult] = await prisma.$transaction([ + prisma.partnerContentItem.createMany({ + data: contentItems.map((item) => ({ + id: createId({ prefix: "pci_" }), + partnerId, + partnerPlatformId, + platformContentId: item.platformContentId, + url: item.url, + contentType: item.contentType, + title: item.title, + description: item.description, + thumbnailUrl: item.thumbnailUrl, + publishedAt: item.publishedAt, + durationMs: item.durationMs, + viewCount: toNullableBigInt(item.viewCount), + likeCount: toNullableBigInt(item.likeCount), + commentCount: toNullableBigInt(item.commentCount), + shareCount: toNullableBigInt(item.shareCount), + saveCount: toNullableBigInt(item.saveCount), + transcriptFetchStatus: "pending", + transcriptHasTimestamps: false, + totalChunkCount: 0, + embeddedChunkCount: 0, + })), + skipDuplicates: true, + }), + prisma.partnerPlatform.update({ + where: { + id: partnerPlatformId, + }, + data: { + contentLastFetchedAt: new Date(), + ...(latestContentUrl && { + latestContentUrl, + }), + }, + }), + ]); + + const transcriptContentItems = + transcriptJobPlatformContentIds.length === 0 + ? [] + : await prisma.partnerContentItem.findMany({ + where: { + partnerPlatformId, + platformContentId: { + in: transcriptJobPlatformContentIds, + }, + }, + select: { + id: true, + partnerId: true, + partnerPlatformId: true, + platformContentId: true, + }, + }); + + const metadataContentItems = + metadataPlatformContentIds.length === 0 + ? [] + : await prisma.partnerContentItem.findMany({ + where: { + partnerPlatformId, + platformContentId: { + in: metadataPlatformContentIds, + }, + }, + select: { + id: true, + partnerId: true, + platformContentId: true, + chunks: { + where: { + source: "metadata", + }, + select: { + textHash: true, + }, + take: 1, + }, + }, + }); + + const metadataChunkCount = await writeMetadataChunks({ + contentItems: metadataContentItems, + sourceItems: dedupedMetadataSourceItems, + }); + + const transcriptMessages = transcriptContentItems.map((contentItem) => ({ + url: getPartnerContentUrl(PARTNER_CONTENT_SEARCH_ROUTES.transcript), + method: "POST" as const, + deduplicationId: createPartnerContentDeduplicationId( + "partner-content-transcript", + mode, + runStamp, + contentItem.id, + ), + body: { + mode, + runStamp, + dryRun, + partnerId: contentItem.partnerId, + partnerPlatformId: contentItem.partnerPlatformId, + partnerContentItemId: contentItem.id, + platform, + }, + })); + + await dispatchContentJobs({ + messages: transcriptMessages, + kind: "transcript", + mode, + runStamp, + partnerPlatformId, + }); + + const metadataEmbedMessages = metadataContentItems + .filter(({ platformContentId }) => + metadataChunkCount.createdPlatformContentIds.has(platformContentId), + ) + .map((contentItem) => ({ + url: getPartnerContentUrl(PARTNER_CONTENT_SEARCH_ROUTES.embed), + method: "POST" as const, + flowControl: PARTNER_CONTENT_EMBED_FLOW_CONTROL, + deduplicationId: createPartnerContentDeduplicationId( + "partner-content-embed-metadata", + mode, + runStamp, + contentItem.id, + ), + body: { + mode, + runStamp, + dryRun, + partnerId: contentItem.partnerId, + partnerContentItemId: contentItem.id, + }, + })); + + await dispatchContentJobs({ + messages: metadataEmbedMessages, + kind: "metadata-embed", + mode, + runStamp, + partnerPlatformId, + }); + + return { + contentItemsCreated: createResult.count, + transcriptJobCount: transcriptMessages.length, + embedJobCount: metadataEmbedMessages.length, + }; +} + +// Post-commit dispatch: items are already written, so a dropped batch strands +// transcript/embed jobs. Log + rethrow (withCron 500); dedup ids keep retry safe. +async function dispatchContentJobs< + T extends Parameters[0], +>({ + messages, + kind, + mode, + runStamp, + partnerPlatformId, +}: { + messages: T; + kind: "transcript" | "metadata-embed"; + mode: PartnerContentIngestionMode; + runStamp: string; + partnerPlatformId: string; +}) { + if (messages.length === 0) return; + + try { + await qstash.batchJSON(messages); + } catch (error) { + logger.error("partner-content.batchJSON.failed", { + error, + kind, + mode, + runStamp, + partnerPlatformId, + messageCount: messages.length, + }); + + throw error; + } +} + +function dedupeContentItems(contentItems: NormalizedPartnerContentItem[]) { + return Array.from( + new Map( + contentItems.map((item) => [item.platformContentId, item]), + ).values(), + ); +} + +async function writeMetadataChunks({ + contentItems, + sourceItems, +}: { + contentItems: Array<{ + id: string; + partnerId: string; + platformContentId: string; + chunks: Array<{ + textHash: string; + }>; + }>; + sourceItems: NormalizedPartnerContentItem[]; +}) { + const sourceItemByPlatformContentId = new Map( + sourceItems.map((item) => [item.platformContentId, item]), + ); + const createdPlatformContentIds = new Set(); + const embeddingModel = PARTNER_CONTENT_SEARCH_MODELS.embedding.id; + + // Batch changed items + new chunks into one transaction instead of per-item + // (which fanned out to ~100-250 serial round-trips on a full backfill). + const changedItems: { + id: string; + sourceItem: NormalizedPartnerContentItem; + }[] = []; + const chunksToCreate: Prisma.PartnerContentChunkCreateManyInput[] = []; + + for (const contentItem of contentItems) { + const sourceItem = sourceItemByPlatformContentId.get( + contentItem.platformContentId, + ); + if (!sourceItem) continue; + + const chunkText = createMetadataChunkText(sourceItem); + const textHash = chunkText ? hashText(chunkText) : null; + const existingTextHash = contentItem.chunks[0]?.textHash ?? null; + + if (textHash === existingTextHash) continue; + + changedItems.push({ id: contentItem.id, sourceItem }); + + if (chunkText) { + chunksToCreate.push({ + id: createId({ prefix: "pcc_" }), + partnerContentItemId: contentItem.id, + partnerId: contentItem.partnerId, + source: "metadata", + chunkIndex: 0, + chunkText, + startMs: null, + endMs: null, + textHash: textHash!, + embeddingModel, + }); + createdPlatformContentIds.add(contentItem.platformContentId); + } + } + + if (changedItems.length === 0) { + return { createdPlatformContentIds }; + } + + const changedItemIds = changedItems.map(({ id }) => id); + + await prisma.$transaction([ + ...changedItems.map(({ id, sourceItem }) => + prisma.partnerContentItem.update({ + where: { + id, + }, + data: { + url: sourceItem.url, + title: sourceItem.title, + description: sourceItem.description, + thumbnailUrl: sourceItem.thumbnailUrl, + publishedAt: sourceItem.publishedAt, + durationMs: sourceItem.durationMs, + viewCount: toNullableBigInt(sourceItem.viewCount), + likeCount: toNullableBigInt(sourceItem.likeCount), + commentCount: toNullableBigInt(sourceItem.commentCount), + shareCount: toNullableBigInt(sourceItem.shareCount), + saveCount: toNullableBigInt(sourceItem.saveCount), + }, + }), + ), + prisma.partnerContentChunk.deleteMany({ + where: { + partnerContentItemId: { + in: changedItemIds, + }, + source: "metadata", + }, + }), + ...(chunksToCreate.length > 0 + ? [prisma.partnerContentChunk.createMany({ data: chunksToCreate })] + : []), + ]); + + await refreshPartnerContentItemChunkCountsBulk(changedItemIds); + + return { createdPlatformContentIds }; +} + +function createMetadataChunkText(item: NormalizedPartnerContentItem) { + const lines = [ + item.contentType ? `Content type: ${item.contentType}` : null, + item.title ? `Title: ${item.title}` : null, + item.description ? `Description: ${item.description}` : null, + ].filter((line): line is string => Boolean(line?.trim())); + + if (lines.length <= 1 && !item.title && !item.description) return null; + + return lines.join("\n"); +} + +function toNullableBigInt(value: number | null) { + return value === null ? null : BigInt(Math.trunc(value)); +} diff --git a/apps/web/lib/partner-content-search/listing.ts b/apps/web/lib/partner-content-search/listing.ts new file mode 100644 index 00000000000..675e3fe26fe --- /dev/null +++ b/apps/web/lib/partner-content-search/listing.ts @@ -0,0 +1,167 @@ +import { DISCOVERABLE_NETWORK_STATUSES } from "@/lib/api/network/partner-network-listing-where"; +import { prisma } from "@/lib/prisma"; +import { PlatformType, Prisma } from "@prisma/client"; +import { PARTNER_CONTENT_SEARCH_MODELS } from "./constants"; +import type { PartnerContentSearchRow } from "./search-utils"; + +// Empty-query path: most recent embedded content across the eligible network, +// shaped into the same per-chunk row as the vector search so downstream is identical. +export async function listPartnerNetworkContent({ + programId, + platforms, + country, + partnerIds, + starred, + limit, +}: { + programId: string; + platforms?: PlatformType[]; + country?: string; + partnerIds?: string[]; + starred?: boolean; + limit: number; +}) { + const discoveredFilters: Prisma.PartnerWhereInput[] = [ + { + discoveredByPrograms: { + none: { + programId, + ignoredAt: { + not: null, + }, + }, + }, + }, + ]; + + if (starred === true) { + discoveredFilters.push({ + discoveredByPrograms: { + some: { + programId, + starredAt: { + not: null, + }, + }, + }, + }); + } else if (starred === false) { + discoveredFilters.push({ + discoveredByPrograms: { + none: { + programId, + starredAt: { + not: null, + }, + }, + }, + }); + } + + const contentItems = await prisma.partnerContentItem.findMany({ + where: { + ...(partnerIds?.length && { + partnerId: { + in: partnerIds, + }, + }), + embeddingModel: PARTNER_CONTENT_SEARCH_MODELS.embedding.id, + embeddedChunkCount: { + gt: 0, + }, + ...(platforms?.length && { + partnerPlatform: { + type: { in: platforms }, + }, + }), + partner: { + networkStatus: { + in: DISCOVERABLE_NETWORK_STATUSES, + }, + ...(country && { country }), + programs: { + none: { + programId, + }, + }, + AND: discoveredFilters, + }, + }, + select: { + id: true, + partnerId: true, + platformContentId: true, + url: true, + contentType: true, + title: true, + description: true, + thumbnailUrl: true, + publishedAt: true, + durationMs: true, + viewCount: true, + likeCount: true, + commentCount: true, + shareCount: true, + saveCount: true, + partnerPlatform: { + select: { + type: true, + identifier: true, + }, + }, + chunks: { + where: { + embeddingModel: PARTNER_CONTENT_SEARCH_MODELS.embedding.id, + }, + select: { + id: true, + source: true, + }, + orderBy: { + id: "asc", + }, + take: 1, + }, + }, + orderBy: [ + { + publishedAt: "desc", + }, + { + id: "asc", + }, + ], + take: limit, + }); + + return contentItems.flatMap((contentItem) => { + const chunk = contentItem.chunks[0]; + if (!chunk) return []; + + return { + chunkId: chunk.id, + partnerContentItemId: contentItem.id, + partnerId: contentItem.partnerId, + platformType: contentItem.partnerPlatform.type, + platformIdentifier: contentItem.partnerPlatform.identifier, + platformContentId: contentItem.platformContentId, + contentUrl: contentItem.url, + contentType: contentItem.contentType, + contentTitle: contentItem.title, + contentDescription: contentItem.description, + contentThumbnailUrl: contentItem.thumbnailUrl, + contentPublishedAt: contentItem.publishedAt, + contentDurationMs: contentItem.durationMs, + contentViewCount: contentItem.viewCount, + contentLikeCount: contentItem.likeCount, + contentCommentCount: contentItem.commentCount, + contentShareCount: contentItem.shareCount, + contentSaveCount: contentItem.saveCount, + chunkSource: chunk.source, + chunkText: "", + startMs: null, + endMs: null, + distance: 0, + }; + }); +} diff --git a/apps/web/lib/partner-content-search/match-summaries.ts b/apps/web/lib/partner-content-search/match-summaries.ts new file mode 100644 index 00000000000..ce45c6f80af --- /dev/null +++ b/apps/web/lib/partner-content-search/match-summaries.ts @@ -0,0 +1,223 @@ +import { PlatformType } from "@prisma/client"; +import { + ContentMatchContext, + getBestMatchByContentItemId, + getContentMatchStats, + QueryContentMatchContext, + toContentMatch, +} from "./content-matches"; +import { fetchMatchSummaryBaseData } from "./match-summary-queries"; +import { + deriveTopicFit, + getEvidenceSource, + setSourceScore, + type PartnerContentMatchSource, + type SourceScoreByContentItemId, +} from "./ranking"; +import { median, type PartnerContentSearchRow } from "./search-utils"; +import type { PartnerContentSearchTimingLogger } from "./timing"; + +function groupRowsBy(rows: T[], getKey: (row: T) => K) { + const rowsByKey = new Map(); + + for (const row of rows) { + const key = getKey(row); + const group = rowsByKey.get(key) ?? []; + group.push(row); + rowsByKey.set(key, group); + } + + return rowsByKey; +} + +export async function getPartnerMatchSummaries({ + rows, + partnerIds, + platforms, + queryVector, + cutoffDistance, + itemSourceBestDistance, + logTiming, +}: { + rows: PartnerContentSearchRow[]; + partnerIds: string[]; + platforms?: PlatformType[]; + queryVector?: string | null; + cutoffDistance?: number | null; + itemSourceBestDistance?: SourceScoreByContentItemId; + logTiming?: PartnerContentSearchTimingLogger; +}) { + if (partnerIds.length === 0) return new Map(); + + const { contentCounts, recentContentRows, followerRows } = + await fetchMatchSummaryBaseData({ + partnerIds, + platforms, + logTiming, + }); + const followersByPartner = new Map( + followerRows.map((row) => [ + row.partnerId, + row._sum.subscribers != null ? Number(row._sum.subscribers) : 0, + ]), + ); + + const countByPartnerId = new Map( + contentCounts.map((row) => [row.partnerId, row]), + ); + const rowsByPartnerId = groupRowsBy(rows, ({ partnerId }) => partnerId); + const recentRowsByPartnerId = groupRowsBy( + recentContentRows, + ({ partnerId }) => partnerId, + ); + + const recentItemSourceBestDistance = + queryVector && itemSourceBestDistance + ? itemSourceBestDistance + : new Map>(); + + const rerankByItemSource: SourceScoreByContentItemId = new Map(); + for (const row of rows) { + if (row.rerankScore != null) { + setSourceScore( + rerankByItemSource, + row.partnerContentItemId, + getEvidenceSource(row.chunkSource), + row.rerankScore, + ); + } + } + + const queryMatchContext: QueryContentMatchContext | null = queryVector + ? { + cutoffDistance, + recentItemSourceBestDistance, + rerankByItemSource, + } + : null; + + logTiming?.("match-summary-aggregation-start", { + partnerCount: partnerIds.length, + recentContentRows: recentContentRows.length, + }); + const summaries = new Map( + partnerIds.map((partnerId) => { + const partnerRows = rowsByPartnerId.get(partnerId) ?? []; + const recentRows = recentRowsByPartnerId.get(partnerId) ?? []; + const countRow = countByPartnerId.get(partnerId); + const totalContentCount = countRow ? countRow._count._all : 0; + const bestMatchByContentItemId = getBestMatchByContentItemId(partnerRows); + const contentMatchContext: ContentMatchContext = queryMatchContext ?? { + bestMatchByContentItemId, + }; + const contentMatches = recentRows.map((row) => + toContentMatch({ + row, + context: contentMatchContext, + }), + ); + const { + matchedContent, + matchedContentCount, + strongMatchedContentCount, + partialMatchedContentCount, + transcriptMatchedContentCount, + creatorTextMatchedContentCount, + creatorTextOnlyContentCount, + weightedMatchedContentCount, + weightedMatchedContentScore, + } = getContentMatchStats(contentMatches); + const recentContentCount = contentMatches.length; + const { topicFit, band } = deriveTopicFit({ + matchedContentCount, + weightedMatchedContentCount, + weightedMatchedContentScore, + recentContentCount, + }); + const medianViews = median( + matchedContent + .map((match) => match.viewCount) + .filter((views): views is number => views != null && views > 0), + { round: true }, + ); + const matchedTimestamps = matchedContent + .map((match) => match.publishedAt) + .filter((publishedAt): publishedAt is string => Boolean(publishedAt)) + .map((publishedAt) => new Date(publishedAt).getTime()); + const lastOnTopicAt = matchedTimestamps.length + ? new Date(Math.max(...matchedTimestamps)).toISOString() + : null; + const followers = followersByPartner.get(partnerId) ?? null; + const platformFrequency = new Map(); + for (const match of matchedContent.length + ? matchedContent + : contentMatches) { + platformFrequency.set( + match.platform, + (platformFrequency.get(match.platform) ?? 0) + 1, + ); + } + const topPlatforms = Array.from(platformFrequency.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([platform]) => platform); + const publishedDates = contentMatches + .map(({ publishedAt }) => (publishedAt ? new Date(publishedAt) : null)) + .filter((date): date is Date => Boolean(date)); + const oldestPublishedAt = publishedDates.length + ? new Date( + Math.min(...publishedDates.map((date) => date.getTime())), + ).toISOString() + : countRow?._min.publishedAt?.toISOString() ?? null; + const newestPublishedAt = publishedDates.length + ? new Date( + Math.max(...publishedDates.map((date) => date.getTime())), + ).toISOString() + : countRow?._max.publishedAt?.toISOString() ?? null; + + return [ + partnerId, + { + matchedContentCount, + strongMatchedContentCount, + partialMatchedContentCount, + transcriptMatchedContentCount, + creatorTextMatchedContentCount, + creatorTextOnlyContentCount, + weightedMatchedContentCount, + weightedMatchedContentScore, + recentContentCount, + totalContentCount, + topicFit, + band, + followers, + medianViews, + lastOnTopicAt, + topPlatforms, + platforms: Array.from( + new Set( + (recentRows.map(({ platformType }) => platformType).length + ? recentRows.map(({ platformType }) => platformType) + : partnerRows.map(({ platformType }) => platformType) + ).filter(Boolean), + ), + ).sort(), + sources: Array.from( + new Set( + partnerRows.map(({ chunkSource }) => + getEvidenceSource(chunkSource), + ), + ), + ).sort(), + oldestPublishedAt, + newestPublishedAt, + contentMatches, + }, + ] as const; + }), + ); + logTiming?.("match-summary-aggregation-complete", { + summaryCount: summaries.size, + }); + + return summaries; +} diff --git a/apps/web/lib/partner-content-search/match-summary-queries.ts b/apps/web/lib/partner-content-search/match-summary-queries.ts new file mode 100644 index 00000000000..1cb7b22458f --- /dev/null +++ b/apps/web/lib/partner-content-search/match-summary-queries.ts @@ -0,0 +1,157 @@ +import { prisma } from "@/lib/prisma"; +import { PlatformType, Prisma } from "@prisma/client"; +import { + PARTNER_CONTENT_SEARCH_LIMITS, + PARTNER_CONTENT_SEARCH_MODELS, +} from "./constants"; +import type { PartnerContentSearchTimingLogger } from "./timing"; + +// Slim content row for aggregates only; display fields come from loaded chunks. +export type PartnerRecentContentRow = { + partnerId: string; + partnerContentItemId: string; + platformType: string; + contentType: string; + publishedAt: Date | null; + viewCount: bigint | number | null; + rowNumber: bigint | number; +}; + +export async function fetchMatchSummaryBaseData({ + partnerIds, + platforms, + logTiming, +}: { + partnerIds: string[]; + platforms?: PlatformType[]; + logTiming?: PartnerContentSearchTimingLogger; +}) { + const platformFilter = platforms?.length + ? Prisma.sql`AND pp.type IN (${Prisma.join(platforms)})` + : Prisma.empty; + + // "Recent" is a time window (recencyWindowMonths), not a post count — a count + // window spans wildly different time per creator, conflating active and dormant. + const recencyCutoff = new Date(); + recencyCutoff.setMonth( + recencyCutoff.getMonth() - + PARTNER_CONTENT_SEARCH_LIMITS.recencyWindowMonths, + ); + + const contentCountsPromise = prisma.partnerContentItem + .groupBy({ + by: ["partnerId"], + where: { + partnerId: { + in: partnerIds, + }, + embeddingModel: PARTNER_CONTENT_SEARCH_MODELS.embedding.id, + embeddedChunkCount: { + gt: 0, + }, + ...(platforms?.length && { + partnerPlatform: { + type: { in: platforms }, + }, + }), + }, + _count: { + _all: true, + }, + _min: { + publishedAt: true, + }, + _max: { + publishedAt: true, + }, + }) + .then((contentCounts) => { + logTiming?.("match-summary-content-counts-complete", { + contentCountRows: contentCounts.length, + }); + return contentCounts; + }); + + const recentContentRowsPromise = prisma + .$queryRaw( + Prisma.sql` + SELECT + partnerId, + partnerContentItemId, + platformType, + contentType, + publishedAt, + viewCount, + rowNumber + FROM ( + SELECT + pci.partnerId, + pci.id AS partnerContentItemId, + pp.type AS platformType, + pci.contentType, + pci.publishedAt, + pci.viewCount, + ROW_NUMBER() OVER ( + PARTITION BY pci.partnerId + ORDER BY pci.publishedAt DESC, pci.createdAt DESC, pci.id ASC + ) AS rowNumber + FROM PartnerContentItem pci + INNER JOIN PartnerPlatform pp ON pp.id = pci.partnerPlatformId + WHERE pci.partnerId IN (${Prisma.join(partnerIds)}) + ${platformFilter} + AND ( + pci.publishedAt >= ${recencyCutoff} + OR (pci.publishedAt IS NULL AND pci.createdAt >= ${recencyCutoff}) + ) + AND pci.embeddingModel = ${PARTNER_CONTENT_SEARCH_MODELS.embedding.id} + AND pci.embeddedChunkCount > 0 + ) recentContent + WHERE rowNumber <= ${PARTNER_CONTENT_SEARCH_LIMITS.recentContentMaxPerPartner} + ORDER BY partnerId ASC, rowNumber ASC + `, + ) + .then((recentContentRows) => { + logTiming?.("match-summary-recent-content-complete", { + recentContentRows: recentContentRows.length, + }); + return recentContentRows; + }); + + const followerRowsPromise = prisma.partnerPlatform + .groupBy({ + by: ["partnerId"], + where: { + partnerId: { + in: partnerIds, + }, + ...(platforms?.length && { + type: { in: platforms }, + }), + }, + _sum: { + subscribers: true, + }, + }) + .then((followerRows) => { + logTiming?.("match-summary-followers-complete", { + followerRows: followerRows.length, + }); + return followerRows; + }); + + logTiming?.("match-summary-base-queries-start", { + partnerCount: partnerIds.length, + }); + const [contentCounts, recentContentRows, followerRows] = await Promise.all([ + contentCountsPromise, + recentContentRowsPromise, + followerRowsPromise, + ]); + logTiming?.("match-summary-base-queries-complete", { + contentCountRows: contentCounts.length, + recentContentRows: recentContentRows.length, + followerRows: followerRows.length, + }); + + return { contentCounts, recentContentRows, followerRows }; +} diff --git a/apps/web/lib/partner-content-search/network-partners.ts b/apps/web/lib/partner-content-search/network-partners.ts new file mode 100644 index 00000000000..0bb23b6bbb5 --- /dev/null +++ b/apps/web/lib/partner-content-search/network-partners.ts @@ -0,0 +1,113 @@ +import { + partnerNetworkListingWhere, + partnerReachWhere, +} from "@/lib/api/network/partner-network-listing-where"; +import type { ReachTier } from "@/lib/api/network/reach-tiers"; +import { prisma } from "@/lib/prisma"; +import { NetworkPartnerSchema } from "@/lib/zod/schemas/partner-network"; +import type { PlatformType } from "@prisma/client"; +import type * as z from "zod/v4"; + +type NetworkPartnerCard = z.infer; + +// Hydrate partner cards for content search +export async function getNetworkPartnersById({ + programId, + partnerIds, + platforms, + reach, + country, +}: { + programId: string; + partnerIds: string[]; + platforms?: PlatformType[]; + reach?: ReachTier[]; + country?: string; +}): Promise> { + if (partnerIds.length === 0) return new Map(); + + const partners = await prisma.partner.findMany({ + where: { + AND: [ + partnerNetworkListingWhere({ partnerIds, country }), + // do not require verified platforms for content search; move this out of partnerNetworkListingWhere + ...(platforms?.length + ? [{ platforms: { some: { type: { in: platforms } } } }] + : []), + partnerReachWhere({ reach, platform: platforms }), + ], + }, + select: { + id: true, + name: true, + companyName: true, + country: true, + profileType: true, + image: true, + description: true, + createdAt: true, + networkStatus: true, + monthlyTraffic: true, + identityVerificationStatus: true, + identityVerifiedAt: true, + platforms: { + select: { + type: true, + identifier: true, + verifiedAt: true, + platformId: true, + subscribers: true, + posts: true, + views: true, + }, + }, + programs: { + where: { status: "approved" }, + select: { + program: { select: { categories: { select: { category: true } } } }, + }, + }, + discoveredByPrograms: { + where: { programId }, + select: { starredAt: true, invitedAt: true, ignoredAt: true }, + take: 1, + }, + }, + }); + + const cards = partners.map((partner): NetworkPartnerCard => { + const discovered = partner.discoveredByPrograms[0]; + const categories = Array.from( + new Set( + partner.programs.flatMap(({ program }) => + program.categories.map(({ category }) => category), + ), + ), + ).sort(); + + return { + id: partner.id, + name: partner.name, + companyName: partner.companyName, + country: partner.country, + profileType: partner.profileType, + image: partner.image, + description: partner.description, + createdAt: partner.createdAt, + networkStatus: partner.networkStatus, + monthlyTraffic: partner.monthlyTraffic, + identityVerificationStatus: partner.identityVerificationStatus, + identityVerifiedAt: partner.identityVerifiedAt, + preferredEarningStructures: [], // schema-required; UI doesn't read them + salesChannels: [], + starredAt: discovered?.starredAt ?? null, + invitedAt: discovered?.invitedAt ?? null, + ignoredAt: discovered?.ignoredAt ?? null, + recruitedAt: null, // discover candidates aren't enrolled in this program + categories, + platforms: partner.platforms, + }; + }); + + return new Map(cards.map((card) => [card.id, card])); +} diff --git a/apps/web/lib/partner-content-search/ranking.ts b/apps/web/lib/partner-content-search/ranking.ts new file mode 100644 index 00000000000..2fe92b3fda5 --- /dev/null +++ b/apps/web/lib/partner-content-search/ranking.ts @@ -0,0 +1,292 @@ +import type { PartnerContentTopicFitBand } from "./constants"; +import { PARTNER_CONTENT_SEARCH_TOPIC_FIT } from "./constants"; +import { + effectiveRowScore, + toScore, + type PartnerContentSearchRow, +} from "./search-utils"; + +export type PartnerContentMatchSource = "transcript" | "creatorText"; + +// Best score per content item, split by evidence source (get/set helpers below). +export type SourceScoreByContentItemId = Map< + string, + Map +>; + +export type PartnerContentMatchEvidence = { + primarySource: PartnerContentMatchSource | null; + sources: PartnerContentMatchSource[]; + transcriptScore: number | null; + creatorTextScore: number | null; + creatorTextWeight: number; + weight: number; +}; + +export function deriveTopicFit({ + matchedContentCount, + weightedMatchedContentCount, + weightedMatchedContentScore, + recentContentCount, +}: { + matchedContentCount: number; + weightedMatchedContentCount: number; + weightedMatchedContentScore: number; + recentContentCount: number; +}): { topicFit: number; band: PartnerContentTopicFitBand } { + if (matchedContentCount === 0 || recentContentCount === 0) { + return { topicFit: 0, band: "none" }; + } + + // Coverage = depthConfidence (saturating on-topic evidence, so one post can't read + // as strong) modulated by purity (strength-weighted on-topic share). depthConfidence + // gates the score; purity only moves it between dilutionForgiveness and 1, so a deep + // on-topic body still scores well when mixed with off-topic posts. + const { + coverageCurveExponent, + bandThresholds, + depthSaturationScore, + dilutionForgiveness, + } = PARTNER_CONTENT_SEARCH_TOPIC_FIT; + const purity = Math.min(1, weightedMatchedContentScore / recentContentCount); + const depthConfidence = + 1 - Math.exp(-weightedMatchedContentScore / depthSaturationScore); + const coverage = Math.min( + 1, + Math.max( + 0, + depthConfidence * + (dilutionForgiveness + (1 - dilutionForgiveness) * purity), + ), + ); + const topicFit = Math.round(100 * Math.pow(coverage, coverageCurveExponent)); + + const band: PartnerContentTopicFitBand = + coverage >= bandThresholds.consistent + ? "consistent" + : coverage >= bandThresholds.frequent + ? "frequent" + : matchedContentCount === 1 + ? "one-off" + : "occasional"; + + return { topicFit, band }; +} + +// Ranking-domain alias of effectiveRowScore (single implementation, no drift). +export const getRowRelevanceScore = effectiveRowScore; + +export function getSourceAwareRowScore(row: PartnerContentSearchRow) { + const score = getRowRelevanceScore(row); + const source = getEvidenceSource(row.chunkSource); + + if (source === "transcript") return score; + return Number((score * getCreatorTextWeight(row.contentType)).toFixed(6)); +} + +export function sortRowsByRelevanceScore(rows: PartnerContentSearchRow[]) { + return [...rows].sort( + (a, b) => getRowRelevanceScore(b) - getRowRelevanceScore(a), + ); +} + +export function sortRowsBySourceAwareScore(rows: PartnerContentSearchRow[]) { + return [...rows].sort( + (a, b) => getSourceAwareRowScore(b) - getSourceAwareRowScore(a), + ); +} + +// Final query-mode partner ordering: topic fit, then match-strength tiebreakers, +// then row score and reach. Generic so the route passes its candidate objects. +export function sortPartnersByTopicFit< + T extends { + score: number; + matchSummary: { + topicFit: number; + weightedMatchedContentScore: number; + weightedMatchedContentCount: number; + transcriptMatchedContentCount: number; + matchedContentCount: number; + followers: number | null; + } | null; + }, +>(partners: T[]) { + return [...partners].sort((a, b) => { + const aSummary = a.matchSummary; + const bSummary = b.matchSummary; + + return ( + (bSummary?.topicFit ?? 0) - (aSummary?.topicFit ?? 0) || + (bSummary?.weightedMatchedContentScore ?? 0) - + (aSummary?.weightedMatchedContentScore ?? 0) || + (bSummary?.weightedMatchedContentCount ?? 0) - + (aSummary?.weightedMatchedContentCount ?? 0) || + (bSummary?.transcriptMatchedContentCount ?? 0) - + (aSummary?.transcriptMatchedContentCount ?? 0) || + (bSummary?.matchedContentCount ?? 0) - + (aSummary?.matchedContentCount ?? 0) || + b.score - a.score || + (bSummary?.followers ?? 0) - (aSummary?.followers ?? 0) + ); + }); +} + +export function getEvidenceSource(source: string): PartnerContentMatchSource { + return source === "transcript" ? "transcript" : "creatorText"; +} + +function getCreatorTextWeight(contentType: string) { + return isVideoLikeContentType(contentType) + ? PARTNER_CONTENT_SEARCH_TOPIC_FIT.creatorTextOnlyVideoWeight + : 1; +} + +function isVideoLikeContentType(contentType: string) { + return ["video", "reel", "short", "shorts"].includes( + contentType.toLowerCase(), + ); +} + +export function createContentMatchEvidence({ + contentType, + transcriptScore, + creatorTextScore, +}: { + contentType: string; + transcriptScore: number | null; + creatorTextScore: number | null; +}): PartnerContentMatchEvidence { + const sources: PartnerContentMatchSource[] = []; + if (transcriptScore != null) sources.push("transcript"); + if (creatorTextScore != null) sources.push("creatorText"); + + const primarySource: PartnerContentMatchSource | null = + transcriptScore != null + ? "transcript" + : creatorTextScore != null + ? "creatorText" + : null; + const creatorTextWeight = getCreatorTextWeight(contentType); + const weight = + primarySource === null + ? 0 + : primarySource === "transcript" + ? 1 + : creatorTextWeight; + + return { + primarySource, + sources, + transcriptScore, + creatorTextScore, + creatorTextWeight, + weight, + }; +} + +export function getEvidenceMatchScore(evidence: PartnerContentMatchEvidence) { + if (evidence.sources.length === 0) return null; + + return Number( + Math.max( + getEvidenceStrength(evidence.transcriptScore), + getEvidenceStrength(evidence.creatorTextScore) * + evidence.creatorTextWeight, + ).toFixed(6), + ); +} + +export function getEvidenceTopicScore(evidence: PartnerContentMatchEvidence) { + if (evidence.sources.length === 0) return null; + + return Number( + Math.max( + // Transcript match already passed the relevance gate → full credit; creator + // text uses its context-aware weight. + evidence.transcriptScore != null ? 1 : 0, + getEvidenceStrength(evidence.creatorTextScore) * + evidence.creatorTextWeight, + ).toFixed(6), + ); +} + +function getEvidenceStrength(score: number | null) { + if (score == null) return 0; + + const { + rerankMatchThreshold, + strongMatchScore, + minimumMatchedEvidenceStrength, + } = PARTNER_CONTENT_SEARCH_TOPIC_FIT; + + if (score >= strongMatchScore) return 1; + if (score <= rerankMatchThreshold) return minimumMatchedEvidenceStrength; + + const progress = + (score - rerankMatchThreshold) / (strongMatchScore - rerankMatchThreshold); + + return ( + minimumMatchedEvidenceStrength + + progress * (1 - minimumMatchedEvidenceStrength) + ); +} + +export function getMatchedSourceScore({ + rerankScore, + bestDistance, + cutoffDistance, +}: { + rerankScore?: number; + bestDistance?: number; + cutoffDistance?: number | null; +}) { + if (rerankScore !== undefined) { + return rerankScore >= PARTNER_CONTENT_SEARCH_TOPIC_FIT.rerankMatchThreshold + ? rerankScore + : null; + } + + return bestDistance !== undefined && + cutoffDistance != null && + bestDistance <= cutoffDistance + ? toScore(bestDistance) + : null; +} + +export function getSourceScore( + sourceScoresByItemId: Map>, + itemId: string, + source: PartnerContentMatchSource, +) { + return sourceScoresByItemId.get(itemId)?.get(source); +} + +export function setSourceScore( + sourceScoresByItemId: Map>, + itemId: string, + source: PartnerContentMatchSource, + score: number, +) { + const itemScores = sourceScoresByItemId.get(itemId) ?? new Map(); + const current = itemScores.get(source); + + if (current === undefined || score > current) { + itemScores.set(source, score); + sourceScoresByItemId.set(itemId, itemScores); + } +} + +export function setSourceDistance( + sourceDistancesByItemId: Map>, + itemId: string, + source: PartnerContentMatchSource, + distance: number, +) { + const itemDistances = sourceDistancesByItemId.get(itemId) ?? new Map(); + const current = itemDistances.get(source); + + if (current === undefined || distance < current) { + itemDistances.set(source, distance); + sourceDistancesByItemId.set(itemId, itemDistances); + } +} diff --git a/apps/web/lib/partner-content-search/rerank.ts b/apps/web/lib/partner-content-search/rerank.ts new file mode 100644 index 00000000000..8e8158f366b --- /dev/null +++ b/apps/web/lib/partner-content-search/rerank.ts @@ -0,0 +1,78 @@ +// Server-only second-stage reranking via the Voyage reranker. Kept separate from +// search-utils so the pure ranking/grouping helpers there stay client-safe (the +// content-search UI imports them transitively). +import "server-only"; + +import { + PARTNER_CONTENT_SEARCH_LIMITS, + PARTNER_CONTENT_SEARCH_RERANK_TIMEOUT_MS, +} from "./constants"; +import { effectiveRowScore, type PartnerContentSearchRow } from "./search-utils"; +import { + rerankPartnerContent, + VoyageApiError, + VoyageTimeoutError, +} from "./voyage"; + +// Re-score the top candidates with the Voyage reranker, sorted by effective score. +// Fails soft: on timeout/API error, returns the original cosine ordering. +export async function rerankPartnerSearchRows({ + query, + rows, +}: { + query: string; + rows: PartnerContentSearchRow[]; +}): Promise<{ rows: PartnerContentSearchRow[]; reranked: boolean }> { + if (rows.length === 0) return { rows, reranked: false }; + + // Only the top-N candidates are reranked, to bound payload/latency. + const candidates = rows.slice( + 0, + PARTNER_CONTENT_SEARCH_LIMITS.rerankerCandidateCount, + ); + const documentCharSafetyLimit = + PARTNER_CONTENT_SEARCH_LIMITS.rerankerDocumentCharSafetyLimit; + const documents = candidates.map(({ chunkText }) => { + const document = chunkText ?? ""; + return document.length > documentCharSafetyLimit + ? document.slice(0, documentCharSafetyLimit) + : document; + }); + + try { + const results = await rerankPartnerContent({ + query, + documents, + topK: candidates.length, + timeoutMs: PARTNER_CONTENT_SEARCH_RERANK_TIMEOUT_MS, + }); + + for (const { index, relevanceScore } of results) { + const row = candidates[index]; + if (row) row.rerankScore = relevanceScore; + } + } catch (error) { + if ( + error instanceof VoyageTimeoutError || + error instanceof VoyageApiError + ) { + console.error( + "[partner-content-search] reranker failed, falling back to cosine:", + error.message, + ); + return { rows, reranked: false }; + } + throw error; + } + + // Reranker-only ordering: return just the reranked candidates, sorted by their + // calibrated relevance. We drop the un-reranked cosine tail (rows beyond + // rerankerCandidateCount) rather than appending it — the cosine and rerank score + // scales must never be blended in one ordering. The fail-soft path above returns + // the full cosine ordering instead. + const reranked = candidates + .filter((row) => row.rerankScore != null) + .sort((a, b) => effectiveRowScore(b) - effectiveRowScore(a)); + + return { rows: reranked, reranked: true }; +} diff --git a/apps/web/lib/partner-content-search/retrieval.ts b/apps/web/lib/partner-content-search/retrieval.ts new file mode 100644 index 00000000000..a9b5f418dc3 --- /dev/null +++ b/apps/web/lib/partner-content-search/retrieval.ts @@ -0,0 +1,365 @@ +import { DubApiError } from "@/lib/api/errors"; +import { DISCOVERABLE_NETWORK_STATUSES } from "@/lib/api/network/partner-network-listing-where"; +import { prisma } from "@/lib/prisma"; +import { redis } from "@/lib/upstash"; +import { createHash } from "crypto"; +import { PlatformType, Prisma } from "@prisma/client"; +import { + PARTNER_CONTENT_CHUNK_VECTOR_DISTANCE, + PARTNER_CONTENT_CHUNK_VECTOR_INDEX, + PARTNER_CONTENT_SEARCH_LIMITS, + PARTNER_CONTENT_SEARCH_MODELS, + PARTNER_CONTENT_SEARCH_VOYAGE_QUERY_TIMEOUT_MS, +} from "./constants"; +import { + getEvidenceSource, + setSourceDistance, + sortRowsByRelevanceScore, + type SourceScoreByContentItemId, +} from "./ranking"; +import { rerankPartnerSearchRows } from "./rerank"; +import { + dedupeBestChunkPerContentItem, + dedupeBestChunkPerContentItemSource, + type PartnerContentSearchRow, +} from "./search-utils"; +import type { PartnerContentSearchTimingLogger } from "./timing"; +import { + embedPartnerContentTexts, + serializeEmbeddingForVector, + VoyageTimeoutError, +} from "./voyage"; + +// cache query embeddings for 60 days (query, embedding model). refresh on read +const QUERY_EMBEDDING_CACHE_TTL_SECONDS = 60 * 60 * 24 * 60; // 60 days + +function queryEmbeddingCacheKey(query: string) { + const digest = createHash("sha256").update(query).digest("hex").slice(0, 32); + return `pcs:query-embedding:${PARTNER_CONTENT_SEARCH_MODELS.embedding.id}:${digest}`; +} + +async function readCachedQueryEmbedding(key: string) { + try { + // GETEX re-arms the TTL on every hit so frequently searched queries never expire. + const cached = await redis.getex(key, { + ex: QUERY_EMBEDDING_CACHE_TTL_SECONDS, + }); + return Array.isArray(cached) && cached.length ? cached : null; + } catch { + return null; + } +} + +function cacheQueryEmbedding(key: string, embedding: number[]) { + // Fire-and-forget — never block the search on the cache write. + redis + .set(key, embedding, { ex: QUERY_EMBEDDING_CACHE_TTL_SECONDS }) + .catch(() => {}); +} + +type PartnerContentSearchCandidateRow = Pick< + PartnerContentSearchRow, + "chunkId" | "partnerContentItemId" | "chunkSource" | "distance" +>; + +type PartnerContentSearchHydrationRow = Omit< + PartnerContentSearchRow, + "distance" +>; + +export async function searchPartnerNetworkContent({ + programId, + query, + platforms, + country, + partnerIds, + starred, + limit, + rerank, + logTiming, +}: { + programId: string; + query: string; + platforms?: PlatformType[]; + country?: string; + partnerIds?: string[]; + starred?: boolean; + limit: number; + rerank: boolean; + logTiming?: PartnerContentSearchTimingLogger; +}) { + const embeddingCacheKey = queryEmbeddingCacheKey(query); + logTiming?.("query-embedding-start"); + let queryEmbedding: number[]; + const cachedEmbedding = await readCachedQueryEmbedding(embeddingCacheKey); + if (cachedEmbedding) { + queryEmbedding = cachedEmbedding; + logTiming?.("query-embedding-cache-hit", { + embeddingDimensions: queryEmbedding.length, + }); + } else { + try { + [queryEmbedding] = await embedPartnerContentTexts({ + input: [query], + inputType: "query", + timeoutMs: PARTNER_CONTENT_SEARCH_VOYAGE_QUERY_TIMEOUT_MS, + }); + } catch (error) { + if (error instanceof VoyageTimeoutError) { + throw new DubApiError({ + code: "internal_server_error", + message: "Partner content search timed out. Please try again.", + }); + } + throw error; + } + cacheQueryEmbedding(embeddingCacheKey, queryEmbedding); + logTiming?.("query-embedding-complete", { + embeddingDimensions: queryEmbedding.length, + }); + } + const queryVector = serializeEmbeddingForVector(queryEmbedding); + + // Eligibility filters inline in the ANN query; post-filtering would starve the candidate pool. + const annFilters: Prisma.Sql[] = [ + Prisma.sql`AND NOT EXISTS ( + SELECT 1 FROM ProgramEnrollment pe + WHERE pe.partnerId = c.partnerId AND pe.programId = ${programId} + )`, + Prisma.sql`AND NOT EXISTS ( + SELECT 1 FROM DiscoveredPartner dpi + WHERE dpi.partnerId = c.partnerId AND dpi.programId = ${programId} + AND dpi.ignoredAt IS NOT NULL + )`, + ]; + if (partnerIds?.length) { + annFilters.push(Prisma.sql`AND c.partnerId IN (${Prisma.join(partnerIds)})`); + } + if (starred === true) { + annFilters.push(Prisma.sql`AND EXISTS ( + SELECT 1 FROM DiscoveredPartner dps + WHERE dps.partnerId = c.partnerId AND dps.programId = ${programId} + AND dps.starredAt IS NOT NULL + )`); + } else if (starred === false) { + annFilters.push(Prisma.sql`AND NOT EXISTS ( + SELECT 1 FROM DiscoveredPartner dps + WHERE dps.partnerId = c.partnerId AND dps.programId = ${programId} + AND dps.starredAt IS NOT NULL + )`); + } + if (country) { + annFilters.push(Prisma.sql`AND c.country = ${country}`); + } + if (platforms?.length) { + annFilters.push( + Prisma.sql`AND c.platformType IN (${Prisma.join(platforms)})`, + ); + } + const annFilter = Prisma.join(annFilters, " "); + + // Lean ANN query first (vector index), then hydrate chunk ids — joins stay off the vector path. + const fetchCandidateChunks = (poolSize: number) => + prisma.$queryRaw(Prisma.sql` + SELECT + c.id AS chunkId, + c.partnerContentItemId, + c.source AS chunkSource, + DISTANCE(TO_VECTOR(${queryVector}), c.embedding, ${Prisma.raw(`'${PARTNER_CONTENT_CHUNK_VECTOR_DISTANCE}'`)}) AS distance + FROM PartnerContentChunk c FORCE INDEX (${Prisma.raw(PARTNER_CONTENT_CHUNK_VECTOR_INDEX)}) + WHERE c.embedding IS NOT NULL + AND c.embeddingModel = ${PARTNER_CONTENT_SEARCH_MODELS.embedding.id} + ${annFilter} + ORDER BY distance ASC + LIMIT ${poolSize} + `); + + // Fixed pool size — ANN refills when eligibility filters skip near-neighbors. + const poolSize = PARTNER_CONTENT_SEARCH_LIMITS.vectorSearchChunkPoolSize; + logTiming?.("vector-search-start", { + poolSize, + retrievalShape: "ann-then-topk-hydrate", + }); + + const candidateRows = await fetchCandidateChunks(poolSize); + logTiming?.("vector-candidate-search-complete", { + candidateRowCount: candidateRows.length, + poolSize, + vectorIndex: PARTNER_CONTENT_CHUNK_VECTOR_INDEX, + }); + const sourceDeduped = dedupeBestChunkPerContentItemSource(candidateRows); + + // Reused by getPartnerMatchSummaries to gate matched posts without a second DISTANCE pass. + const itemSourceBestDistance: SourceScoreByContentItemId = new Map(); + for (const row of sourceDeduped) { + setSourceDistance( + itemSourceBestDistance, + row.partnerContentItemId, + getEvidenceSource(row.chunkSource), + Number(row.distance), + ); + } + + // One best chunk per item for the cutoff; one per item+source for rerank/evidence. + const itemRows = dedupeBestChunkPerContentItem(candidateRows).slice(0, limit); + const candidateSourceRows = sourceDeduped.slice( + 0, + Math.min(PARTNER_CONTENT_SEARCH_LIMITS.rerankerCandidateCount, limit * 2), + ); + + // Match gate: worst distance among kept candidates (networkStatus drift dropped at hydrate). + const cutoffDistance = itemRows.length + ? Number(itemRows[itemRows.length - 1].distance) + : null; + logTiming?.("candidate-dedupe-complete", { + candidateRowCount: candidateRows.length, + sourceRowCount: candidateSourceRows.length, + cutoffDistance, + }); + + const rows = await hydratePartnerContentSearchRows({ + candidateRows: candidateSourceRows, + logTiming, + }); + + if (!rerank) { + return { + rows: sortRowsByRelevanceScore(rows), + reranked: false, + queryVector, + cutoffDistance, + itemSourceBestDistance, + }; + } + + // reach/verified-platform filter at card hydration (post-rerank); pre-filtering risks ANN starvation. + logTiming?.("rerank-start", { + rowCount: rows.length, + rerankerCandidateCount: Math.min( + rows.length, + PARTNER_CONTENT_SEARCH_LIMITS.rerankerCandidateCount, + ), + }); + const rerankResult = await rerankPartnerSearchRows({ + query, + rows, + }); + logTiming?.("rerank-complete", { + reranked: rerankResult.reranked, + rowCount: rerankResult.rows.length, + rerankedRowCount: rerankResult.rows.filter( + ({ rerankScore }) => rerankScore != null, + ).length, + }); + return { + // Don't re-sort — reranker order is intentional; blending scales would be wrong. + ...rerankResult, + queryVector, + cutoffDistance, + itemSourceBestDistance, + }; +} + +async function hydratePartnerContentSearchRows({ + candidateRows, + logTiming, +}: { + candidateRows: PartnerContentSearchCandidateRow[]; + logTiming?: PartnerContentSearchTimingLogger; +}) { + if (candidateRows.length === 0) return []; + + const chunkIds = candidateRows.map(({ chunkId }) => chunkId); + // networkStatus filtered post-ANN; catches status drift since embedding. + const hydratedRows = await prisma.partnerContentChunk.findMany({ + where: { + id: { in: chunkIds }, + embeddingModel: PARTNER_CONTENT_SEARCH_MODELS.embedding.id, + partner: { + networkStatus: { in: DISCOVERABLE_NETWORK_STATUSES }, + }, + }, + select: { + id: true, + partnerContentItemId: true, + partnerId: true, + source: true, + startMs: true, + endMs: true, + chunkText: true, + partnerContentItem: { + select: { + platformContentId: true, + url: true, + contentType: true, + title: true, + description: true, + thumbnailUrl: true, + publishedAt: true, + durationMs: true, + viewCount: true, + likeCount: true, + commentCount: true, + shareCount: true, + saveCount: true, + partnerPlatform: { + select: { + type: true, + identifier: true, + }, + }, + }, + }, + }, + }); + const hydratedByChunkId = new Map( + hydratedRows.map(({ partnerContentItem: item, ...chunk }): [ + string, + PartnerContentSearchHydrationRow, + ] => [ + chunk.id, + { + chunkId: chunk.id, + partnerContentItemId: chunk.partnerContentItemId, + partnerId: chunk.partnerId, + platformType: item.partnerPlatform.type, + platformIdentifier: item.partnerPlatform.identifier, + platformContentId: item.platformContentId, + contentUrl: item.url, + contentType: item.contentType, + contentTitle: item.title, + contentDescription: item.description, + contentThumbnailUrl: item.thumbnailUrl, + contentPublishedAt: item.publishedAt, + contentDurationMs: item.durationMs, + contentViewCount: item.viewCount, + contentLikeCount: item.likeCount, + contentCommentCount: item.commentCount, + contentShareCount: item.shareCount, + contentSaveCount: item.saveCount, + chunkSource: chunk.source, + chunkText: chunk.chunkText, + startMs: chunk.startMs, + endMs: chunk.endMs, + }, + ]), + ); + const rows = candidateRows.flatMap((candidate) => { + const hydrated = hydratedByChunkId.get(candidate.chunkId); + if (!hydrated) return []; + + return [ + { + ...hydrated, + distance: candidate.distance, + }, + ]; + }); + logTiming?.("vector-candidate-hydration-complete", { + candidateRowCount: candidateRows.length, + hydratedRowCount: hydratedRows.length, + filteredOutRowCount: candidateRows.length - rows.length, + }); + + return rows; +} diff --git a/apps/web/lib/partner-content-search/search-utils.ts b/apps/web/lib/partner-content-search/search-utils.ts new file mode 100644 index 00000000000..0e99208606a --- /dev/null +++ b/apps/web/lib/partner-content-search/search-utils.ts @@ -0,0 +1,168 @@ +// Client-safe helpers for content search; server-only reranking lives in ./rerank. + +import { PARTNER_CONTENT_SEARCH_LIMITS } from "./constants"; + +export type PartnerContentSearchRow = { + chunkId: string; + partnerContentItemId: string; + partnerId: string; + platformType: string; + platformIdentifier: string; + platformContentId: string; + contentUrl: string; + contentType: string; + contentTitle: string | null; + contentDescription?: string | null; + contentThumbnailUrl: string | null; + contentPublishedAt: Date | null; + contentDurationMs: number | null; + contentViewCount?: bigint | number | null; + contentLikeCount?: bigint | number | null; + contentCommentCount?: bigint | number | null; + contentShareCount?: bigint | number | null; + contentSaveCount?: bigint | number | null; + chunkSource: string; + chunkText: string; + startMs: number | null; + endMs: number | null; + distance: number | string; + rerankScore?: number | null; // rerank stage only; absent for cosine-only +}; + +export function toScore(distance: number) { + return Number((1 - distance).toFixed(6)); +} + +// Query mode over-fetches (×6, min 25, rerank cap); list mode uses ×2. +export function getCandidateChunkCount({ + hasQuery, + limit, + chunksPerPartner, +}: { + hasQuery: boolean; + limit: number; + chunksPerPartner: number; +}) { + if (!hasQuery) return limit * chunksPerPartner * 2; + + return Math.min( + PARTNER_CONTENT_SEARCH_LIMITS.rerankerCandidateCount, + Math.max(25, limit * chunksPerPartner * 6), + ); +} + +export function median( + values: number[], + { round = false }: { round?: boolean } = {}, +): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 !== 0) return sorted[mid]; + const average = (sorted[mid - 1] + sorted[mid]) / 2; + return round ? Math.round(average) : average; +} + +export function effectiveRowScore(row: PartnerContentSearchRow) { + return row.rerankScore ?? toScore(Number(row.distance)); +} + +// dedupe in app to preserve ANN distance ordering without circumventing index +export function dedupeBestChunkPerContentItem< + T extends { partnerContentItemId: string }, +>(rows: T[]) { + const seen = new Set(); + const deduped: T[] = []; + + for (const row of rows) { + if (seen.has(row.partnerContentItemId)) continue; + seen.add(row.partnerContentItemId); + deduped.push(row); + } + + return deduped; +} + +export function dedupeBestChunkPerContentItemSource< + T extends { partnerContentItemId: string; chunkSource: string }, +>(rows: T[]) { + const seen = new Set(); + const deduped: T[] = []; + + for (const row of rows) { + const key = `${row.partnerContentItemId}:${row.chunkSource}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(row); + } + + return deduped; +} + +export function groupPartnerSearchResults({ + rows, + limit, + chunksPerPartner, + toChunkResult, + dedupeKey, + getRowScore = effectiveRowScore, +}: { + rows: PartnerContentSearchRow[]; + limit: number; + chunksPerPartner: number; + toChunkResult: (row: PartnerContentSearchRow, distance: number) => TChunk; + dedupeKey?: (row: PartnerContentSearchRow) => string; + getRowScore?: (row: PartnerContentSearchRow) => number; +}) { + const partners = new Map< + string, + { + partnerId: string; + score: number; + cosineScore: number; + rerankScore: number | null; + chunks: TChunk[]; + chunkKeys: Set; + } + >(); + + for (const row of rows) { + const distance = Number(row.distance); + const cosineScore = toScore(distance); + const rerankScore = row.rerankScore ?? null; + const effectiveScore = getRowScore(row); + + const existing = partners.get(row.partnerId); + const partner = existing ?? { + partnerId: row.partnerId, + score: effectiveScore, + cosineScore, + rerankScore, + chunks: [] as TChunk[], + chunkKeys: new Set(), + }; + + if (existing && effectiveScore > partner.score) { + partner.score = effectiveScore; + partner.cosineScore = cosineScore; + partner.rerankScore = rerankScore; + } + + const chunkKey = dedupeKey?.(row); + const shouldAddChunk = + partner.chunks.length < chunksPerPartner && + (!chunkKey || !partner.chunkKeys.has(chunkKey)); + + if (shouldAddChunk) { + partner.chunks.push(toChunkResult(row, distance)); + if (chunkKey) partner.chunkKeys.add(chunkKey); + } + + partners.set(row.partnerId, partner); + } + + return Array.from(partners.values()) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ chunkKeys, ...partner }) => partner); +} diff --git a/apps/web/lib/partner-content-search/thumbnail-url.ts b/apps/web/lib/partner-content-search/thumbnail-url.ts new file mode 100644 index 00000000000..111f6e2171d --- /dev/null +++ b/apps/web/lib/partner-content-search/thumbnail-url.ts @@ -0,0 +1,23 @@ +export function getPartnerContentThumbnailUrl(thumbnailUrl: string | null) { + if (!thumbnailUrl) return null; + + if (isInstagramCdnUrl(thumbnailUrl)) { + return `/api/partner-content/thumbnail?url=${encodeURIComponent(thumbnailUrl)}`; + } + + return thumbnailUrl; +} + +export function isInstagramCdnUrl(url: string) { + try { + const { hostname, protocol } = new URL(url); + if (protocol !== "https:") return false; + return ( + hostname === "cdninstagram.com" || + hostname.endsWith(".cdninstagram.com") || + (hostname.endsWith(".fbcdn.net") && hostname.startsWith("instagram.")) + ); + } catch { + return false; + } +} diff --git a/apps/web/lib/partner-content-search/timing.ts b/apps/web/lib/partner-content-search/timing.ts new file mode 100644 index 00000000000..dac47788239 --- /dev/null +++ b/apps/web/lib/partner-content-search/timing.ts @@ -0,0 +1,55 @@ +// Stage timing logger for the content search route: one structured console line +// per pipeline stage (elapsed/delta), throttling sub-5ms stages off the allowlist. + +const MIN_PARTNER_CONTENT_SEARCH_TIMING_DELTA_MS = 5; +const PARTNER_CONTENT_SEARCH_ALWAYS_LOG_TIMING_STAGES = new Set([ + "query-embedding-complete", + "vector-candidate-search-complete", + "vector-candidate-hydration-complete", + "vector-search-complete", + "candidate-dedupe-complete", + "chunk-text-hydration-complete", + "rerank-complete", + "partner-candidates-grouped", + "match-summary-base-queries-complete", + "match-summary-content-counts-complete", + "match-summary-recent-content-complete", + "match-summary-followers-complete", + "match-summary-aggregation-complete", + "partner-hydration-complete", + "response-ready", +]); + +export type PartnerContentSearchTimingLogger = ( + stage: string, + metadata?: Record, +) => void; + +export function createPartnerContentSearchTimingLogger( + context: Record, +): PartnerContentSearchTimingLogger { + const startedAt = Date.now(); + let previousAt = startedAt; + + return (stage, metadata = {}) => { + const now = Date.now(); + const elapsedMs = now - startedAt; + const deltaMs = now - previousAt; + previousAt = now; + + if ( + deltaMs < MIN_PARTNER_CONTENT_SEARCH_TIMING_DELTA_MS && + !PARTNER_CONTENT_SEARCH_ALWAYS_LOG_TIMING_STAGES.has(stage) + ) { + return; + } + + console.info("[partner-content-search:timing]", { + stage, + elapsedMs, + deltaMs, + ...context, + ...metadata, + }); + }; +} diff --git a/apps/web/lib/partner-content-search/top-content-ranking.ts b/apps/web/lib/partner-content-search/top-content-ranking.ts new file mode 100644 index 00000000000..9b48e529310 --- /dev/null +++ b/apps/web/lib/partner-content-search/top-content-ranking.ts @@ -0,0 +1,57 @@ +// Pure ranking helpers for the partner detail-pane "Top content" list. Dependency- +// free (constants only) so it stays client-safe — no server-only code (voyage, db) +// in the bundle. Orders that list only; never feeds Topic Fit, the coverage summary, or counts. + +import { PARTNER_CONTENT_SEARCH_TOP_CONTENT } from "./constants"; +import { median } from "./search-utils"; + +// Creator's "typical" view count (median over recent posts with view data), used as +// the engagement baseline — robust to a single viral hit. +export function getViewBaseline( + views: Array, +): number | null { + return median( + views.filter((value): value is number => value != null && value > 0), + ); +} + +// Maps a post's views to a [0,1] engagement score vs. the creator's median, via a +// log-scale logistic: median ~0.5, viral saturates toward 1, below-median near 0 +// (never negative). Null when there's no usable signal (no baseline or no views). +export function getEngagementScore({ + views, + baselineViews, +}: { + views: number | null | undefined; + baselineViews: number | null | undefined; +}): number | null { + if (baselineViews == null || baselineViews <= 0) return null; + if (views == null || views <= 0) return null; + + const { engagementLogSpread } = PARTNER_CONTENT_SEARCH_TOP_CONTENT; + const z = + (Math.log10(1 + views) - Math.log10(1 + baselineViews)) / + engagementLogSpread; + + return 1 / (1 + Math.exp(-z)); +} + +// Relevance-led blend ordering the "Top content" list; degrades to pure relevance +// when there's no engagement signal. +export function getBlendedTopContentScore({ + relevance, + views, + baselineViews, +}: { + relevance: number; + views: number | null | undefined; + baselineViews: number | null | undefined; +}): number { + const engagement = getEngagementScore({ views, baselineViews }); + if (engagement == null) return relevance; + + const { relevanceWeight, engagementWeight } = + PARTNER_CONTENT_SEARCH_TOP_CONTENT; + + return relevanceWeight * relevance + engagementWeight * engagement; +} diff --git a/apps/web/lib/partner-content-search/types.ts b/apps/web/lib/partner-content-search/types.ts new file mode 100644 index 00000000000..f66f399259f --- /dev/null +++ b/apps/web/lib/partner-content-search/types.ts @@ -0,0 +1,63 @@ +import type { PlatformType } from "@prisma/client"; + +export const PARTNER_CONTENT_SEARCH_PLATFORMS = [ + "youtube", + "instagram", + "tiktok", +] as const satisfies readonly PlatformType[]; + +export type PartnerContentPlatform = + (typeof PARTNER_CONTENT_SEARCH_PLATFORMS)[number]; + +const PARTNER_CONTENT_SEARCH_PLATFORM_SET = new Set( + PARTNER_CONTENT_SEARCH_PLATFORMS, +); + +export function isPartnerContentSearchPlatform( + platform: string | null | undefined, +): platform is PartnerContentPlatform { + return Boolean(platform && PARTNER_CONTENT_SEARCH_PLATFORM_SET.has(platform)); +} + +export type VoyageInputType = "document" | "query"; + +export type TranscriptSegment = { + text: string; + startMs?: number | null; + endMs?: number | null; +}; + +export type SentenceTimestamp = { + text: string; + startMs: number | null; + endMs: number | null; + segmentIndex: number; +}; + +export type TranscriptChunk = { + chunkIndex: number; + text: string; + tokenEstimate: number; + startMs: number | null; + endMs: number | null; + sentenceTimestamps: SentenceTimestamp[]; +}; + +export type ChunkTranscriptOptions = { + minTokens?: number; + maxTokens?: number; + overlapTokens?: number; +}; + +export type VoyageEmbeddingMetadata = { + provider: "voyage"; + model: string; + dimensions: number; + inputType: VoyageInputType; +}; + +export type VoyageRerankResult = { + index: number; + relevanceScore: number; + document?: string; +}; diff --git a/apps/web/lib/partner-content-search/voyage.ts b/apps/web/lib/partner-content-search/voyage.ts new file mode 100644 index 00000000000..413192a7ede --- /dev/null +++ b/apps/web/lib/partner-content-search/voyage.ts @@ -0,0 +1,232 @@ +import "server-only"; +import { + PARTNER_CONTENT_SEARCH_LIMITS, + PARTNER_CONTENT_SEARCH_MODELS, +} from "./constants"; +import { + VoyageEmbeddingMetadata, + VoyageInputType, + VoyageRerankResult, +} from "./types"; + +const VOYAGE_API_BASE_URL = "https://api.voyageai.com/v1"; + +type VoyageEmbeddingResponse = { + data: Array<{ + embedding: number[]; + index: number; + }>; +}; + +type VoyageRerankResponse = { + data: Array<{ + index: number; + relevance_score: number; + document?: string; + }>; +}; + +type VoyageFetch = typeof fetch; + +// Typed error so callers can branch on HTTP status (e.g. 429), not the message. +export class VoyageApiError extends Error { + constructor( + readonly status: number, + readonly endpoint: string, + ) { + super(`Voyage ${endpoint} request failed: ${status}`); + this.name = "VoyageApiError"; + } +} + +// Thrown when a Voyage request exceeds the caller's timeout. Search routes catch +// it to fail fast instead of hanging until Vercel kills the function at maxDuration. +export class VoyageTimeoutError extends Error { + constructor( + readonly endpoint: string, + readonly timeoutMs: number, + ) { + super(`Voyage ${endpoint} request timed out after ${timeoutMs}ms`); + this.name = "VoyageTimeoutError"; + } +} + +function isAbortOrTimeoutError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === "TimeoutError" || error.name === "AbortError") + ); +} + +// Serialize an embedding for raw SQL TO_VECTOR(...). Used by the embed + search routes. +export function serializeEmbeddingForVector(embedding: number[]) { + const expectedDimensions = PARTNER_CONTENT_SEARCH_MODELS.embedding.dimensions; + + if (embedding.length !== expectedDimensions) { + throw new Error( + `Expected ${expectedDimensions} embedding dimensions, received ${embedding.length}.`, + ); + } + + return JSON.stringify( + embedding.map((value) => { + if (!Number.isFinite(value)) { + throw new Error("Voyage returned a non-finite embedding value."); + } + + return value; + }), + ); +} + +export function buildVoyageEmbeddingRequest({ + input, + inputType, +}: { + input: string[]; + inputType: VoyageInputType; +}) { + return { + input, + model: PARTNER_CONTENT_SEARCH_MODELS.embedding.model, + input_type: inputType, + output_dimension: PARTNER_CONTENT_SEARCH_MODELS.embedding.dimensions, + output_dtype: PARTNER_CONTENT_SEARCH_MODELS.embedding.outputDtype, + truncation: true, + }; +} + +export function buildVoyageRerankRequest({ + query, + documents, + topK, + returnDocuments = false, +}: { + query: string; + documents: string[]; + topK?: number; + returnDocuments?: boolean; +}) { + return { + query, + documents, + model: PARTNER_CONTENT_SEARCH_MODELS.reranker.model, + top_k: topK ?? PARTNER_CONTENT_SEARCH_LIMITS.rerankerCandidateCount, + return_documents: returnDocuments, + truncation: true, + }; +} + +export function getVoyageEmbeddingMetadata( + inputType: VoyageInputType, +): VoyageEmbeddingMetadata { + return { + provider: "voyage", + model: PARTNER_CONTENT_SEARCH_MODELS.embedding.model, + dimensions: PARTNER_CONTENT_SEARCH_MODELS.embedding.dimensions, + inputType, + }; +} + +export async function embedPartnerContentTexts({ + input, + inputType, + apiKey = process.env.VOYAGE_API_KEY, + fetchImpl = fetch, + timeoutMs, +}: { + input: string[]; + inputType: VoyageInputType; + apiKey?: string; + fetchImpl?: VoyageFetch; + // Opt-in abort timeout; the bulk embed cron omits it so large batches aren't cut off. + timeoutMs?: number; +}) { + if (!apiKey) throw new Error("VOYAGE_API_KEY is not set."); + if (input.length === 0) return []; + + let response: Awaited>; + try { + response = await fetchImpl(`${VOYAGE_API_BASE_URL}/embeddings`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(buildVoyageEmbeddingRequest({ input, inputType })), + signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, + }); + } catch (error) { + if (timeoutMs && isAbortOrTimeoutError(error)) { + throw new VoyageTimeoutError("embeddings", timeoutMs); + } + throw error; + } + + if (!response.ok) { + throw new VoyageApiError(response.status, "embeddings"); + } + + const result = (await response.json()) as VoyageEmbeddingResponse; + + return result.data + .sort((a, b) => a.index - b.index) + .map(({ embedding }) => embedding); +} + +export async function rerankPartnerContent({ + query, + documents, + topK, + apiKey = process.env.VOYAGE_API_KEY, + fetchImpl = fetch, + timeoutMs, +}: { + query: string; + documents: string[]; + topK?: number; + apiKey?: string; + fetchImpl?: VoyageFetch; + // Opt-in abort timeout; search routes pass it so a slow rerank falls back to cosine. + timeoutMs?: number; +}): Promise { + // AI SDK Core doesn't support Voyage reranking; direct wrapper until we share one. + if (!apiKey) throw new Error("VOYAGE_API_KEY is not set."); + if (documents.length === 0) return []; + + let response: Awaited>; + try { + response = await fetchImpl(`${VOYAGE_API_BASE_URL}/rerank`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify( + buildVoyageRerankRequest({ + query, + documents, + topK, + }), + ), + signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, + }); + } catch (error) { + if (timeoutMs && isAbortOrTimeoutError(error)) { + throw new VoyageTimeoutError("rerank", timeoutMs); + } + throw error; + } + + if (!response.ok) { + throw new VoyageApiError(response.status, "rerank"); + } + + const result = (await response.json()) as VoyageRerankResponse; + + return result.data.map(({ relevance_score, ...rest }) => ({ + index: rest.index, + document: rest.document, + relevanceScore: relevance_score, + })); +} diff --git a/apps/web/lib/swr/use-partner-content-search.ts b/apps/web/lib/swr/use-partner-content-search.ts new file mode 100644 index 00000000000..0cedbd1dfb7 --- /dev/null +++ b/apps/web/lib/swr/use-partner-content-search.ts @@ -0,0 +1,116 @@ +import type { ReachTier } from "@/lib/api/network/reach-tiers"; +import { + PARTNER_CONTENT_SEARCH_DEFAULT_CHUNKS_PER_PARTNER, + PARTNER_CONTENT_SEARCH_PARTNER_LIMIT, +} from "@/lib/partner-content-search/constants"; +import type { + PartnerContentMatchEvidence, + PartnerContentMatchSource, + PartnerNetworkContentSearchPartner, + PartnerNetworkContentSearchResponse, +} from "@/lib/zod/schemas/partner-network"; +import type { PlatformType } from "@prisma/client"; +import useSWR from "swr"; +import { useDebounce } from "use-debounce"; +import useWorkspace from "./use-workspace"; + +// Derived from the response schema (single source of truth in partner-network.ts) +// and re-exported under these names so existing consumers keep importing them here. +export type { PartnerContentMatchEvidence, PartnerContentMatchSource }; +export type PartnerContentSearchPartner = PartnerNetworkContentSearchPartner; +export type PartnerContentSearchResponse = PartnerNetworkContentSearchResponse; + +export default function usePartnerContentSearch({ + enabled, + query, + platforms, + reach, + country, + starred, + partnerIds, + limit = PARTNER_CONTENT_SEARCH_PARTNER_LIMIT, + chunksPerPartner = PARTNER_CONTENT_SEARCH_DEFAULT_CHUNKS_PER_PARTNER, + debounceMs = 0, +}: { + enabled: boolean; + query: string; + platforms?: PlatformType[]; + reach?: ReachTier[]; + country?: string; + starred: boolean; + partnerIds?: string[]; + limit?: number; + chunksPerPartner?: number; + // Coalesce rapid filter changes into one request. Voyage search is billed and + // ~seconds, so callers driving it from fast-changing filters pass a small delay. + debounceMs?: number; +}) { + const { id: workspaceId } = useWorkspace(); + + // Stable string signature (not a fresh array each render) so the debounce timer + // only resets when the inputs actually change. The fetcher below reads the live + // values; by the time the debounced key settles they equal this signature. + const keySignature = + enabled && workspaceId + ? JSON.stringify([ + workspaceId, + query, + platforms?.join(",") ?? "", + reach?.join(",") ?? "", + country, + starred, + partnerIds?.join(",") ?? "", + limit, + chunksPerPartner, + ]) + : null; + const [debouncedKeySignature] = useDebounce(keySignature, debounceMs); + + const { data, error, isLoading, isValidating, mutate } = + useSWR( + debouncedKeySignature + ? ["partner-content-search", debouncedKeySignature] + : null, + // The shared `fetcher` from @dub/utils is GET-only; this endpoint takes a + // POST body, so it needs its own fetcher. + async () => { + const response = await fetch( + `/api/network/partners/content-search?workspaceId=${workspaceId}`, + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + query, + limit, + chunksPerPartner, + ...(platforms?.length && { platforms }), + ...(reach?.length && { reach }), + ...(country && { country }), + ...(partnerIds?.length && { partnerIds }), + starred: starred || undefined, + }), + }, + ); + + if (!response.ok) { + throw new Error("Failed to search partner content"); + } + + return response.json(); + }, + { + keepPreviousData: true, + revalidateOnFocus: false, + dedupingInterval: 20000, + }, + ); + + // True from the moment the inputs change — while the fetch key is still + // debouncing AND during the request itself — so the UI can show a loading + // state immediately instead of after the debounce settles. + const isPending = keySignature !== debouncedKeySignature || isValidating; + + return { data, error, isLoading, isPending, mutate }; +} diff --git a/apps/web/lib/zod/schemas/partner-network.ts b/apps/web/lib/zod/schemas/partner-network.ts index e69ec255d79..bdf8c046057 100644 --- a/apps/web/lib/zod/schemas/partner-network.ts +++ b/apps/web/lib/zod/schemas/partner-network.ts @@ -1,4 +1,10 @@ +import { REACH_TIER_KEYS } from "@/lib/api/network/reach-tiers"; import { processKey } from "@/lib/api/links/utils"; +import { + PARTNER_CONTENT_SEARCH_DEFAULT_CHUNKS_PER_PARTNER, + PARTNER_CONTENT_SEARCH_MAX_CHUNKS_PER_PARTNER, + PARTNER_CONTENT_SEARCH_PARTNER_LIMIT, +} from "@/lib/partner-content-search/constants"; import { PlatformType } from "@prisma/client"; import * as z from "zod/v4"; import { booleanQuerySchema, getPaginationQuerySchema } from "./misc"; @@ -41,8 +47,16 @@ export const getNetworkPartnersQuerySchema = z status: NetworkPartnersStatusSchema.default("discover"), country: z.string().optional(), starred: booleanQuerySchema.nullish(), - sortBy: z.enum(["relevance", "subscribers"]).default("relevance"), - platform: z.enum(PlatformType).optional(), + platform: z + .union([z.string(), z.array(z.string())]) + .transform((v) => (Array.isArray(v) ? v : v.split(","))) + .pipe(z.array(z.enum(PlatformType))) + .optional(), + reach: z + .union([z.string(), z.array(z.string())]) + .transform((v) => (Array.isArray(v) ? v : v.split(","))) + .pipe(z.array(z.enum(REACH_TIER_KEYS))) + .optional(), partnerIds: z .union([z.string(), z.array(z.string())]) .transform((v) => (Array.isArray(v) ? v : v.split(","))) @@ -114,3 +128,156 @@ export const invitePartnerFromNetworkSchema = z.object({ emailTitle: z.string().trim().max(255).optional(), emailBody: z.string().trim().max(3000).optional(), }); + +const PARTNER_NETWORK_CONTENT_SEARCH_DEFAULT_PARTNER_LIMIT = 20; + +// POST /api/network/partners/content-search - semantic search over indexed partner content +export const partnerNetworkContentSearchSchema = z.object({ + query: z.string().trim().max(500).optional(), + platforms: z.array(z.enum(PlatformType)).min(1).optional(), + reach: z.array(z.enum(REACH_TIER_KEYS)).min(1).optional(), + country: z.string().trim().min(1).optional(), + partnerIds: z.array(z.string()).min(1).max(100).optional(), + starred: z.boolean().optional(), + limit: z + .number() + .int() + .positive() + .max(PARTNER_CONTENT_SEARCH_PARTNER_LIMIT) + .default(PARTNER_NETWORK_CONTENT_SEARCH_DEFAULT_PARTNER_LIMIT), + chunksPerPartner: z + .number() + .int() + .positive() + .max(PARTNER_CONTENT_SEARCH_MAX_CHUNKS_PER_PARTNER) + .default(PARTNER_CONTENT_SEARCH_DEFAULT_CHUNKS_PER_PARTNER), + rerank: z.boolean().default(true), +}); + +// Content-search response schema — shared by the route validator and SWR types. + +export const partnerContentMatchSourceSchema = z.enum([ + "transcript", + "creatorText", +]); + +export const partnerContentMatchEvidenceSchema = z.object({ + primarySource: partnerContentMatchSourceSchema.nullable(), + sources: z.array(partnerContentMatchSourceSchema), + transcriptScore: z.number().nullable(), + creatorTextScore: z.number().nullable(), + creatorTextWeight: z.number(), + weight: z.number(), +}); + +const partnerContentSearchChunkSchema = z.object({ + chunkId: z.string(), + partnerContentItemId: z.string(), + platform: z.object({ + type: z.string(), + identifier: z.string(), + }), + content: z.object({ + platformContentId: z.string(), + url: z.string(), + type: z.string(), + title: z.string().nullable(), + description: z.string().nullable(), + thumbnailUrl: z.string().nullable(), + publishedAt: z.string().nullable(), + durationMs: z.number().nullable(), + viewCount: z.number().nullable(), + likeCount: z.number().nullable(), + commentCount: z.number().nullable(), + shareCount: z.number().nullable(), + saveCount: z.number().nullable(), + }), + chunk: z.object({ + source: z.string(), + text: z.string(), + startMs: z.number().nullable(), + endMs: z.number().nullable(), + }), + score: z.number(), + cosineScore: z.number().nullish(), + rerankScore: z.number().nullish(), + matchEvidence: partnerContentMatchEvidenceSchema.optional(), +}); + +const partnerContentSearchContentMatchSchema = z.object({ + partnerContentItemId: z.string(), + platform: z.string(), + publishedAt: z.string().nullable(), + viewCount: z.number().nullable(), + matched: z.boolean(), + matchScore: z.number().nullable(), + matchEvidence: partnerContentMatchEvidenceSchema, +}); + +const partnerContentTopicFitBandSchema = z.enum([ + "consistent", + "frequent", + "occasional", + "one-off", + "none", +]); + +const partnerContentSearchMatchSummarySchema = z.object({ + matchedContentCount: z.number(), + strongMatchedContentCount: z.number(), + partialMatchedContentCount: z.number(), + transcriptMatchedContentCount: z.number(), + creatorTextMatchedContentCount: z.number(), + creatorTextOnlyContentCount: z.number(), + weightedMatchedContentCount: z.number(), + weightedMatchedContentScore: z.number(), + recentContentCount: z.number(), + totalContentCount: z.number(), + topicFit: z.number(), + band: partnerContentTopicFitBandSchema, + followers: z.number().nullable(), + medianViews: z.number().nullable(), + lastOnTopicAt: z.string().nullable(), + topPlatforms: z.array(z.string()), + platforms: z.array(z.string()), + sources: z.array(z.string()), + oldestPublishedAt: z.string().nullable(), + newestPublishedAt: z.string().nullable(), + contentMatches: z.array(partnerContentSearchContentMatchSchema), +}); + +const partnerContentSearchResponsePartnerSchema = z.object({ + partnerId: z.string(), + score: z.number(), + cosineScore: z.number().nullish(), + rerankScore: z.number().nullish(), + // Profile fields on `partner` only; z.custom skips re-parsing nested transforms. + partner: z.custom>(), + chunks: z.array(partnerContentSearchChunkSchema), + matchSummary: partnerContentSearchMatchSummarySchema.nullable(), +}); + +export const partnerNetworkContentSearchResponseSchema = z.object({ + success: z.boolean(), + query: z.string().nullable(), + platforms: z.array(z.enum(PlatformType)).nullable(), + country: z.string().nullable(), + candidateChunkCount: z.number(), + embeddingModel: z.string(), + reranked: z.boolean(), + rerankModel: z.string().nullable(), + resultCount: z.number(), + partners: z.array(partnerContentSearchResponsePartnerSchema), +}); + +export type PartnerNetworkContentSearchResponse = z.infer< + typeof partnerNetworkContentSearchResponseSchema +>; +export type PartnerNetworkContentSearchPartner = + PartnerNetworkContentSearchResponse["partners"][number]; +export type PartnerContentMatchEvidence = z.infer< + typeof partnerContentMatchEvidenceSchema +>; +export type PartnerContentMatchSource = z.infer< + typeof partnerContentMatchSourceSchema +>; diff --git a/apps/web/prisma/manual/partner-content-search-vector-index.sql b/apps/web/prisma/manual/partner-content-search-vector-index.sql new file mode 100644 index 00000000000..52ef6bbc9ab --- /dev/null +++ b/apps/web/prisma/manual/partner-content-search-vector-index.sql @@ -0,0 +1,10 @@ +-- Manual PlanetScale VECTOR index for partner natural-language search. Prisma can't +-- express the VECTOR INDEX distance/options, so create it here after Prisma has pushed +-- the embedding column (the same-named @@index in the schema just stops db push from +-- dropping it). The name + distance must match PARTNER_CONTENT_CHUNK_VECTOR_INDEX / +-- _DISTANCE in lib/partner-content-search/constants.ts; vector-index-sync.test.ts +-- enforces it, and the search routes' FORCE INDEX hint depends on it. +CREATE /*vt+ QUERY_TIMEOUT_MS=0 */ + VECTOR INDEX partner_content_chunk_embedding_cosine_idx + ON PartnerContentChunk(embedding) + SECONDARY_ENGINE_ATTRIBUTE='{"type":"spann", "distance":"cosine"}'; diff --git a/apps/web/prisma/schema/partner-content-search.prisma b/apps/web/prisma/schema/partner-content-search.prisma new file mode 100644 index 00000000000..781f5a49536 --- /dev/null +++ b/apps/web/prisma/schema/partner-content-search.prisma @@ -0,0 +1,91 @@ +// Partner natural-language search schema. +// PartnerContentChunk.embedding is an Unsupported VECTOR column, so it's absent from +// Prisma Client and all vector reads/writes are raw SQL. The real cosine index is +// created via prisma/manual/partner-content-search-vector-index.sql (Prisma can't +// express VECTOR INDEX options); the @@index below just preserves it across db push. + +enum PartnerContentTranscriptFetchStatus { + pending + fetched + notAvailable + error +} + +enum PartnerContentChunkSource { + metadata + transcript +} + +model PartnerContentItem { + id String @id + partnerId String + partnerPlatformId String + platformContentId String + url String @db.Text + contentType String + title String? @db.Text + description String? @db.Text + thumbnailUrl String? @db.Text // may require internal image cache for long-term storage; MVP field + publishedAt DateTime? + durationMs Int? + viewCount BigInt? + likeCount BigInt? + commentCount BigInt? + shareCount BigInt? + saveCount BigInt? + transcriptFetchStatus PartnerContentTranscriptFetchStatus @default(pending) + transcriptCreditsUsed Int? + transcriptLastAttemptedAt DateTime? + transcriptHasTimestamps Boolean @default(false) + normalizedTranscript String? @db.LongText + transcriptHash String? + embeddingModel String? + totalChunkCount Int @default(0) + embeddedChunkCount Int @default(0) + lastFetchedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade) + partnerPlatform PartnerPlatform @relation(fields: [partnerPlatformId], references: [id], onDelete: Cascade) + chunks PartnerContentChunk[] + + @@unique([partnerPlatformId, platformContentId]) + @@index([partnerId, partnerPlatformId]) + @@index([partnerId, embeddingModel, publishedAt]) + @@index(publishedAt) +} + +model PartnerContentChunk { + id String @id + partnerContentItemId String + partnerId String + source PartnerContentChunkSource @default(transcript) + chunkIndex Int + chunkText String @db.Text + startMs Int? + endMs Int? + textHash String + embedding Unsupported("vector(1024)")? + embeddingModel String + + // Denormalized from Partner / PartnerPlatform so the vector ANN can pre-filter + // inline (no joins) on the index fast path; both fields are effectively static. + country String? // denormalized from Partner.country + platformType PlatformType? // denormalized from PartnerPlatform.type + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + partnerContentItem PartnerContentItem @relation(fields: [partnerContentItemId], references: [id], onDelete: Cascade) + partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade) + + @@unique([partnerContentItemId, source, chunkIndex, embeddingModel]) + @@index(partnerId) + // Covers the empty-query "recent content" list path, which filters on + // embeddingModel while joining chunks by partnerContentItemId. + @@index([partnerContentItemId, embeddingModel]) + // Drift guard so db push keeps the manual cosine index (see file header and + // partner-content-search-vector-index.sql). Name verified by vector-index-sync.test.ts. + @@index([embedding], map: "partner_content_chunk_embedding_cosine_idx") +} diff --git a/apps/web/prisma/schema/partner.prisma b/apps/web/prisma/schema/partner.prisma index 57542d208f1..21efdf71678 100644 --- a/apps/web/prisma/schema/partner.prisma +++ b/apps/web/prisma/schema/partner.prisma @@ -111,6 +111,8 @@ model Partner { platforms PartnerPlatform[] referredApplications ProgramApplicationEvent[] submittedLeads SubmittedLead[] + partnerContentItems PartnerContentItem[] + partnerContentChunks PartnerContentChunk[] @@index(country) @@index(networkStatus) diff --git a/apps/web/prisma/schema/platform.prisma b/apps/web/prisma/schema/platform.prisma index f3d5a285bdb..c8f423a4d95 100644 --- a/apps/web/prisma/schema/platform.prisma +++ b/apps/web/prisma/schema/platform.prisma @@ -22,8 +22,11 @@ model PartnerPlatform { updatedAt DateTime @updatedAt verifiedAt DateTime? lastCheckedAt DateTime? + contentLastFetchedAt DateTime? // last time we fetched recent content from ScrapeCreators for this platform + latestContentUrl String? @db.Text // the most recent content URL we found for this platform - partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade) + partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade) + partnerContentItems PartnerContentItem[] @@unique([partnerId, type]) @@index([type, verifiedAt, lastCheckedAt]) diff --git a/apps/web/tests/partner-content-search/chunk-transcript.test.ts b/apps/web/tests/partner-content-search/chunk-transcript.test.ts new file mode 100644 index 00000000000..e4d2cd7245e --- /dev/null +++ b/apps/web/tests/partner-content-search/chunk-transcript.test.ts @@ -0,0 +1,102 @@ +import { + chunkTranscriptSegments, + estimateTokenCount, + hashTranscript, + normalizeTranscriptText, +} from "@/lib/partner-content-search/chunk-transcript"; +import { describe, expect, test } from "vitest"; + +describe("partner content transcript chunking", () => { + test("normalizes transcript whitespace", () => { + expect(normalizeTranscriptText(" hello\n\n world\tagain ")).toBe( + "hello world again", + ); + }); + + test("creates timestamp-aware chunks with overlap", () => { + const segments = Array.from({ length: 8 }, (_, index) => ({ + text: `Sentence ${index + 1} talks about AI partner marketing.`, + startMs: index * 1_000, + endMs: (index + 1) * 1_000, + })); + + const chunks = chunkTranscriptSegments(segments, { + minTokens: 12, + maxTokens: 18, + overlapTokens: 8, + }); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks[0].chunkIndex).toBe(0); + expect(chunks[0].startMs).toBe(0); + expect(chunks[0].endMs).toBeGreaterThan(0); + expect(chunks[0].sentenceTimestamps[0]).toMatchObject({ + text: "Sentence 1 talks about AI partner marketing.", + startMs: 0, + endMs: 1000, + segmentIndex: 0, + }); + + expect(chunks[0].text).toContain("Sentence 2"); + expect(chunks[1].text).toContain("Sentence 2"); + }); + + test("splits long segment text into sentence timestamp maps", () => { + const chunks = chunkTranscriptSegments( + [ + { + text: "First sentence. Second sentence! Third sentence?", + startMs: 0, + endMs: 3_000, + }, + ], + { + minTokens: 20, + maxTokens: 50, + overlapTokens: 0, + }, + ); + + expect(chunks).toHaveLength(1); + expect(chunks[0].sentenceTimestamps).toEqual([ + { + text: "First sentence.", + startMs: 0, + endMs: 1000, + segmentIndex: 0, + }, + { + text: "Second sentence!", + startMs: 1000, + endMs: 2000, + segmentIndex: 0, + }, + { + text: "Third sentence?", + startMs: 2000, + endMs: 3000, + segmentIndex: 0, + }, + ]); + }); + + test("hashes normalized transcript content deterministically", () => { + const hashA = hashTranscript([ + { text: "Hello world", startMs: 0, endMs: 1000 }, + ]); + const hashB = hashTranscript([ + { text: "Hello world", startMs: 0, endMs: 1000 }, + ]); + const hashC = hashTranscript([ + { text: "Hello world again", startMs: 0, endMs: 1000 }, + ]); + + expect(hashA).toBe(hashB); + expect(hashA).not.toBe(hashC); + }); + + test("uses a stable token estimate", () => { + expect(estimateTokenCount("one two three four")).toBe(6); + expect(estimateTokenCount("")).toBe(0); + }); +}); diff --git a/apps/web/tests/partner-content-search/normalize-content.test.ts b/apps/web/tests/partner-content-search/normalize-content.test.ts new file mode 100644 index 00000000000..a151c76328b --- /dev/null +++ b/apps/web/tests/partner-content-search/normalize-content.test.ts @@ -0,0 +1,43 @@ +import { normalizeInstagramUserPost } from "@/lib/partner-content-search/ingestion/normalize-content"; +import { describe, expect, test } from "vitest"; + +describe("partner content normalization", () => { + test("canonicalizes Instagram reel URLs and uses captions as titles", () => { + const item = normalizeInstagramUserPost({ + id: "3590000000000000000", + pk: "3590000000000000000", + code: "DZqeWQvJ1Fk", + url: "https://www.instagram.com/andresthedesigner/p/DZqeWQvJ1Fk/", + media_type: 2, + product_type: "clips", + taken_at: 1781659229, + caption: { + text: "Framer 3.0 is pretty nice imo\n#framer #webdesign", + }, + play_count: 1234, + ig_play_count: null, + like_count: 123, + comment_count: 12, + display_uri: null, + image_versions2: { + candidates: [{ url: "https://cdn.example.com/thumbnail.jpg" }], + }, + video_versions: [{ url: "https://cdn.example.com/video.mp4" }], + video_duration: 12.3, + has_audio: true, + }); + + expect(item).toMatchObject({ + platformContentId: "DZqeWQvJ1Fk", + url: "https://www.instagram.com/reel/DZqeWQvJ1Fk/", + contentType: "reel", + title: "Framer 3.0 is pretty nice imo", + description: "Framer 3.0 is pretty nice imo\n#framer #webdesign", + thumbnailUrl: "https://cdn.example.com/thumbnail.jpg", + viewCount: 1234, + likeCount: 123, + commentCount: 12, + transcriptEligible: true, + }); + }); +}); diff --git a/apps/web/tests/partner-content-search/ranking.test.ts b/apps/web/tests/partner-content-search/ranking.test.ts new file mode 100644 index 00000000000..26ca5b569a5 --- /dev/null +++ b/apps/web/tests/partner-content-search/ranking.test.ts @@ -0,0 +1,163 @@ +import { + createContentMatchEvidence, + deriveTopicFit, + getEvidenceMatchScore, + getEvidenceTopicScore, + getMatchedSourceScore, +} from "@/lib/partner-content-search/ranking"; +import { PARTNER_CONTENT_SEARCH_TOPIC_FIT } from "@/lib/partner-content-search/constants"; +import { describe, expect, test, vi } from "vitest"; + +// Mock server-only module (ranking.ts imports search-utils.ts -> voyage.ts). +vi.mock("server-only", () => ({})); + +describe("partner content ranking", () => { + test("weights creator-entered video text below transcript evidence", () => { + const creatorTextOnlyVideo = createContentMatchEvidence({ + contentType: "video", + transcriptScore: null, + creatorTextScore: 0.8, + }); + const transcriptVideo = createContentMatchEvidence({ + contentType: "video", + transcriptScore: 0.8, + creatorTextScore: null, + }); + const creatorTextOnlyPhoto = createContentMatchEvidence({ + contentType: "photo", + transcriptScore: null, + creatorTextScore: 0.8, + }); + + expect(creatorTextOnlyVideo.weight).toBe(0.4); + expect(getEvidenceMatchScore(creatorTextOnlyVideo)).toBe(0.4); + expect(getEvidenceMatchScore(transcriptVideo)).toBe(1); + expect(getEvidenceMatchScore(creatorTextOnlyPhoto)).toBe(1); + expect(getEvidenceTopicScore(creatorTextOnlyVideo)).toBe(0.4); + expect(getEvidenceTopicScore(transcriptVideo)).toBe(1); + }); + + test("gates a recent post as matched only via reranker threshold or cutoff distance", () => { + const { rerankMatchThreshold } = PARTNER_CONTENT_SEARCH_TOPIC_FIT; + + // Reranker score is authoritative when present: at/above the threshold matches, + // below it does not — regardless of distance. + expect(getMatchedSourceScore({ rerankScore: rerankMatchThreshold })).toBe( + rerankMatchThreshold, + ); + expect( + getMatchedSourceScore({ rerankScore: rerankMatchThreshold - 0.01 }), + ).toBeNull(); + + // Without a reranker score, fall back to the cosine cutoff: within cutoff + // matches (scored as 1 - distance), beyond it does not. + expect( + getMatchedSourceScore({ bestDistance: 0.3, cutoffDistance: 0.4 }), + ).toBe(0.7); + expect( + getMatchedSourceScore({ bestDistance: 0.5, cutoffDistance: 0.4 }), + ).toBeNull(); + + // No retrieval evidence at all → no match (lexical title/description text + // alone can no longer create or boost a match). + expect(getMatchedSourceScore({})).toBeNull(); + expect(getMatchedSourceScore({ bestDistance: 0.3 })).toBeNull(); + }); + + test("does not let high creator text override weaker transcript evidence at full weight", () => { + const evidence = createContentMatchEvidence({ + contentType: "video", + transcriptScore: 0.6, + creatorTextScore: 0.9, + }); + + expect(evidence.primarySource).toBe("transcript"); + expect(getEvidenceMatchScore(evidence)).toBeCloseTo(0.783, 3); + expect(getEvidenceTopicScore(evidence)).toBe(1); + }); + + test("topic fit rewards stronger evidence for the same matched coverage", () => { + const weak = deriveTopicFit({ + matchedContentCount: 20, + weightedMatchedContentCount: 20, + weightedMatchedContentScore: 7.433, + recentContentCount: 20, + }); + const strong = deriveTopicFit({ + matchedContentCount: 20, + weightedMatchedContentCount: 20, + weightedMatchedContentScore: 20, + recentContentCount: 20, + }); + + expect(strong.topicFit).toBeGreaterThan(weak.topicFit); + }); + + test("topic fit discounts tiny samples with the same coverage and strength", () => { + const smallSample = deriveTopicFit({ + matchedContentCount: 3, + weightedMatchedContentCount: 3, + weightedMatchedContentScore: 3, + recentContentCount: 3, + }); + const largeSample = deriveTopicFit({ + matchedContentCount: 30, + weightedMatchedContentCount: 30, + weightedMatchedContentScore: 30, + recentContentCount: 30, + }); + + expect(largeSample.topicFit).toBeGreaterThan(smallSample.topicFit); + }); + + test("topic fit lets broad strong coverage reach the high 90s", () => { + const topicFit = deriveTopicFit({ + matchedContentCount: 28, + weightedMatchedContentCount: 28, + weightedMatchedContentScore: 28, + recentContentCount: 28, + }); + + expect(topicFit.topicFit).toBeGreaterThanOrEqual(98); + }); + + test("topic fit demotes a thin one-off below a deep but diluted creator", () => { + // One on-topic post, nothing else: topically "pure" but unproven. + const oneOff = deriveTopicFit({ + matchedContentCount: 1, + weightedMatchedContentCount: 1, + weightedMatchedContentScore: 0.9, + recentContentCount: 1, + }); + // Many on-topic posts diluted by off-topic ones (the creator also posts + // about other things). Lower purity, but a real body of on-topic work. + const deepButDiluted = deriveTopicFit({ + matchedContentCount: 30, + weightedMatchedContentCount: 30, + weightedMatchedContentScore: 30, + recentContentCount: 100, + }); + + expect(oneOff.band).toBe("one-off"); + expect(oneOff.topicFit).toBeLessThan(50); + expect(deepButDiluted.topicFit).toBeGreaterThan(oneOff.topicFit); + }); + + test("topic fit rewards on-topic depth over a shallow diluted creator", () => { + const deepButDiluted = deriveTopicFit({ + matchedContentCount: 30, + weightedMatchedContentCount: 30, + weightedMatchedContentScore: 30, + recentContentCount: 100, + }); + // Only a couple of on-topic posts amid many off-topic ones: little evidence. + const shallowDiluted = deriveTopicFit({ + matchedContentCount: 2, + weightedMatchedContentCount: 2, + weightedMatchedContentScore: 2, + recentContentCount: 50, + }); + + expect(deepButDiluted.topicFit).toBeGreaterThan(shallowDiluted.topicFit); + }); +}); diff --git a/apps/web/tests/partner-content-search/top-content-ranking.test.ts b/apps/web/tests/partner-content-search/top-content-ranking.test.ts new file mode 100644 index 00000000000..b08bda6042c --- /dev/null +++ b/apps/web/tests/partner-content-search/top-content-ranking.test.ts @@ -0,0 +1,100 @@ +import { PARTNER_CONTENT_SEARCH_TOP_CONTENT } from "@/lib/partner-content-search/constants"; +import { + getBlendedTopContentScore, + getEngagementScore, + getViewBaseline, +} from "@/lib/partner-content-search/top-content-ranking"; +import { describe, expect, test } from "vitest"; + +describe("top content ranking", () => { + test("view baseline is the median and ignores zero/missing views", () => { + expect(getViewBaseline([100, 200, 300, null, 0, undefined])).toBe(200); + }); + + test("view baseline is robust to a single viral outlier", () => { + const typical = [100, 120, 90, 110, 105]; + const withViral = [...typical, 5_000_000]; + + // One viral post barely moves the median (a mean would jump to ~830k). + expect(getViewBaseline(withViral)!).toBeLessThan(150); + expect(getViewBaseline(withViral)!).toBeGreaterThan(100); + }); + + test("a median post scores about 0.5 engagement", () => { + const score = getEngagementScore({ views: 1000, baselineViews: 1000 }); + expect(score).toBeCloseTo(0.5, 5); + }); + + test("a viral post saturates toward 1 without running away", () => { + const big = getEngagementScore({ views: 1_000_000, baselineViews: 1000 })!; + const huge = getEngagementScore({ views: 100_000_000, baselineViews: 1000 })!; + + expect(big).toBeGreaterThan(0.9); + expect(big).toBeLessThan(1); + // 100x more views than the already-viral post barely moves the score: the + // outlier is bounded, it can't dominate the blend. + expect(huge - big).toBeLessThan(0.05); + expect(huge).toBeLessThan(1); + }); + + test("a below-median post lands near 0 but is never negative", () => { + const score = getEngagementScore({ views: 10, baselineViews: 10_000 })!; + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThan(0.5); + }); + + test("engagement is unavailable without a usable signal", () => { + expect(getEngagementScore({ views: 1000, baselineViews: null })).toBeNull(); + expect(getEngagementScore({ views: 1000, baselineViews: 0 })).toBeNull(); + expect(getEngagementScore({ views: null, baselineViews: 1000 })).toBeNull(); + expect(getEngagementScore({ views: 0, baselineViews: 1000 })).toBeNull(); + }); + + test("a high-relevance typical post outranks a low-relevance viral post", () => { + const baselineViews = 1000; + const relevantTypical = getBlendedTopContentScore({ + relevance: 0.9, + views: 1000, + baselineViews, + }); + const irrelevantViral = getBlendedTopContentScore({ + relevance: 0.3, + views: 10_000_000, + baselineViews, + }); + + expect(relevantTypical).toBeGreaterThan(irrelevantViral); + }); + + test("among similarly-relevant posts the higher-view one wins", () => { + const baselineViews = 1000; + const lowViews = getBlendedTopContentScore({ + relevance: 0.7, + views: 200, + baselineViews, + }); + const highViews = getBlendedTopContentScore({ + relevance: 0.7, + views: 50_000, + baselineViews, + }); + + expect(highViews).toBeGreaterThan(lowViews); + }); + + test("falls back to pure relevance when there's no engagement signal", () => { + expect( + getBlendedTopContentScore({ + relevance: 0.42, + views: null, + baselineViews: null, + }), + ).toBe(0.42); + }); + + test("blend weights sum to one so a perfect post scores one", () => { + const { relevanceWeight, engagementWeight } = + PARTNER_CONTENT_SEARCH_TOP_CONTENT; + expect(relevanceWeight + engagementWeight).toBeCloseTo(1, 5); + }); +}); diff --git a/apps/web/tests/partner-content-search/vector-index-sync.test.ts b/apps/web/tests/partner-content-search/vector-index-sync.test.ts new file mode 100644 index 00000000000..8bcbcbf805d --- /dev/null +++ b/apps/web/tests/partner-content-search/vector-index-sync.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { + PARTNER_CONTENT_CHUNK_VECTOR_DISTANCE, + PARTNER_CONTENT_CHUNK_VECTOR_INDEX, +} from "@/lib/partner-content-search/constants"; + +// The index name + distance metric are hardcoded in both the .sql DDL and the Prisma +// @@index (neither can import the TS constant), so assert they match the constants. +describe("partner content search vector index", () => { + const read = (relativePath: string) => + readFileSync(join(process.cwd(), relativePath), "utf8"); + + test("manual DDL matches the index name and distance constants", () => { + const ddl = read( + "prisma/manual/partner-content-search-vector-index.sql", + ); + + expect(ddl).toContain(`VECTOR INDEX ${PARTNER_CONTENT_CHUNK_VECTOR_INDEX}`); + expect(ddl).toContain(`"distance":"${PARTNER_CONTENT_CHUNK_VECTOR_DISTANCE}"`); + }); + + test("prisma @@index map matches the index name constant", () => { + const schema = read("prisma/schema/partner-content-search.prisma"); + + expect(schema).toContain(`map: "${PARTNER_CONTENT_CHUNK_VECTOR_INDEX}"`); + }); +}); diff --git a/apps/web/tests/partner-content-search/voyage.test.ts b/apps/web/tests/partner-content-search/voyage.test.ts new file mode 100644 index 00000000000..111f1bd8593 --- /dev/null +++ b/apps/web/tests/partner-content-search/voyage.test.ts @@ -0,0 +1,52 @@ +import { + buildVoyageEmbeddingRequest, + buildVoyageRerankRequest, + getVoyageEmbeddingMetadata, +} from "@/lib/partner-content-search/voyage"; +import { describe, expect, test, vi } from "vitest"; + +// Mock server-only module (voyage.ts imports it) +vi.mock("server-only", () => ({})); + +describe("partner content Voyage helpers", () => { + test("builds document embedding requests with model metadata", () => { + expect( + buildVoyageEmbeddingRequest({ + input: ["AI sales assistant transcript chunk"], + inputType: "document", + }), + ).toEqual({ + input: ["AI sales assistant transcript chunk"], + model: "voyage-4", + input_type: "document", + output_dimension: 1024, + output_dtype: "float", + truncation: true, + }); + }); + + test("builds query embedding metadata", () => { + expect(getVoyageEmbeddingMetadata("query")).toEqual({ + provider: "voyage", + model: "voyage-4", + dimensions: 1024, + inputType: "query", + }); + }); + + test("builds rerank requests with default candidate limits", () => { + expect( + buildVoyageRerankRequest({ + query: "fitness influencers", + documents: ["video about workouts", "video about AI agents"], + }), + ).toEqual({ + query: "fitness influencers", + documents: ["video about workouts", "video about AI agents"], + model: "rerank-2.5", + top_k: 150, + return_documents: false, + truncation: true, + }); + }); +}); diff --git a/apps/web/ui/partners/partner-about.tsx b/apps/web/ui/partners/partner-about.tsx index 2af8482a711..cbfd9a700b7 100644 --- a/apps/web/ui/partners/partner-about.tsx +++ b/apps/web/ui/partners/partner-about.tsx @@ -13,6 +13,9 @@ import { Icon, InfoTooltip } from "@dub/ui"; export function PartnerAbout({ partner, error, + hideSocials = false, + hideDescription = false, + hideMonthlyTraffic = false, }: { partner?: Pick< EnrolledPartnerExtendedProps, @@ -25,32 +28,41 @@ export function PartnerAbout({ | "platforms" >; error?: any; + // Network-browse sheets surface website/socials, description, and traffic in the + // right sidebar instead, so they hide them here to avoid duplication. + hideSocials?: boolean; + hideDescription?: boolean; + hideMonthlyTraffic?: boolean; }) { return partner ? ( <> -
-

- Description -

-

- {partner.description || ( - - No description provided - - )} -

-
+ {!hideDescription && ( +
+

+ Description +

+

+ {partner.description || ( + + No description provided + + )} +

+
+ )} -
-

- Website and socials -

- -
+ {!hideSocials && ( +
+

+ Website and socials +

+ +
+ )} {Boolean(partner.industryInterests?.length) && (
@@ -97,7 +109,7 @@ export function PartnerAbout({
)} - {Boolean(partner.monthlyTraffic) && ( + {!hideMonthlyTraffic && Boolean(partner.monthlyTraffic) && (

diff --git a/apps/web/ui/partners/partner-basic-fields.tsx b/apps/web/ui/partners/partner-basic-fields.tsx new file mode 100644 index 00000000000..aef4987ea00 --- /dev/null +++ b/apps/web/ui/partners/partner-basic-fields.tsx @@ -0,0 +1,165 @@ +import { + AdminNetworkPartner, + EnrolledPartnerExtendedProps, + NetworkPartnerProps, +} from "@/lib/types"; +import { CalendarIcon, Globe, OfficeBuilding, TimestampTooltip } from "@dub/ui"; +import { + TriangleWarning, + UserArrowRight, + Users, + VerifiedBadge, +} from "@dub/ui/icons"; +import { COUNTRIES, formatDate, formatDateTimeSmart } from "@dub/utils"; +import { CircleMinus } from "lucide-react"; +import { ReactNode, createElement } from "react"; +import { + getPayoutMethodIconConfig, + getPayoutMethodLabel, +} from "./payouts/payout-method-config"; +import { ReferredByPartner } from "./referred-by-partner"; + +export type BasicField = { + id: string; + icon: React.ReactElement; + text: ReactNode | null | undefined; + /** When set, the row is wrapped in TimestampTooltip (local / UTC / unix). */ + timestamp?: Date | string | number; + /** Optional outer wrapper (e.g. ConversionScoreTooltip) around the row content. */ + wrapper?: React.ComponentType<{ children: React.ReactNode }>; +}; + +export function getBasicFields({ + partner, + isEnrolled, + isAdmin, + isNetwork, + canCreateReferralReward, +}: { + partner?: + | EnrolledPartnerExtendedProps + | NetworkPartnerProps + | AdminNetworkPartner; + isEnrolled: boolean; + isAdmin: boolean; + isNetwork: boolean; + canCreateReferralReward: boolean; +}): BasicField[] { + let basicFields: BasicField[] = [ + { + id: "country", + icon: partner?.country ? ( + {`Flag + ) : ( + + ), + text: partner?.country ? COUNTRIES[partner.country] : "Planet Earth", + }, + { + id: "companyName", + icon: , + text: partner ? partner.companyName || null : undefined, + }, + ]; + + if ((isEnrolled || isAdmin) && partner) { + const isPendingApplication = + "status" in partner && partner.status === "pending"; + + // This branch runs only for enrolled/admin partners. Cast to surface the + // payout/referral fields that NetworkPartnerProps lacks — compile-time only, + // with no runtime effect (absent fields already read as undefined). + const detailPartner = partner as EnrolledPartnerExtendedProps; + + basicFields = basicFields.concat([ + { + id: "createdAt", + icon: isPendingApplication ? ( + + ) : ( + + ), + text: isPendingApplication ? ( + + + Applied {formatDate(partner.createdAt)} + + + ) : ( + `${isPendingApplication ? "Applied" : "Partner since"} ${formatDate(partner.createdAt)}` + ), + timestamp: isPendingApplication ? undefined : partner.createdAt, + }, + { + id: "payoutMethod" as const, + icon: detailPartner.defaultPayoutMethod ? ( + createElement( + getPayoutMethodIconConfig(detailPartner.defaultPayoutMethod).Icon, + { className: "size-3.5 shrink-0" }, + ) + ) : ( + + ), + text: + detailPartner.defaultPayoutMethod && detailPartner.payoutsEnabledAt + ? `${getPayoutMethodLabel(detailPartner.defaultPayoutMethod)} connected ${formatDateTimeSmart(detailPartner.payoutsEnabledAt)}` + : "No payout method connected", + ...(detailPartner.payoutsEnabledAt + ? { timestamp: detailPartner.payoutsEnabledAt } + : {}), + }, + // TODO: once more partners verify their identity, we can show this by default + ...(partner.identityVerifiedAt + ? [ + { + id: "identityVerifiedAt", + icon: partner.identityVerifiedAt ? ( + + ) : ( + + ), + text: partner.identityVerifiedAt + ? `Identity verified ${formatDate(partner.identityVerifiedAt, { month: "short" })}` + : "Identity not verified", + ...(partner.identityVerifiedAt + ? { timestamp: partner.identityVerifiedAt } + : {}), + }, + ] + : []), + + // Referred by + ...(isEnrolled && canCreateReferralReward + ? [ + { + id: "referredBy", + icon: , + text: , + }, + ] + : []), + ]); + } + + if (isNetwork) { + basicFields = basicFields.concat([ + { + id: "joinedAt", + icon: , + text: partner ? `Joined ${formatDate(partner.createdAt!)}` : undefined, + timestamp: partner?.createdAt, + }, + ]); + } + + return basicFields; +} diff --git a/apps/web/ui/partners/partner-info-cards.tsx b/apps/web/ui/partners/partner-info-cards.tsx index e385386af80..8664070b43e 100644 --- a/apps/web/ui/partners/partner-info-cards.tsx +++ b/apps/web/ui/partners/partner-info-cards.tsx @@ -1,44 +1,15 @@ -import { useAttributeReferringPartnerModal } from "@/lib/partner-referrals/components/attribute-referring-partner-modal"; -import { usePartnerReferral } from "@/lib/partner-referrals/hooks/use-partner-referral"; import { getPlanCapabilities } from "@/lib/plan-capabilities"; import useGroup from "@/lib/swr/use-group"; import useWorkspace from "@/lib/swr/use-workspace"; import { AdminNetworkPartner, - BountyListProps, EnrolledPartnerExtendedProps, NetworkPartnerProps, - RewardProps, } from "@/lib/types"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; -import { INACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners"; import { usePartnerGroupHistorySheet } from "@/ui/activity-logs/partner-group-history-sheet"; -import { - Button, - CalendarIcon, - CopyButton, - Globe, - Heart, - OfficeBuilding, - TimestampTooltip, - Trophy, -} from "@dub/ui"; -import { - TriangleWarning, - UserArrowRight, - Users, - VerifiedBadge, -} from "@dub/ui/icons"; -import { - COUNTRIES, - fetcher, - formatDate, - formatDateTimeSmart, -} from "@dub/utils"; -import { CircleMinus } from "lucide-react"; -import Link from "next/link"; -import { Fragment, ReactNode, createElement } from "react"; -import useSWR from "swr"; +import { CopyButton, InfoTooltip, TimestampTooltip } from "@dub/ui"; +import { Fragment, ReactNode } from "react"; import { PartnerApplicationRiskSummary } from "./fraud-risks/partner-application-risk-summary"; import { PartnerApplicationRiskBanner, @@ -46,20 +17,14 @@ import { } from "./fraud-risks/partner-risk-banner"; import { PartnerRiskIndicator } from "./fraud-risks/partner-risk-indicator"; import { PartnerAvatar } from "./partner-avatar"; -import { PartnerInfoGroup } from "./partner-info-group"; +import { PARTNER_PLATFORM_FIELDS } from "@/lib/partners/partner-platforms"; +import { monthlyTrafficAmountsMap } from "@/lib/partners/partner-profile"; +import { getBasicFields } from "./partner-basic-fields"; +import { PartnerInfoRewardsCard } from "./partner-info-rewards-card"; +import { PartnerInfoTagsCard } from "./partner-info-tags-card"; import { PartnerNetworkStatusBadge } from "./partner-network/partner-network-status-badge"; import { PartnerStarButton } from "./partner-star-button"; import { PartnerStatusBadgeWithTooltip } from "./partner-status-badge-with-tooltip"; -import { PartnerTagsList } from "./partner-tags-list"; -import { - getPayoutMethodIconConfig, - getPayoutMethodLabel, -} from "./payouts/payout-method-config"; -import { ProgramRewardList } from "./program-reward-list"; -import { - UpdatePartnerTagsModal, - useUpdatePartnerTagsModal, -} from "./update-partner-tags-modal"; type PartnerInfoCardsProps = { showFraudIndicator?: boolean; @@ -72,22 +37,18 @@ type PartnerInfoCardsProps = { // Only used for a controlled group selector that doesn't persist the selection itself selectedGroupId?: string | null; setSelectedGroupId?: (groupId: string) => void; + + /** + * Network-browse (marketplace) mode: partner isn't enrolled, so hide the + * group/rewards/bounties block and show website & socials in the sidebar instead. + */ + browseMode?: boolean; } & ( | { type?: "enrolled"; partner?: EnrolledPartnerExtendedProps } | { type: "network"; partner?: NetworkPartnerProps } | { type: "admin"; partner?: AdminNetworkPartner } ); -type BasicField = { - id: string; - icon: React.ReactElement; - text: ReactNode | null | undefined; - /** When set, the row is wrapped in TimestampTooltip (local / UTC / unix). */ - timestamp?: Date | string | number; - /** Optional outer wrapper (e.g. ConversionScoreTooltip) around the row content. */ - wrapper?: React.ComponentType<{ children: React.ReactNode }>; -}; - export function PartnerInfoCards({ type, partner, @@ -97,6 +58,7 @@ export function PartnerInfoCards({ setSelectedGroupId, showFraudIndicator = true, showApplicationRiskAnalysis = false, + browseMode = false, }: PartnerInfoCardsProps) { const { id: workspaceId, slug: workspaceSlug, plan } = useWorkspace(); @@ -123,123 +85,37 @@ export function PartnerInfoCards({ { keepPreviousData: false }, ); - const { data: bounties, error: errorBounties } = useSWR( - workspaceId && partner && isEnrolled - ? `/api/bounties?workspaceId=${workspaceId}&partnerId=${partner.id}` - : null, - fetcher, - ); - - let basicFields: BasicField[] = [ - { - id: "country", - icon: partner?.country ? ( - {`Flag - ) : ( - - ), - text: partner?.country ? COUNTRIES[partner.country] : "Planet Earth", - }, - { - id: "companyName", - icon: , - text: partner ? partner.companyName || null : undefined, - }, - ]; - - if ((isEnrolled || isAdmin) && partner) { - const isPendingApplication = - "status" in partner && partner.status === "pending"; - - basicFields = basicFields.concat([ - { - id: "createdAt", - icon: isPendingApplication ? ( - - ) : ( - - ), - text: isPendingApplication ? ( - - - Applied {formatDate(partner.createdAt)} - - - ) : ( - `${isPendingApplication ? "Applied" : "Partner since"} ${formatDate(partner.createdAt)}` - ), - timestamp: isPendingApplication ? undefined : partner.createdAt, - }, - { - id: "payoutMethod" as const, - icon: partner.defaultPayoutMethod ? ( - createElement( - getPayoutMethodIconConfig(partner.defaultPayoutMethod).Icon, - { className: "size-3.5 shrink-0" }, - ) - ) : ( - - ), - text: - partner.defaultPayoutMethod && partner.payoutsEnabledAt - ? `${getPayoutMethodLabel(partner.defaultPayoutMethod)} connected ${formatDateTimeSmart(partner.payoutsEnabledAt)}` - : "No payout method connected", - ...(partner.payoutsEnabledAt - ? { timestamp: partner.payoutsEnabledAt } - : {}), - }, - // TODO: once more partners verify their identity, we can show this by default - ...(partner.identityVerifiedAt - ? [ - { - id: "identityVerifiedAt", - icon: partner.identityVerifiedAt ? ( - - ) : ( - - ), - text: partner.identityVerifiedAt - ? `Identity verified ${formatDate(partner.identityVerifiedAt, { month: "short" })}` - : "Identity not verified", - ...(partner.identityVerifiedAt - ? { timestamp: partner.identityVerifiedAt } - : {}), - }, - ] - : []), - - // Referred by - ...(isEnrolled && canCreateReferralReward - ? [ - { - id: "referredBy", - icon: , - text: , - }, - ] - : []), - ]); - } + const basicFields = getBasicFields({ + partner, + isEnrolled, + isAdmin, + isNetwork, + canCreateReferralReward, + }); - if (isNetwork) { - basicFields = basicFields.concat([ - { - id: "joinedAt", - icon: , - text: partner ? `Joined ${formatDate(partner.createdAt!)}` : undefined, - timestamp: partner?.createdAt, - }, - ]); - } + // Browse mode: website/socials rendered as compact rows (matching the detail + // rows above) instead of the bordered platform cards. + const socialFields = + browseMode && partner && "platforms" in partner + ? PARTNER_PLATFORM_FIELDS.map((field) => ({ + label: field.label, + icon: field.icon, + ...field.data(partner.platforms), + })).filter((field) => field.value && field.href) + : []; + + // Browse mode: description + monthly traffic move into the sidebar profile card. + const description = + browseMode && partner && "description" in partner + ? partner.description + : null; + const monthlyTrafficLabel = + browseMode && + partner && + "monthlyTraffic" in partner && + partner.monthlyTraffic + ? (monthlyTrafficAmountsMap[partner.monthlyTraffic]?.label ?? null) + : null; return (
@@ -368,227 +244,82 @@ export function PartnerInfoCards({ ); })}
- {isEnrolled && partner && } - {partner && isEnrolled && showApplicationRiskAnalysis && ( - + {description && ( +
+

+ About +

+

+ {description} +

+
)} -

-
- {!isAdmin && ( -
- {/* Group */} -
- {isEnrolled && ( -
-

- Group -

- - {partner && partner.status !== "pending" && hasActivityLogs && ( - + {partner && isEnrolled && showApplicationRiskAnalysis && ( + + )} +
- setShowUpdatePartnerTagsModal(true)} - mode="link" - /> -
- ); -} - -function ReferredByPartner({ - partner, -}: { - partner: Pick< - EnrolledPartnerExtendedProps, - "id" | "name" | "image" | "email" | "groupId" | "totalCommissions" - >; -}) { - const { slug } = useWorkspace(); - - const { referral, loading, error } = usePartnerReferral({ - partnerId: partner?.id, - }); - const { - AttributeReferringPartnerModal, - setShowAttributeReferringPartnerModal, - } = useAttributeReferringPartnerModal({ partner }); - - if (error) { - return null; - } - - if (loading) { - return ( - -
-
-
- - ); - } - - // Has a referring partner - if (referral && referral.referredBy) { - return ( - - Referred by - - - {referral.referredBy.name} - - - ); - } - - return ( - <> - - - + {!isAdmin && !browseMode && ( + + )} +
); } diff --git a/apps/web/ui/partners/partner-info-rewards-card.tsx b/apps/web/ui/partners/partner-info-rewards-card.tsx new file mode 100644 index 00000000000..98a28b015dc --- /dev/null +++ b/apps/web/ui/partners/partner-info-rewards-card.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { + AdminNetworkPartner, + BountyListProps, + EnrolledPartnerExtendedProps, + GroupProps, + NetworkPartnerProps, + RewardProps, +} from "@/lib/types"; +import { INACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners"; +import { Button, Heart, Trophy } from "@dub/ui"; +import { fetcher } from "@dub/utils"; +import Link from "next/link"; +import { ReactNode } from "react"; +import useSWR from "swr"; +import { PartnerInfoGroup } from "./partner-info-group"; +import { ProgramRewardList } from "./program-reward-list"; + +export function PartnerInfoRewardsCard({ + partner, + workspaceId, + workspaceSlug, + isEnrolled, + group, + partnerGroupHistorySheet, + setGroupHistoryOpen, + hasActivityLogs, + selectedGroupId, + setSelectedGroupId, +}: { + partner?: + | EnrolledPartnerExtendedProps + | NetworkPartnerProps + | AdminNetworkPartner; + workspaceId?: string; + workspaceSlug?: string; + isEnrolled: boolean; + group?: GroupProps; + partnerGroupHistorySheet: ReactNode; + setGroupHistoryOpen: (open: boolean) => void; + hasActivityLogs: boolean; + selectedGroupId?: string | null; + setSelectedGroupId?: (groupId: string) => void; +}) { + const { data: bounties, error: errorBounties } = useSWR( + workspaceId && partner && isEnrolled + ? `/api/bounties?workspaceId=${workspaceId}&partnerId=${partner.id}` + : null, + fetcher, + ); + + return ( +
+ {/* Group */} +
+ {isEnrolled && ( +
+

+ Group +

+ + {partner && + (partner as EnrolledPartnerExtendedProps).status !== "pending" && + hasActivityLogs && ( +
+ )} + + {partnerGroupHistorySheet} + {partner ? ( + + ) : ( +
+ )} +
+ + {isEnrolled && + (partner as EnrolledPartnerExtendedProps | undefined)?.status === + "approved" && ( + <> + {/* Rewards */} +
+

+ Rewards +

+ {group ? ( + group.clickReward || + group.leadReward || + group.saleReward || + group.discount ? ( + r !== null)} + discount={group.discount} + variant="plain" + className="text-content-subtle gap-2 text-xs leading-4" + iconClassName="size-3.5" + /> + ) : ( + No rewards + ) + ) : ( +
+ )} +
+ {/* Eligible bounties */} +
+

+ Eligible Bounties +

+ {bounties ? ( + bounties.length ? ( +
+ {bounties.map((bounty) => { + const Icon = + bounty.type === "performance" ? Trophy : Heart; + return ( + + + + {bounty.name} + + + ); + })} +
+ ) : ( +

+ No eligible bounties +

+ ) + ) : errorBounties ? ( +

+ Failed to load bounties +

+ ) : ( +
+ )} +
+ + )} +
+ ); +} diff --git a/apps/web/ui/partners/partner-info-tags-card.tsx b/apps/web/ui/partners/partner-info-tags-card.tsx new file mode 100644 index 00000000000..5743c8bede7 --- /dev/null +++ b/apps/web/ui/partners/partner-info-tags-card.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { EnrolledPartnerExtendedProps } from "@/lib/types"; +import { PartnerTagsList } from "./partner-tags-list"; +import { + UpdatePartnerTagsModal, + useUpdatePartnerTagsModal, +} from "./update-partner-tags-modal"; + +export function PartnerInfoTagsCard({ + partner, +}: { + partner: EnrolledPartnerExtendedProps; +}) { + const { showUpdatePartnerTagsModal, setShowUpdatePartnerTagsModal } = + useUpdatePartnerTagsModal(); + + return ( +
+ +
+ + Tags + + + +
+ setShowUpdatePartnerTagsModal(true)} + mode="link" + /> +
+ ); +} diff --git a/apps/web/ui/partners/partner-sheet-tabs.tsx b/apps/web/ui/partners/partner-sheet-tabs.tsx index 5332ed527ff..75fb3b7a528 100644 --- a/apps/web/ui/partners/partner-sheet-tabs.tsx +++ b/apps/web/ui/partners/partner-sheet-tabs.tsx @@ -8,10 +8,13 @@ export function PartnerSheetTabs({ partnerId, currentTabId, setCurrentTabId, + aboutLabel = "About", }: { partnerId: string; currentTabId: string; setCurrentTabId: Dispatch>; + // Override the first tab's label (e.g. "Content matches" in network browse). + aboutLabel?: string; }) { const { count: commentsCount } = usePartnerCommentsCount( { @@ -26,7 +29,7 @@ export function PartnerSheetTabs({ () => [ { id: "about", - label: "About", + label: aboutLabel, icon: User, }, { @@ -40,7 +43,7 @@ export function PartnerSheetTabs({ icon: Msg, }, ], - [commentsCount], + [commentsCount, aboutLabel], ); const layoutGroupId = useId(); diff --git a/apps/web/ui/partners/referred-by-partner.tsx b/apps/web/ui/partners/referred-by-partner.tsx new file mode 100644 index 00000000000..dae25d9d2c2 --- /dev/null +++ b/apps/web/ui/partners/referred-by-partner.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useAttributeReferringPartnerModal } from "@/lib/partner-referrals/components/attribute-referring-partner-modal"; +import { usePartnerReferral } from "@/lib/partner-referrals/hooks/use-partner-referral"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { EnrolledPartnerExtendedProps } from "@/lib/types"; +import Link from "next/link"; +import { PartnerAvatar } from "./partner-avatar"; + +export function ReferredByPartner({ + partner, +}: { + partner: Pick< + EnrolledPartnerExtendedProps, + "id" | "name" | "image" | "email" | "groupId" | "totalCommissions" + >; +}) { + const { slug } = useWorkspace(); + + const { referral, loading, error } = usePartnerReferral({ + partnerId: partner?.id, + }); + + const { + AttributeReferringPartnerModal, + setShowAttributeReferringPartnerModal, + } = useAttributeReferringPartnerModal({ partner }); + + if (error) { + return null; + } + + if (loading) { + return ( + +
+
+
+ + ); + } + + // Has a referring partner + if (referral && referral.referredBy) { + return ( + + Referred by + + + {referral.referredBy.name} + + + ); + } + + return ( + <> + + + + ); +}