diff --git a/.env.example b/.env.example index f06b41f..0989ea9 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ ATPROTO_BASE_URL=http://127.0.0.1:3000 # ATSTORE_IDENTIFIER= # ATSTORE_APP_PASSWORD= # ATSTORE_SERVICE=https://bsky.social -# Optional: DID of the store publisher (avoids an extra login when checking product-claim eligibility). +# Optional: DID of the store publisher (avoids Bluesky login on listing detail, snapshot refresh, and claim checks). # ATSTORE_REPO_DID=did:plc:... # ATSTORE_PROFILE_DISPLAY_NAME=AT Store # ATSTORE_WEBSITE_URL=https://at.store diff --git a/docker-compose.yml b/docker-compose.yml index 03735c4..a3a9dbd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,34 @@ services: + tap: + image: ghcr.io/bluesky-social/indigo/tap:latest + ports: + - "2480:2480" + environment: + TAP_DATABASE_URL: sqlite:///data/tap.db + TAP_NO_REPLAY: "true" + TAP_SIGNAL_COLLECTION: fyi.atstore.listing.detail + TAP_COLLECTION_FILTERS: >- + fyi.atstore.listing.detail, + fyi.atstore.listing.review, + fyi.atstore.listing.reviewReply, + fyi.atstore.listing.favorite, + site.standard.publication, + site.standard.document, + com.germnetwork.declaration, + fund.at.actor.declaration, + fund.at.funding.contribute, + fund.at.funding.channel, + fund.at.funding.plan, + fund.at.graph.dependency + volumes: + - tap_data:/data + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost:2480/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + postgres: image: pgvector/pgvector:pg17 environment: @@ -17,3 +47,4 @@ services: volumes: postgres_data: + tap_data: diff --git a/drizzle/0041_store_listing_page_snapshots.sql b/drizzle/0041_store_listing_page_snapshots.sql new file mode 100644 index 0000000..c813ae4 --- /dev/null +++ b/drizzle/0041_store_listing_page_snapshots.sql @@ -0,0 +1,8 @@ +CREATE TABLE "store_listing_page_snapshots" ( + "store_listing_id" uuid PRIMARY KEY NOT NULL, + "payload" jsonb NOT NULL, + "payload_version" integer DEFAULT 1 NOT NULL, + "refreshed_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +ALTER TABLE "store_listing_page_snapshots" ADD CONSTRAINT "store_listing_page_snapshots_store_listing_id_store_listings_id_fk" FOREIGN KEY ("store_listing_id") REFERENCES "public"."store_listings"("id") ON DELETE cascade ON UPDATE no action; diff --git a/drizzle/0042_ensure_store_listing_page_snapshots.sql b/drizzle/0042_ensure_store_listing_page_snapshots.sql new file mode 100644 index 0000000..031e54a --- /dev/null +++ b/drizzle/0042_ensure_store_listing_page_snapshots.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS "store_listing_page_snapshots" ( + "store_listing_id" uuid PRIMARY KEY NOT NULL, + "payload" jsonb NOT NULL, + "payload_version" integer DEFAULT 1 NOT NULL, + "refreshed_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "store_listing_page_snapshots" ADD CONSTRAINT "store_listing_page_snapshots_store_listing_id_store_listings_id_fk" FOREIGN KEY ("store_listing_id") REFERENCES "public"."store_listings"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4489dd0..9580a61 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -281,6 +281,20 @@ "when": 1779100000000, "tag": "0040_store_listing_oauth_discovery", "breakpoints": true + }, + { + "idx": 41, + "version": "7", + "when": 1779200000000, + "tag": "0041_store_listing_page_snapshots", + "breakpoints": true + }, + { + "idx": 42, + "version": "7", + "when": 1779300000000, + "tag": "0042_ensure_store_listing_page_snapshots", + "breakpoints": true } ] } diff --git a/package.json b/package.json index aa5ffc0..74d3b0e 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "listing:add": "tsx scripts/add-manual-directory-listing.ts", "listing:publish-store": "tsx -r dotenv/config scripts/publish-store-listing-to-atproto.ts", "listing:rehydrate-from-at-uri": "tsx -r dotenv/config scripts/rehydrate-store-listing-from-at-uri.ts", + "listing:backfill-atstore": "tsx -r dotenv/config scripts/atstore-listings-backfill.ts", "listing:restore-from-backup-db": "tsx -r dotenv/config scripts/restore-store-listing-from-backup-db.ts", "oauth:detect-scopes": "tsx scripts/detect-listing-oauth-scopes.ts", "listing:oauth-probes-sync": "tsx -r dotenv/config scripts/sync-listing-oauth-probes.ts", "listing:oauth-lexicon-hub-refresh": "tsx -r dotenv/config scripts/refresh-oauth-lexicon-hub.ts", + "listing:page-snapshots-refresh": "tsx -r dotenv/config scripts/refresh-listing-page-snapshots.ts", "listing:oauth-lexicon-keys-backfill": "tsx -r dotenv/config scripts/backfill-oauth-lexicon-keys.ts", "listing:oauth-discover-metadata": "tsx -r dotenv/config scripts/discover-listing-oauth-metadata.ts", "db:generate": "drizzle-kit generate", diff --git a/scripts/atstore-listings-backfill.ts b/scripts/atstore-listings-backfill.ts new file mode 100644 index 0000000..23ffc02 --- /dev/null +++ b/scripts/atstore-listings-backfill.ts @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * Bulk backfill `store_listings` from every `fyi.atstore.listing.detail` on the @store repo. + * + * Local Tap with `TAP_SIGNAL_COLLECTION` only discovers owner repos over the firehose; it does + * not reliably mirror the full @store publisher catalog. Run this after `pnpm tap:consumer` setup + * when Postgres is missing most directory rows. + * + * pnpm listing:backfill-atstore + * + * Requires `DATABASE_URL`. Uses `ATSTORE_IDENTIFIER` + `ATSTORE_APP_PASSWORD` (or + * `ATSTORE_REPO_DID`) like `listing:rehydrate-from-at-uri`. + */ +import "dotenv/config"; +import { + paginateListRecords, + rkeyFromCollectionAtUri, +} from "#/lib/atproto/list-records"; +import { COLLECTION } from "#/lib/atproto/nsids"; +import { getAtstoreRepoDid } from "#/lib/atproto/publish-directory-listing"; +import { resolveAtprotoPdsBaseUrl } from "#/lib/atproto/resolve-atproto-pds"; +import { + tryParseListingDetailRecord, + upsertDirectoryListingFromTap, +} from "#/lib/atproto/tap-listing-sync"; + +import { db, dbClient } from "../src/db/index.server"; + +async function main() { + if (!process.env.DATABASE_URL?.trim()) { + console.error("[atstore-listings-backfill] DATABASE_URL is required"); + process.exit(1); + } + + const did = await getAtstoreRepoDid(); + const pds = await resolveAtprotoPdsBaseUrl(did); + if (!pds) { + console.error( + `[atstore-listings-backfill] no PDS for ${did}; cannot listRecords`, + ); + process.exit(1); + } + + console.log( + `[atstore-listings-backfill] listing.detail on ${did} via ${pds}…`, + ); + + let ok = 0; + let failed = 0; + let skipped = 0; + + for await (const row of paginateListRecords( + pds, + did, + COLLECTION.listingDetail, + )) { + const rkey = rkeyFromCollectionAtUri(row.uri, COLLECTION.listingDetail); + if (!rkey) { + skipped++; + continue; + } + const body = row.value as Record | null | undefined; + if (!body || typeof body !== "object") { + skipped++; + continue; + } + const parsed = tryParseListingDetailRecord(body); + if (!parsed.ok) { + console.warn( + `[atstore-listings-backfill] skip rkey=${rkey}: ${parsed.stage} ${parsed.reason}`, + ); + skipped++; + continue; + } + try { + await upsertDirectoryListingFromTap({ + db, + did, + rkey, + record: parsed.record, + trustedPublisher: true, + }); + ok++; + if (ok % 50 === 0) { + console.log(`[atstore-listings-backfill] … ${String(ok)} upserted`); + } + } catch (error) { + failed++; + console.error( + `[atstore-listings-backfill] failed rkey=${rkey} slug=${parsed.record.slug}`, + error, + ); + } + } + + console.log( + `[atstore-listings-backfill] done ok=${String(ok)} failed=${String(failed)} skipped=${String(skipped)}`, + ); + if (failed > 0) { + process.exit(1); + } +} + +main() + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(() => dbClient.end({ timeout: 5 })); diff --git a/scripts/refresh-listing-page-snapshots.ts b/scripts/refresh-listing-page-snapshots.ts new file mode 100644 index 0000000..d1229fa --- /dev/null +++ b/scripts/refresh-listing-page-snapshots.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * Rebuild `store_listing_page_snapshots` for every public listing (slow: OAuth, funding, reviews). + * + * pnpm listing:page-snapshots-refresh + * pnpm listing:page-snapshots-refresh -- --slug=murmul + */ +import "dotenv/config"; +import { refreshListingPageSnapshot } from "#/lib/listing-page-snapshot"; +import { and, asc, eq, isNotNull } from "drizzle-orm"; + +function ts(): string { + return new Date().toISOString(); +} + +function slugArg(): string | null { + const raw = process.argv.find((a) => a.startsWith("--slug=")); + if (!raw) return null; + const value = raw.slice("--slug=".length).trim(); + return value.length > 0 ? value : null; +} + +async function main() { + if (!process.env.DATABASE_URL?.trim()) { + console.error( + `[refresh-listing-page-snapshots] ${ts()} DATABASE_URL is required`, + ); + process.exit(1); + } + + const onlySlug = slugArg(); + const { db, dbClient } = await import("#/db/index.server"); + const schema = await import("#/db/schema"); + + const rows = await db + .select({ id: schema.storeListings.id, slug: schema.storeListings.slug }) + .from(schema.storeListings) + .where( + onlySlug + ? and( + eq(schema.storeListings.slug, onlySlug), + isNotNull(schema.storeListings.slug), + ) + : isNotNull(schema.storeListings.slug), + ) + .orderBy(asc(schema.storeListings.slug)); + + if (onlySlug && rows.length === 0) { + console.error( + `[refresh-listing-page-snapshots] ${ts()} no listing for slug=${onlySlug}`, + ); + process.exit(1); + } + + console.log( + `[refresh-listing-page-snapshots] ${ts()} refreshing ${String(rows.length)} listing(s)…`, + ); + + let ok = 0; + let failed = 0; + const startedAt = Date.now(); + + for (const row of rows) { + try { + await refreshListingPageSnapshot(db, row.id); + ok++; + if (ok % 25 === 0 || ok === rows.length) { + console.log( + `[refresh-listing-page-snapshots] ${ts()} progress ok=${String(ok)} failed=${String(failed)} slug=${row.slug ?? row.id}`, + ); + } + } catch (error) { + failed++; + const cause = + error instanceof Error && "cause" in error && error.cause != null + ? error.cause + : null; + console.warn( + `[refresh-listing-page-snapshots] ${ts()} failed id=${row.id} slug=${row.slug ?? "?"}`, + error instanceof Error ? (error.stack ?? error.message) : error, + cause == null ? undefined : { cause }, + ); + if ( + failed === 1 && + cause instanceof Error && + /store_listing_page_snapshots/i.test(cause.message) && + /does not exist|relation/i.test(cause.message) + ) { + console.error( + `[refresh-listing-page-snapshots] ${ts()} table missing — run: pnpm db:migrate`, + ); + } + } + } + + const elapsedMs = Date.now() - startedAt; + console.log( + `[refresh-listing-page-snapshots] ${ts()} done ok=${String(ok)} failed=${String(failed)} elapsedMs=${String(elapsedMs)}`, + ); + + await dbClient.end({ timeout: 5 }).catch(() => {}); + if (failed > 0) { + process.exit(1); + } +} + +main().catch((error) => { + console.error( + `[refresh-listing-page-snapshots] fatal`, + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exit(1); +}); diff --git a/scripts/sync-listing-oauth-probes.ts b/scripts/sync-listing-oauth-probes.ts index 8a17e8f..40b7369 100644 --- a/scripts/sync-listing-oauth-probes.ts +++ b/scripts/sync-listing-oauth-probes.ts @@ -25,6 +25,7 @@ */ import "dotenv/config"; import * as schema from "#/db/schema"; +import { refreshListingPageSnapshot } from "#/lib/listing-page-snapshot"; import { refreshOAuthLexiconHubSnapshot } from "#/lib/oauth-lexicon-hub-snapshot.server"; import { probeOAuthListingAuth } from "#/lib/oauth-listing-auth-probe"; import { extractOAuthLexiconKeysForStorefrontProbe } from "#/lib/oauth-scope-lexicon-keys"; @@ -240,6 +241,19 @@ async function main() { target: schema.storeListingOAuthProbes.storeListingId, set: omitPk(payload), }); + + try { + await refreshListingPageSnapshot(db, listing.id); + } catch (refreshError) { + log("warn", "page_snapshot_refresh_failed", { + listingId: listing.id, + slug: listing.slug, + error: + refreshError instanceof Error + ? refreshError.message + : String(refreshError), + }); + } } async function persistError( diff --git a/src/db/schema.ts b/src/db/schema.ts index b9e9b0d..be44467 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,5 @@ import type { ListingLink } from "#/lib/atproto/listing-record"; +import type { StoreListingPageSnapshotPayload } from "#/lib/listing-page-snapshot.types"; import type { DirectoryOAuthLexiconHubData } from "#/lib/oauth-lexicon-hub.types"; import type { OAuthAuthProbeReport } from "#/lib/oauth-listing-auth-probe"; import type { StoreListingOauthDiscoveryDetail } from "#/lib/oauth-listing-oauth-discovery.types"; @@ -766,6 +767,24 @@ export const oauthLexiconHubSnapshot = pgTable("oauth_lexicon_hub_snapshot", { computedAt: timestamp("computed_at", { withTimezone: true }).notNull(), }); +/** + * Precomputed public product-page bundle (reviews/mentions/related/oauth/funding). + * Rebuilt on listing/review/mention/probe/fund changes and by backfill cron. + */ +export const storeListingPageSnapshots = pgTable( + "store_listing_page_snapshots", + { + storeListingId: uuid("store_listing_id") + .primaryKey() + .references(() => storeListings.id, { onDelete: "cascade" }), + payload: jsonb("payload") + .$type() + .notNull(), + payloadVersion: integer("payload_version").notNull().default(1), + refreshedAt: timestamp("refreshed_at", { withTimezone: true }).notNull(), + }, +); + /** Ordered homepage hero slots managed from admin. */ export const homePageHeroListings = pgTable( "home_page_hero_listings", diff --git a/src/integrations/tanstack-query/api-directory-listings.functions.ts b/src/integrations/tanstack-query/api-directory-listings.functions.ts index 7347c48..e3c665b 100644 --- a/src/integrations/tanstack-query/api-directory-listings.functions.ts +++ b/src/integrations/tanstack-query/api-directory-listings.functions.ts @@ -2,6 +2,7 @@ import type { Database } from "#/db/index.server"; import type { StoreListing } from "#/db/schema"; import type { ListingLink } from "#/lib/atproto/listing-record"; import type { FundingDetail } from "#/lib/atproto/load-funding-summaries"; +import type { StoreListingPageSnapshotPayload } from "#/lib/listing-page-snapshot.types"; import type { SummaryScopeHumanRow } from "#/lib/oauth-listing-auth-probe"; import type { AtprotoSessionContext } from "#/middleware/auth"; import type { SQL } from "drizzle-orm"; @@ -51,23 +52,21 @@ import { upsertListingReviewFromTap } from "#/lib/atproto/tap-review-sync"; import { fetchBlueskyHandleForDid, fetchBlueskyPublicProfileFields, + fetchBlueskyPublicProfilesBatch, resolveBlueskyHandleToDid, } from "#/lib/bluesky-public-profile"; import { bskyAppPostUrlFromAtUri } from "#/lib/bsky-app-urls"; import { resolveGermDmHrefFromRecordJson } from "#/lib/germ-network-dm"; -import { loadLexiconRecordDescriptionsForWorkspace } from "#/lib/lexicon-local-record-description"; import { httpsListingImageUrlOrNull, publicMediaUrlOrNull, } from "#/lib/listing-image-url"; +import { LISTING_PAGE_SNAPSHOT_VERSION } from "#/lib/listing-page-snapshot.types"; import { computeOAuthLexiconHubData, getOAuthLexiconHubSnapshot, } from "#/lib/oauth-lexicon-hub-snapshot.server"; -import { - oauthClientDistinctTokensFromPublishedScopeLine, - probeOAuthListingAuth, -} from "#/lib/oauth-listing-auth-probe"; +import { oauthClientDistinctTokensFromPublishedScopeLine } from "#/lib/oauth-scope-include-parse"; import { compareOAuthLexiconKeysForDisplayOrder, extractOAuthLexiconKeysForStorefrontProbe, @@ -76,6 +75,8 @@ import { parseOAuthLexiconKey, } from "#/lib/oauth-scope-lexicon-keys"; import { findEligibleProductClaimsForDid } from "#/lib/product-claim-eligibility"; +import { PRODUCT_REVIEW_PREVIEW_COUNT } from "#/lib/product-reviews"; +import { serializeForJsonColumn } from "#/lib/serialize-for-json-column"; import { trendingScoreSortEnabled } from "#/lib/trending/config"; import { adminFnMiddleware, @@ -117,6 +118,7 @@ import { buildDirectoryCategoryTree, findDirectoryCategoryNode, flattenDirectoryCategoryTree, + getAppEcosystemRootCategoryId, getDirectoryCategoryDescendantIds, getDirectoryCategoryOption, primaryCategorySlug, @@ -779,8 +781,14 @@ const getRelatedDirectoryListingsInput = z.object({ limit: z.number().int().min(1).max(8).default(4), }); +const getRelatedListingsInCategoryInput = z.object({ + id: z.string().uuid(), + limit: z.number().int().min(1).max(8).default(3), +}); + const getDirectoryListingReviewsInput = z.object({ id: z.string().min(1), + limit: z.number().int().min(1).max(50).optional(), }); const PRODUCT_SITE_UPDATES_LIMIT = 40; @@ -1219,6 +1227,8 @@ const rescanListingOAuthProbeDev = createServerFn({ method: "POST" }) } try { + const { probeOAuthListingAuth } = + await import("#/lib/oauth-listing-auth-probe"); const report = await probeOAuthListingAuth(storefrontUrl); const successfulClientUrl = report.clientMetadata.find( (c) => c.result.ok, @@ -2167,19 +2177,7 @@ const getDirectoryCategoriesQueryOptions = queryOptions({ const getDirectoryCategoryTree = createServerFn({ method: "GET" }) .middleware([dbMiddleware]) - .handler(async ({ context }) => { - const table = context.schema.storeListings; - const rows = await context.db - .select({ - categorySlugs: table.categorySlugs, - }) - .from(table) - .where(listingPublicWhere(table)); - - return buildDirectoryCategoryTree( - rows.flatMap((row) => row.categorySlugs ?? []), - ); - }); + .handler(async ({ context }) => loadDirectoryCategoryTreeForContext(context)); const getDirectoryCategoryTreeQueryOptions = queryOptions({ queryKey: ["storeListings", "categoryTree"], @@ -2477,6 +2475,8 @@ const getLexiconRecordMainDescriptionForNsid = createServerFn({ .inputValidator(getLexiconRecordMainDescriptionForNsidInput) .handler(async ({ data }) => { const { nsid } = getLexiconRecordMainDescriptionForNsidInput.parse(data); + const { loadLexiconRecordDescriptionsForWorkspace } = + await import("#/lib/lexicon-local-record-description"); const map = await loadLexiconRecordDescriptionsForWorkspace([nsid]); return map[nsid] ?? null; }); @@ -2609,40 +2609,11 @@ const getRelatedAppsBySharedLexiconKeys = createServerFn({ method: "GET" }) .inputValidator(getRelatedAppsBySharedLexiconKeysInput) .handler(async ({ data, context }) => { const input = getRelatedAppsBySharedLexiconKeysInput.parse(data); - const listTable = context.schema.storeListings; - const probeTable = context.schema.storeListingOAuthProbes; - - const keys = await loadCrossAppMatchingOAuthLexiconKeysForListing( + return loadRelatedAppsBySharedLexiconKeysForContext( context, input.listingId, + input.limit, ); - - if (keys == null) { - return { - listings: [], - } satisfies RelatedAppsByOAuthLexiconPayload; - } - - /** Require materialized keys so overlap queries stay index-friendly. */ - const candidateRows = await context.db - .select(getListingSelect(listTable)) - .from(listTable) - .innerJoin(probeTable, eq(probeTable.storeListingId, listTable.id)) - .where( - and( - listingPublicWhere(listTable), - sqlCategorySlugsMatchesLike(listTable.categorySlugs, "apps/%"), - ne(listTable.id, input.listingId), - sql`cardinality(${probeTable.oauthLexiconKeys}) > 0`, - arrayOverlaps(probeTable.oauthLexiconKeys, keys), - ), - ) - .orderBy(...orderByPopularListingSort(listTable)) - .limit(input.limit); - - return { - listings: candidateRows.map((row) => toListingCard(row)), - } satisfies RelatedAppsByOAuthLexiconPayload; }); function getRelatedAppsBySharedLexiconKeysQueryOptions( @@ -2838,26 +2809,14 @@ const getDirectoryListingDetail = createServerFn({ method: "GET" }) return null; } - const session = await getAtprotoSessionForRequest(getRequest()); - - const [oauthProbe, isStoreManaged, germDmHref, fundingDetail] = - await Promise.all([ - fetchStoreListingOAuthProbe(row.id, context), - computeIsStoreManaged(row), - germDmHrefForMirroredRepoDid({ - db: context.db, - schemaMod: context.schema, - repoDid: row.productAccountDid, - session, - }), - loadFundingDetailForDid(context.db, row.productAccountDid), - ]); - return toListingDetail(row, { - isStoreManaged, - oauthProbe, - germDmHref, - fundingDetail, + isStoreManaged: computeIsStoreManagedSync( + row, + resolveAtstoreRepoDidFromEnv(), + ), + oauthProbe: null, + germDmHref: null, + fundingDetail: null, }); }); @@ -2905,29 +2864,32 @@ const getDirectoryListingDetailBySlug = createServerFn({ method: "GET" }) return null; } - const session = await getAtprotoSessionForRequest(getRequest()); - - const [oauthProbe, isStoreManaged, germDmHref, fundingDetail] = - await Promise.all([ - fetchStoreListingOAuthProbe(row.id, context), - computeIsStoreManaged(row), - germDmHrefForMirroredRepoDid({ - db: context.db, - schemaMod: context.schema, - repoDid: row.productAccountDid, - session, - }), - loadFundingDetailForDid(context.db, row.productAccountDid), - ]); - return toListingDetail(row, { - isStoreManaged, - oauthProbe, - germDmHref, - fundingDetail, + isStoreManaged: computeIsStoreManagedSync( + row, + resolveAtstoreRepoDidFromEnv(), + ), + oauthProbe: null, + germDmHref: null, + fundingDetail: null, }); }); +type ListingStoreManagedRow = { + atUri: string | null; + repoDid: string | null; + migratedFromAtUri: string | null; +}; + +/** + * AT Store publisher DID without network I/O. Set `ATSTORE_REPO_DID` in prod so + * product pages never call `getAtstoreRepoDid()` → PDS login on the hot path. + */ +function resolveAtstoreRepoDidFromEnv(): string | null { + const fromEnv = process.env.ATSTORE_REPO_DID?.trim(); + return fromEnv?.startsWith("did:") ? fromEnv : null; +} + /** * Determines whether a listing's AT proto record is hosted by the at-store * publisher (or whether it is not yet on AT proto at all). Listings whose @@ -2939,20 +2901,264 @@ const getDirectoryListingDetailBySlug = createServerFn({ method: "GET" }) * is set by `claimProductListingToPds` only after a successful PDS migration * (and is rolled back on failure), so it's our truthful "claim happened" signal. */ -async function computeIsStoreManaged(row: { - atUri: string | null; - repoDid: string | null; - migratedFromAtUri: string | null; -}): Promise { +function computeIsStoreManagedSync( + row: ListingStoreManagedRow, + atstoreDid: string | null, +): boolean { const atUri = row.atUri?.trim(); if (!atUri) return true; const repoDid = row.repoDid?.trim(); if (!repoDid) return true; - const atstoreDid = await getAtstoreRepoDid(); + if (!atstoreDid) return false; if (repoDid !== atstoreDid) return false; return !row.migratedFromAtUri?.trim(); } +async function computeIsStoreManaged( + row: ListingStoreManagedRow, +): Promise { + const atstoreDid = + resolveAtstoreRepoDidFromEnv() ?? (await getAtstoreRepoDid()); + return computeIsStoreManagedSync(row, atstoreDid); +} + +export type DirectoryListingDetailEnrichment = { + oauthProbe: DirectoryListingOAuthProbe | null; + germDmHref: string | null; + fundingDetail: FundingDetail | null; + isStoreManaged: boolean; +}; + +/** DB + schema handle for loaders callable outside TanStack request context (scripts, ingest). */ +export type ListingDbContext = { + db: Database; + schema: typeof dbSchema; +}; + +export function listingDbContext(db: Database): ListingDbContext { + return { db, schema: dbSchema }; +} + +async function loadDirectoryListingDetailEnrichmentForContext( + context: ListingDbContext, + listingId: string, + session?: AtprotoSessionContext | undefined, +): Promise { + if (!isUuid(listingId)) { + return null; + } + + const table = context.schema.storeListings; + const [row] = await context.db + .select({ + id: table.id, + atUri: table.atUri, + repoDid: table.repoDid, + migratedFromAtUri: table.migratedFromAtUri, + productAccountDid: table.productAccountDid, + }) + .from(table) + .where(listingPublicWhere(table, eq(table.id, listingId))) + .limit(1); + + if (!row) { + return null; + } + + const [oauthProbe, isStoreManaged, germDmHref, fundingDetail] = + await Promise.all([ + fetchStoreListingOAuthProbe(row.id, context), + computeIsStoreManaged(row), + germDmHrefForMirroredRepoDid({ + db: context.db, + schemaMod: context.schema, + repoDid: row.productAccountDid, + session, + }), + loadFundingDetailForDid(context.db, row.productAccountDid), + ]); + + return { + oauthProbe, + germDmHref, + fundingDetail, + isStoreManaged, + }; +} + +const getDirectoryListingDetailEnrichmentInput = z.object({ + id: z.string().min(1), +}); + +const getDirectoryListingDetailEnrichment = createServerFn({ method: "GET" }) + .middleware([dbMiddleware]) + .inputValidator(getDirectoryListingDetailEnrichmentInput) + .handler(async ({ data, context }) => { + const session = await getAtprotoSessionForRequest(getRequest()); + return loadDirectoryListingDetailEnrichmentForContext( + context, + data.id, + session ?? undefined, + ); + }); + +function getDirectoryListingDetailEnrichmentQueryOptions(listingId: string) { + return queryOptions({ + queryKey: ["storeListings", "detailEnrichment", listingId], + queryFn: async () => + getDirectoryListingDetailEnrichment({ data: { id: listingId } }), + }); +} + +const MENTION_SNAPSHOT_PREVIEW_LIMIT = 3; +const RELATED_BY_TAG_SNAPSHOT_LIMIT = 3; +const RELATED_IN_CATEGORY_SNAPSHOT_LIMIT = 3; +const RELATED_BY_LEXICON_SNAPSHOT_LIMIT = 6; + +export type ProductPageLoaderResult = { + productId: string; + productSlug: string; + ecosystemRootId: string | null; + listing: DirectoryListingDetail; + page: StoreListingPageSnapshotPayload; +}; + +async function selectDirectoryListingDetailRow(db: Database, whereClause: SQL) { + const table = dbSchema.storeListings; + const [row] = await db + .select({ + id: table.id, + sourceUrl: table.sourceUrl, + name: table.name, + slug: table.slug, + externalUrl: table.externalUrl, + iconUrl: table.iconUrl, + heroImageUrl: table.heroImageUrl, + screenshotUrls: table.screenshotUrls, + tagline: table.tagline, + fullDescription: table.fullDescription, + categorySlugs: table.categorySlugs, + atUri: table.atUri, + repoDid: table.repoDid, + migratedFromAtUri: table.migratedFromAtUri, + productAccountDid: table.productAccountDid, + productAccountHandle: table.productAccountHandle, + reviewCount: table.reviewCount, + averageRating: table.averageRating, + ...storeListingLegacyDetailColumns, + appTags: table.appTags, + links: table.links, + createdAt: table.createdAt, + updatedAt: table.updatedAt, + }) + .from(table) + .where(listingPublicWhere(table, whereClause)) + .limit(1); + + return row ?? null; +} + +export async function loadProductPageByListingId( + db: Database, + listingId: string, + options?: { refreshIfMissing?: boolean }, +): Promise { + const row = await selectDirectoryListingDetailRow( + db, + eq(dbSchema.storeListings.id, listingId), + ); + if (!row) { + return null; + } + + let [snapshot] = await db + .select({ payload: dbSchema.storeListingPageSnapshots.payload }) + .from(dbSchema.storeListingPageSnapshots) + .where(eq(dbSchema.storeListingPageSnapshots.storeListingId, listingId)) + .limit(1); + + if (!snapshot && (options?.refreshIfMissing ?? true)) { + await refreshListingPageSnapshot(db, listingId); + [snapshot] = await db + .select({ payload: dbSchema.storeListingPageSnapshots.payload }) + .from(dbSchema.storeListingPageSnapshots) + .where(eq(dbSchema.storeListingPageSnapshots.storeListingId, listingId)) + .limit(1); + } + + if (!snapshot) { + return null; + } + + const page = snapshot.payload; + const listing = toListingDetail(row, { + isStoreManaged: page.isStoreManaged, + oauthProbe: page.oauthProbe as DirectoryListingOAuthProbe | null, + germDmHref: page.germDmHref ?? null, + fundingDetail: page.fundingDetail, + }); + + return { + productId: listing.id, + productSlug: getDirectoryListingSlug(listing), + ecosystemRootId: getAppEcosystemRootCategoryId(listing.categorySlug), + listing, + page, + }; +} + +const getProductPageBySlug = createServerFn({ method: "GET" }) + .middleware([dbMiddleware]) + .inputValidator(z.object({ slug: z.string().trim().min(1) })) + .handler( + async ({ data, context }): Promise => { + const row = await selectDirectoryListingDetailRow( + context.db, + eq(context.schema.storeListings.slug, data.slug), + ); + if (!row) { + return null; + } + return loadProductPageByListingId(context.db, row.id); + }, + ); + +const getProductPageById = createServerFn({ method: "GET" }) + .middleware([dbMiddleware]) + .inputValidator(z.object({ id: z.string().min(1) })) + .handler( + async ({ data, context }): Promise => { + const legacyListingId = getLegacyDirectoryListingId(data.id); + if (legacyListingId) { + return loadProductPageByListingId(context.db, legacyListingId); + } + const row = await selectDirectoryListingDetailRow( + context.db, + eq(context.schema.storeListings.slug, data.id), + ); + if (!row) { + return null; + } + return loadProductPageByListingId(context.db, row.id); + }, + ); + +function getProductPageBySlugQueryOptions(slug: string) { + return queryOptions({ + queryKey: ["storeListings", "productPage", "slug", slug], + queryFn: async (): Promise => + getProductPageBySlug({ data: { slug } }), + }); +} + +function getProductPageByIdQueryOptions(id: string) { + return queryOptions({ + queryKey: ["storeListings", "productPage", "id", id], + queryFn: async (): Promise => + getProductPageById({ data: { id } }), + }); +} + function getDirectoryListingDetailQueryOptions(id: string) { return queryOptions({ queryKey: ["storeListings", "detail", id], @@ -3092,278 +3298,60 @@ const getDirectoryListingReviews = createServerFn({ method: "GET" }) const session = await getAtprotoSessionForRequest(getRequest()); const viewerDid = session?.did ?? undefined; - - const rev = context.schema.storeListingReviews; - const rows = await context.db - .select({ - id: rev.id, - authorDid: rev.authorDid, - rating: rev.rating, - text: rev.text, - reviewCreatedAt: rev.reviewCreatedAt, - authorDisplayName: rev.authorDisplayName, - authorAvatarUrl: rev.authorAvatarUrl, - replyCount: rev.replyCount, - }) - .from(rev) - .where(eq(rev.storeListingId, listing.id)) - .orderBy(desc(rev.reviewCreatedAt)); - - const enriched: Array = await Promise.all( - rows.map(async (row) => { - const profile = await fetchBlueskyPublicProfileFields(row.authorDid); - const handle = - profile?.handle?.trim() && profile.handle.trim().length > 0 - ? profile.handle.trim() - : null; - const displayName = - row.authorDisplayName?.trim() || - profile?.displayName?.trim() || - profile?.handle || - null; - const avatarUrl = - row.authorAvatarUrl?.trim() || profile?.avatarUrl || null; - - const replyCount = Number(row.replyCount ?? 0); - return { - id: row.id, - authorDid: row.authorDid, - rating: row.rating, - text: row.text, - reviewCreatedAt: row.reviewCreatedAt.toISOString(), - authorDisplayName: displayName, - authorHandle: handle, - authorAvatarUrl: avatarUrl, - replyCount, - canReply: viewerMayReplyOnListingReview({ - viewerDid, - reviewAuthorDid: row.authorDid, - listingRepoDid: listing.repoDid, - listingProductAccountDid: listing.productAccountDid, - }), - }; - }), + const reviews = await loadDirectoryListingReviewsForContext( + context, + data.id, + data.limit, ); - return enriched; + return reviews.map((review) => ({ + ...review, + canReply: viewerMayReplyOnListingReview({ + viewerDid, + reviewAuthorDid: review.authorDid, + listingRepoDid: listing.repoDid, + listingProductAccountDid: listing.productAccountDid, + }), + })); }); -function getDirectoryListingReviewsQueryOptions(id: string) { +function getDirectoryListingReviewsQueryOptions(id: string, limit?: number) { + const normalized = getDirectoryListingReviewsInput.parse( + limit == null ? { id } : { id, limit }, + ); + return queryOptions({ - queryKey: ["storeListings", "reviews", id], - queryFn: async () => getDirectoryListingReviews({ data: { id } }), + queryKey: [ + "storeListings", + "reviews", + normalized.id, + normalized.limit ?? "all", + ], + queryFn: async () => getDirectoryListingReviews({ data: normalized }), }); } const getDirectoryListingProductUpdates = createServerFn({ method: "GET" }) .middleware([dbMiddleware]) .inputValidator(getDirectoryListingProductUpdatesInput) - .handler(async ({ data, context }) => { - const empty: DirectoryListingProductUpdatesPayload = { - updates: [], - publicationBaseUrl: null, - }; - - if (!isUuid(data.id)) { - return empty; - } + .handler(async ({ data, context }) => + loadDirectoryListingProductUpdatesForContext(context, data.id), + ); - const table = context.schema.storeListings; - const [listing] = await context.db - .select({ id: table.id, productAccountDid: table.productAccountDid }) - .from(table) - .where(listingPublicWhere(table, eq(table.id, data.id))) - .limit(1); +function getDirectoryListingProductUpdatesQueryOptions(id: string) { + return queryOptions({ + queryKey: ["storeListings", "productUpdates", id], + queryFn: async (): Promise => + getDirectoryListingProductUpdates({ data: { id } }), + }); +} - if (!listing) { - return empty; - } - - const productDid = listing.productAccountDid?.trim(); - if (!productDid?.startsWith("did:")) { - return empty; - } - - const docs = context.schema.productSiteDocuments; - const pubs = context.schema.productSitePublications; - - const publicationRows = await context.db - .select({ - atUri: pubs.atUri, - baseUrl: pubs.baseUrl, - }) - .from(pubs) - .where(eq(pubs.repoDid, productDid)); - - const pubByAtUri = new Map( - publicationRows.map((r) => [r.atUri, r.baseUrl] as const), - ); - const fallbackBase = - publicationRows.length === 1 ? publicationRows[0].baseUrl : null; - - const docRows = await context.db - .select({ - id: docs.id, - atUri: docs.atUri, - title: docs.title, - description: docs.description, - path: docs.path, - documentPublishedAt: docs.documentPublishedAt, - publicationAtUri: docs.publicationAtUri, - coverImageUrl: docs.coverImageUrl, - }) - .from(docs) - .where(eq(docs.repoDid, productDid)) - .orderBy(desc(docs.documentPublishedAt)) - .limit(PRODUCT_SITE_UPDATES_LIMIT); - - const out: Array = []; - let publicationBaseUrl: string | null = null; - for (const row of docRows) { - let baseUrl: string | null = null; - const pAt = row.publicationAtUri?.trim(); - if (pAt && pubByAtUri.has(pAt)) { - baseUrl = pubByAtUri.get(pAt) ?? null; - } else if (fallbackBase) { - baseUrl = fallbackBase; - } else if (publicationRows.length > 0) { - baseUrl = publicationRows[0].baseUrl; - } - if (publicationBaseUrl === null && baseUrl?.trim()) { - publicationBaseUrl = baseUrl.trim().replace(/\/+$/, ""); - } - const canonicalPostUrl = - baseUrl != null && baseUrl.length > 0 - ? canonicalStandardSitePostUrl(baseUrl, row.path) - : null; - - out.push({ - id: row.id, - atUri: row.atUri, - title: row.title, - description: row.description, - path: row.path, - publishedAt: row.documentPublishedAt.toISOString(), - canonicalPostUrl, - coverImageUrl: httpsListingImageUrlOrNull(row.coverImageUrl), - }); - } - - return { updates: out, publicationBaseUrl }; - }); - -function getDirectoryListingProductUpdatesQueryOptions(id: string) { - return queryOptions({ - queryKey: ["storeListings", "productUpdates", id], - queryFn: async (): Promise => - getDirectoryListingProductUpdates({ data: { id } }), - }); -} - -const getDirectoryListingMentions = createServerFn({ method: "GET" }) - .middleware([dbMiddleware]) - .inputValidator(getDirectoryListingMentionsInput) - .handler(async ({ data, context }) => { - if (!isUuid(data.id)) { - return { mentions: [], total: 0 }; - } - - const table = context.schema.storeListings; - const [listing] = await context.db - .select({ id: table.id, categorySlugs: table.categorySlugs }) - .from(table) - .where(listingPublicWhere(table, eq(table.id, data.id))) - .limit(1); - - if (!listing) { - return { mentions: [], total: 0 }; - } - - const omitUrl = shouldOmitUrlMentionsForBlueskyPlatformListing( - listing.categorySlugs, - ); - const m = context.schema.storeListingMentions; - const mentionWhere = ( - omitUrl - ? and(eq(m.storeListingId, listing.id), ne(m.matchType, "url")) - : eq(m.storeListingId, listing.id) - ) as SQL; - - const [{ total: mentionCount }] = await context.db - .select({ total: count() }) - .from(m) - .where(mentionWhere); - const total = Number(mentionCount ?? 0); - - const rows = await context.db - .select({ - id: m.id, - postUri: m.postUri, - authorDid: m.authorDid, - authorHandle: m.authorHandle, - postText: m.postText, - postCreatedAt: m.postCreatedAt, - matchType: m.matchType, - matchConfidence: m.matchConfidence, - matchEvidence: m.matchEvidence, - }) - .from(m) - .where(mentionWhere) - .orderBy(desc(m.postCreatedAt)) - .limit(data.limit); - - const postDataByPostUri = await fetchBlueskyPostEmbedsByUri( - rows.map((row) => row.postUri), - ); - - const profileByDid = new Map< - string, - Awaited> - >(); - - async function profileForDid( - did: string, - ): Promise>> { - if (profileByDid.has(did)) return profileByDid.get(did) ?? null; - const p = await fetchBlueskyPublicProfileFields(did); - profileByDid.set(did, p); - return p; - } - - const enriched: Array = await Promise.all( - rows.map(async (row) => { - const profile = await profileForDid(row.authorDid); - const handle = - row.authorHandle?.trim() || profile?.handle?.trim() || null; - const authorDisplayName = profile?.displayName?.trim() || null; - const authorAvatarUrl = profile?.avatarUrl ?? null; - - return { - id: row.id, - postUri: row.postUri, - bskyPostUrl: bskyAppPostUrlFromAtUri(row.postUri), - authorDid: row.authorDid, - authorHandle: handle, - authorDisplayName, - authorAvatarUrl, - postText: postDataByPostUri.get(row.postUri)?.text ?? row.postText, - postFacets: postDataByPostUri.get(row.postUri)?.facets ?? null, - postCreatedAt: row.postCreatedAt.toISOString(), - matchType: row.matchType, - matchConfidence: row.matchConfidence, - matchEvidence: - row.matchEvidence && - typeof row.matchEvidence === "object" && - !Array.isArray(row.matchEvidence) - ? (row.matchEvidence as Record) - : null, - postEmbed: postDataByPostUri.get(row.postUri)?.embed ?? null, - }; - }), - ); - - return { mentions: enriched, total }; - }); +const getDirectoryListingMentions = createServerFn({ method: "GET" }) + .middleware([dbMiddleware]) + .inputValidator(getDirectoryListingMentionsInput) + .handler(async ({ data, context }) => + loadDirectoryListingMentionsForContext(context, data.id, data.limit), + ); function getDirectoryListingMentionsQueryOptions( id: string, @@ -4156,97 +4144,12 @@ const getRelatedDirectoryListings = createServerFn({ method: "GET" }) .middleware([dbMiddleware]) .inputValidator(getRelatedDirectoryListingsInput) .handler(async ({ data, context }) => { - if (!isUuid(data.id)) { - return []; - } - - const table = context.schema.storeListings; - const listingSelect = getListingSelect(table); - - const [currentRow, candidateRows] = await Promise.all([ - context.db - .select({ - id: table.id, - appTags: table.appTags, - categorySlugs: table.categorySlugs, - }) - .from(table) - .where(listingPublicWhere(table, eq(table.id, data.id))) - .limit(1) - .then((rows) => rows[0] ?? null), - context.db - .select({ - ...listingSelect, - updatedAt: table.updatedAt, - createdAt: table.createdAt, - }) - .from(table) - .where(listingPublicWhere(table, ne(table.id, data.id))) - .orderBy(...orderByPopularListingSort(table)) - .limit(128), - ]); - - if (!currentRow) { - return []; - } - - const currentTags = new Set(normalizeAppTags(currentRow.appTags ?? [])); - if (currentTags.size === 0) { - return []; - } - - return candidateRows - .map((row) => { - const tags = normalizeAppTags(row.appTags ?? []); - let overlapCount = 0; - - for (const tag of tags) { - if (currentTags.has(tag)) { - overlapCount += 1; - } - } - - if (overlapCount === 0) { - return null; - } - - return { - card: toListingCard(row), - overlapCount, - sameCategory: categorySlugsOverlap( - row.categorySlugs, - currentRow.categorySlugs, - ), - updatedAt: row.updatedAt, - createdAt: row.createdAt, - }; - }) - .filter((item): item is NonNullable => item !== null) - .toSorted((left, right) => { - if (right.overlapCount !== left.overlapCount) { - return right.overlapCount - left.overlapCount; - } - - if (left.sameCategory !== right.sameCategory) { - return left.sameCategory ? -1 : 1; - } - - const updatedDelta = - right.updatedAt.getTime() - left.updatedAt.getTime(); - if (updatedDelta !== 0) { - return updatedDelta; - } - - const createdDelta = - right.createdAt.getTime() - left.createdAt.getTime(); - if (createdDelta !== 0) { - return createdDelta; - } - - return left.card.name.localeCompare(right.card.name); - }) - .slice(0, data.limit) - .map((item) => item.card); + const input = getRelatedDirectoryListingsInput.parse(data); + return loadRelatedDirectoryListingsForContext( + context, + input.id, + input.limit, + ); }); function getRelatedDirectoryListingsQueryOptions( @@ -4260,6 +4163,30 @@ function getRelatedDirectoryListingsQueryOptions( }); } +const getRelatedListingsInCategory = createServerFn({ method: "GET" }) + .middleware([dbMiddleware]) + .inputValidator(getRelatedListingsInCategoryInput) + .handler(async ({ data, context }) => { + const input = getRelatedListingsInCategoryInput.parse(data); + return loadRelatedListingsInCategoryForContext( + context, + input.id, + input.limit, + ); + }); + +function getRelatedListingsInCategoryQueryOptions( + input: z.input, +) { + const normalizedInput = getRelatedListingsInCategoryInput.parse(input); + + return queryOptions({ + queryKey: ["storeListings", "relatedInCategory", normalizedInput], + queryFn: async () => + getRelatedListingsInCategory({ data: normalizedInput }), + }); +} + const listDirectoryListings = createServerFn({ method: "GET" }) .middleware([dbMiddleware]) .inputValidator(listDirectoryListingsInput) @@ -7154,6 +7081,606 @@ const createStoreManagedListing = createServerFn({ method: "POST" }) }); /** Server-only helpers shared with AT Store XRPC handlers. */ +async function loadDirectoryListingReviewsForContext( + context: ListingDbContext, + listingId: string, + limit?: number, +): Promise> { + if (!isUuid(listingId)) { + return []; + } + + const table = context.schema.storeListings; + const [listing] = await context.db + .select({ + id: table.id, + repoDid: table.repoDid, + productAccountDid: table.productAccountDid, + }) + .from(table) + .where(listingPublicWhere(table, eq(table.id, listingId))) + .limit(1); + + if (!listing) { + return []; + } + + const rev = context.schema.storeListingReviews; + const reviewsQuery = context.db + .select({ + id: rev.id, + authorDid: rev.authorDid, + rating: rev.rating, + text: rev.text, + reviewCreatedAt: rev.reviewCreatedAt, + authorDisplayName: rev.authorDisplayName, + authorAvatarUrl: rev.authorAvatarUrl, + replyCount: rev.replyCount, + }) + .from(rev) + .where(eq(rev.storeListingId, listing.id)) + .orderBy(desc(rev.reviewCreatedAt)); + + const rows = + limit == null ? await reviewsQuery : await reviewsQuery.limit(limit); + + const profilesByDid = await fetchBlueskyPublicProfilesBatch( + rows.map((row) => row.authorDid), + ); + + return rows.map((row) => { + const profile = profilesByDid.get(row.authorDid) ?? null; + const handle = + profile?.handle?.trim() && profile.handle.trim().length > 0 + ? profile.handle.trim() + : null; + const displayName = + row.authorDisplayName?.trim() || + profile?.displayName?.trim() || + profile?.handle || + null; + const avatarUrl = row.authorAvatarUrl?.trim() || profile?.avatarUrl || null; + + const replyCount = Number(row.replyCount ?? 0); + return { + id: row.id, + authorDid: row.authorDid, + rating: row.rating, + text: row.text, + reviewCreatedAt: row.reviewCreatedAt.toISOString(), + authorDisplayName: displayName, + authorHandle: handle, + authorAvatarUrl: avatarUrl, + replyCount, + canReply: viewerMayReplyOnListingReview({ + viewerDid: undefined, + reviewAuthorDid: row.authorDid, + listingRepoDid: listing.repoDid, + listingProductAccountDid: listing.productAccountDid, + }), + }; + }); +} + +async function loadDirectoryListingProductUpdatesForContext( + context: ListingDbContext, + listingId: string, +): Promise { + const empty: DirectoryListingProductUpdatesPayload = { + updates: [], + publicationBaseUrl: null, + }; + + if (!isUuid(listingId)) { + return empty; + } + + const table = context.schema.storeListings; + const [listing] = await context.db + .select({ id: table.id, productAccountDid: table.productAccountDid }) + .from(table) + .where(listingPublicWhere(table, eq(table.id, listingId))) + .limit(1); + + if (!listing) { + return empty; + } + + const productDid = listing.productAccountDid?.trim(); + if (!productDid?.startsWith("did:")) { + return empty; + } + + const docs = context.schema.productSiteDocuments; + const pubs = context.schema.productSitePublications; + + const publicationRows = await context.db + .select({ + atUri: pubs.atUri, + baseUrl: pubs.baseUrl, + }) + .from(pubs) + .where(eq(pubs.repoDid, productDid)); + + const pubByAtUri = new Map( + publicationRows.map((r) => [r.atUri, r.baseUrl] as const), + ); + const fallbackBase = + publicationRows.length === 1 ? publicationRows[0].baseUrl : null; + + const docRows = await context.db + .select({ + id: docs.id, + atUri: docs.atUri, + title: docs.title, + description: docs.description, + path: docs.path, + documentPublishedAt: docs.documentPublishedAt, + publicationAtUri: docs.publicationAtUri, + coverImageUrl: docs.coverImageUrl, + }) + .from(docs) + .where(eq(docs.repoDid, productDid)) + .orderBy(desc(docs.documentPublishedAt)) + .limit(PRODUCT_SITE_UPDATES_LIMIT); + + const out: Array = []; + let publicationBaseUrl: string | null = null; + for (const row of docRows) { + let baseUrl: string | null = null; + const pAt = row.publicationAtUri?.trim(); + if (pAt && pubByAtUri.has(pAt)) { + baseUrl = pubByAtUri.get(pAt) ?? null; + } else if (fallbackBase) { + baseUrl = fallbackBase; + } else if (publicationRows.length > 0) { + baseUrl = publicationRows[0].baseUrl; + } + if (publicationBaseUrl === null && baseUrl?.trim()) { + publicationBaseUrl = baseUrl.trim().replace(/\/+$/, ""); + } + const canonicalPostUrl = + baseUrl != null && baseUrl.length > 0 + ? canonicalStandardSitePostUrl(baseUrl, row.path) + : null; + + out.push({ + id: row.id, + atUri: row.atUri, + title: row.title, + description: row.description, + path: row.path, + publishedAt: row.documentPublishedAt.toISOString(), + canonicalPostUrl, + coverImageUrl: httpsListingImageUrlOrNull(row.coverImageUrl), + }); + } + + return { updates: out, publicationBaseUrl }; +} + +async function loadDirectoryListingMentionsForContext( + context: ListingDbContext, + listingId: string, + limit: number, +): Promise { + if (!isUuid(listingId)) { + return { mentions: [], total: 0 }; + } + + const table = context.schema.storeListings; + const [listing] = await context.db + .select({ id: table.id, categorySlugs: table.categorySlugs }) + .from(table) + .where(listingPublicWhere(table, eq(table.id, listingId))) + .limit(1); + + if (!listing) { + return { mentions: [], total: 0 }; + } + + const omitUrl = shouldOmitUrlMentionsForBlueskyPlatformListing( + listing.categorySlugs, + ); + const m = context.schema.storeListingMentions; + const mentionWhere = ( + omitUrl + ? and(eq(m.storeListingId, listing.id), ne(m.matchType, "url")) + : eq(m.storeListingId, listing.id) + ) as SQL; + + const [{ total: mentionCount }] = await context.db + .select({ total: count() }) + .from(m) + .where(mentionWhere); + const total = Number(mentionCount ?? 0); + + const rows = await context.db + .select({ + id: m.id, + postUri: m.postUri, + authorDid: m.authorDid, + authorHandle: m.authorHandle, + postText: m.postText, + postCreatedAt: m.postCreatedAt, + matchType: m.matchType, + matchConfidence: m.matchConfidence, + matchEvidence: m.matchEvidence, + }) + .from(m) + .where(mentionWhere) + .orderBy(desc(m.postCreatedAt)) + .limit(limit); + + const postDataByPostUri = await fetchBlueskyPostEmbedsByUri( + rows.map((row) => row.postUri), + ); + + const profileByDid = new Map< + string, + Awaited> + >(); + + async function profileForDid( + did: string, + ): Promise>> { + if (profileByDid.has(did)) return profileByDid.get(did) ?? null; + const p = await fetchBlueskyPublicProfileFields(did); + profileByDid.set(did, p); + return p; + } + + const enriched: Array = await Promise.all( + rows.map(async (row) => { + const profile = await profileForDid(row.authorDid); + const handle = + row.authorHandle?.trim() || profile?.handle?.trim() || null; + const authorDisplayName = profile?.displayName?.trim() || null; + const authorAvatarUrl = profile?.avatarUrl ?? null; + + return { + id: row.id, + postUri: row.postUri, + bskyPostUrl: bskyAppPostUrlFromAtUri(row.postUri), + authorDid: row.authorDid, + authorHandle: handle, + authorDisplayName, + authorAvatarUrl, + postText: postDataByPostUri.get(row.postUri)?.text ?? row.postText, + postFacets: postDataByPostUri.get(row.postUri)?.facets ?? null, + postCreatedAt: row.postCreatedAt.toISOString(), + matchType: row.matchType, + matchConfidence: row.matchConfidence, + matchEvidence: + row.matchEvidence && + typeof row.matchEvidence === "object" && + !Array.isArray(row.matchEvidence) + ? (row.matchEvidence as Record) + : null, + postEmbed: postDataByPostUri.get(row.postUri)?.embed ?? null, + }; + }), + ); + + return { mentions: enriched, total }; +} + +async function loadRelatedDirectoryListingsForContext( + context: ListingDbContext, + listingId: string, + limit: number, +): Promise> { + if (!isUuid(listingId)) { + return []; + } + + const table = context.schema.storeListings; + const listingSelect = getListingSelect(table); + + const [currentRow, candidateRows] = await Promise.all([ + context.db + .select({ + id: table.id, + appTags: table.appTags, + categorySlugs: table.categorySlugs, + }) + .from(table) + .where(listingPublicWhere(table, eq(table.id, listingId))) + .limit(1) + .then((rows) => rows[0] ?? null), + context.db + .select({ + ...listingSelect, + updatedAt: table.updatedAt, + createdAt: table.createdAt, + }) + .from(table) + .where(listingPublicWhere(table, ne(table.id, listingId))) + .orderBy(...orderByPopularListingSort(table)) + .limit(128), + ]); + + if (!currentRow) { + return []; + } + + const currentTags = new Set(normalizeAppTags(currentRow.appTags ?? [])); + if (currentTags.size === 0) { + return []; + } + + return candidateRows + .map((row) => { + const tags = normalizeAppTags(row.appTags ?? []); + let overlapCount = 0; + + for (const tag of tags) { + if (currentTags.has(tag)) { + overlapCount += 1; + } + } + + if (overlapCount === 0) { + return null; + } + + return { + card: toListingCard(row), + overlapCount, + sameCategory: categorySlugsOverlap( + row.categorySlugs, + currentRow.categorySlugs, + ), + updatedAt: row.updatedAt, + createdAt: row.createdAt, + }; + }) + .filter((item): item is NonNullable => item !== null) + .toSorted((left, right) => { + if (right.overlapCount !== left.overlapCount) { + return right.overlapCount - left.overlapCount; + } + + if (left.sameCategory !== right.sameCategory) { + return left.sameCategory ? -1 : 1; + } + + const updatedDelta = right.updatedAt.getTime() - left.updatedAt.getTime(); + if (updatedDelta !== 0) { + return updatedDelta; + } + + const createdDelta = right.createdAt.getTime() - left.createdAt.getTime(); + if (createdDelta !== 0) { + return createdDelta; + } + + return left.card.name.localeCompare(right.card.name); + }) + .slice(0, limit) + .map((item) => item.card); +} + +async function loadRelatedListingsInCategoryForContext( + context: ListingDbContext, + listingId: string, + limit: number, +): Promise> { + const table = context.schema.storeListings; + const [current] = await context.db + .select({ + id: table.id, + categorySlugs: table.categorySlugs, + }) + .from(table) + .where(listingPublicWhere(table, eq(table.id, listingId))) + .limit(1); + + if (!current) { + return []; + } + + const categoryId = primaryCategorySlug(current.categorySlugs ?? []); + if (!categoryId) { + return []; + } + + const slugRows = await context.db + .select({ categorySlugs: table.categorySlugs }) + .from(table) + .where(listingPublicWhere(table)); + + const tree = buildDirectoryCategoryTree( + slugRows.flatMap((row) => row.categorySlugs ?? []), + ); + const descendantIds = getDirectoryCategoryDescendantIds(tree, categoryId); + if (descendantIds.length === 0) { + return []; + } + + const rows = await context.db + .select(getListingSelect(table)) + .from(table) + .where( + listingPublicWhere( + table, + and( + ne(table.id, listingId), + arrayOverlaps(table.categorySlugs, descendantIds), + ), + ), + ) + .orderBy(...orderByPopularListingSort(table)) + .limit(limit); + + return rows.map((row) => toListingCard(row)); +} + +async function loadRelatedAppsBySharedLexiconKeysForContext( + context: ListingDbContext, + listingId: string, + limit: number, +): Promise { + const listTable = context.schema.storeListings; + const probeTable = context.schema.storeListingOAuthProbes; + + const keys = await loadCrossAppMatchingOAuthLexiconKeysForListing( + context, + listingId, + ); + + if (keys == null) { + return { listings: [] }; + } + + const candidateRows = await context.db + .select(getListingSelect(listTable)) + .from(listTable) + .innerJoin(probeTable, eq(probeTable.storeListingId, listTable.id)) + .where( + and( + listingPublicWhere(listTable), + sqlCategorySlugsMatchesLike(listTable.categorySlugs, "apps/%"), + ne(listTable.id, listingId), + sql`cardinality(${probeTable.oauthLexiconKeys}) > 0`, + arrayOverlaps(probeTable.oauthLexiconKeys, keys), + ), + ) + .orderBy(...orderByPopularListingSort(listTable)) + .limit(limit); + + return { + listings: candidateRows.map((row) => toListingCard(row)), + }; +} + +async function loadDirectoryCategoryTreeForContext( + context: ListingDbContext, +): Promise> { + const table = context.schema.storeListings; + const rows = await context.db + .select({ + categorySlugs: table.categorySlugs, + }) + .from(table) + .where(listingPublicWhere(table)); + + return buildDirectoryCategoryTree( + rows.flatMap((row) => row.categorySlugs ?? []), + ); +} + +/** + * Rebuild the precomputed product-page JSON for one listing. Safe to call from + * Tap ingest, Jetstream, OAuth probe jobs, or lazy on first page view. + */ +export async function refreshListingPageSnapshot( + db: Database, + listingId: string, +): Promise { + if (!isUuid(listingId)) { + return; + } + + const ctx = listingDbContext(db); + const enrichment = await loadDirectoryListingDetailEnrichmentForContext( + ctx, + listingId, + ); + if (!enrichment) { + await db + .delete(dbSchema.storeListingPageSnapshots) + .where(eq(dbSchema.storeListingPageSnapshots.storeListingId, listingId)); + return; + } + + const [row] = await db + .select({ categorySlugs: dbSchema.storeListings.categorySlugs }) + .from(dbSchema.storeListings) + .where(eq(dbSchema.storeListings.id, listingId)) + .limit(1); + + const [ + reviewPreview, + mentionsResult, + productUpdatesPayload, + relatedByTag, + relatedInCategory, + relatedByLexicon, + categoryTree, + ] = await Promise.all([ + loadDirectoryListingReviewsForContext( + ctx, + listingId, + PRODUCT_REVIEW_PREVIEW_COUNT, + ), + loadDirectoryListingMentionsForContext( + ctx, + listingId, + MENTION_SNAPSHOT_PREVIEW_LIMIT, + ), + loadDirectoryListingProductUpdatesForContext(ctx, listingId), + loadRelatedDirectoryListingsForContext( + ctx, + listingId, + RELATED_BY_TAG_SNAPSHOT_LIMIT, + ), + loadRelatedListingsInCategoryForContext( + ctx, + listingId, + RELATED_IN_CATEGORY_SNAPSHOT_LIMIT, + ), + loadRelatedAppsBySharedLexiconKeysForContext( + ctx, + listingId, + RELATED_BY_LEXICON_SNAPSHOT_LIMIT, + ), + loadDirectoryCategoryTreeForContext(ctx), + ]); + + const ecosystemRootId = getAppEcosystemRootCategoryId( + row?.categorySlugs ?? [], + ); + const ecosystemNode = ecosystemRootId + ? findDirectoryCategoryNode(categoryTree, ecosystemRootId) + : null; + + const payload = serializeForJsonColumn({ + version: LISTING_PAGE_SNAPSHOT_VERSION, + isStoreManaged: enrichment.isStoreManaged, + germDmHref: enrichment.germDmHref, + oauthProbe: enrichment.oauthProbe, + fundingDetail: enrichment.fundingDetail, + reviewPreview, + mentions: mentionsResult.mentions, + mentionTotal: mentionsResult.total, + productUpdates: productUpdatesPayload.updates, + productUpdatesPublicationUrl: productUpdatesPayload.publicationBaseUrl, + relatedByTag, + relatedInCategory, + relatedByLexicon: relatedByLexicon.listings, + ecosystemChildren: ecosystemNode?.children ?? null, + } satisfies StoreListingPageSnapshotPayload); + + const now = new Date(); + await db + .insert(dbSchema.storeListingPageSnapshots) + .values({ + storeListingId: listingId, + payload, + payloadVersion: LISTING_PAGE_SNAPSHOT_VERSION, + refreshedAt: now, + }) + .onConflictDoUpdate({ + target: dbSchema.storeListingPageSnapshots.storeListingId, + set: { + payload, + payloadVersion: LISTING_PAGE_SNAPSHOT_VERSION, + refreshedAt: now, + }, + }); +} + export const directoryListingXrpcHelpers = { listingPublicWhere, listingXrpcPublicWhere, @@ -7200,6 +7727,13 @@ export const directoryListingApi = { getDirectoryListingDetailQueryOptions, getDirectoryListingDetailBySlug, getDirectoryListingDetailBySlugQueryOptions, + getDirectoryListingDetailEnrichment, + getDirectoryListingDetailEnrichmentQueryOptions, + getProductPageBySlug, + getProductPageById, + getProductPageBySlugQueryOptions, + getProductPageByIdQueryOptions, + refreshListingPageSnapshot, getDirectoryListingDetailForOwnerEdit, getDirectoryListingDetailForOwnerEditQueryOptions, getDirectoryListingReviews, @@ -7226,6 +7760,8 @@ export const directoryListingApi = { deleteDirectoryListingReviewReply, getRelatedDirectoryListings, getRelatedDirectoryListingsQueryOptions, + getRelatedListingsInCategory, + getRelatedListingsInCategoryQueryOptions, listDirectoryListings, getListDirectoryListingsQueryOptions, getDirectoryListingCategoryAssignments, diff --git a/src/lib/atproto/fund-backfill.ts b/src/lib/atproto/fund-backfill.ts index a221a2a..1e5222e 100644 --- a/src/lib/atproto/fund-backfill.ts +++ b/src/lib/atproto/fund-backfill.ts @@ -15,6 +15,7 @@ */ import type { Database } from "#/db/index.server"; +import * as schema from "#/db/schema"; import { paginateListRecords, rkeyFromCollectionAtUri, @@ -41,6 +42,8 @@ import { tryParseFundGraphDependencyRecord, upsertFundGraphDependencyIntoDb, } from "#/lib/atproto/tap-fund-graph-dependency-sync"; +import { refreshListingPageSnapshot } from "#/lib/listing-page-snapshot"; +import { eq } from "drizzle-orm"; async function backfillCollection( db: Database, @@ -136,6 +139,19 @@ export async function backfillFundForProductDid( tryParseFundGraphDependencyRecord, upsertFundGraphDependencyIntoDb, ); + + const listings = await db + .select({ id: schema.storeListings.id }) + .from(schema.storeListings) + .where(eq(schema.storeListings.productAccountDid, did)); + + for (const row of listings) { + try { + await refreshListingPageSnapshot(db, row.id); + } catch { + // Non-fatal after fund crawl. + } + } } /** diff --git a/src/lib/atproto/fund-format.ts b/src/lib/atproto/fund-format.ts index 820088c..7126084 100644 --- a/src/lib/atproto/fund-format.ts +++ b/src/lib/atproto/fund-format.ts @@ -106,13 +106,20 @@ export function formatFrequencySuffix(frequency: string): string { * `/wk` / `/mo` etc. tag from `formatFrequencySuffix`. */ export function formatFundingAmount( - amount: bigint | null, + amount: bigint | string | null, currency: string | null, frequency: string | null, ): string | null { if (amount == null) return null; - if (amount === 0n) return "Any amount"; - const whole = Number(amount) / 100; + const units = + typeof amount === "bigint" + ? amount + : typeof amount === "string" + ? BigInt(amount) + : null; + if (units == null) return null; + if (units === 0n) return "Any amount"; + const whole = Number(units) / 100; let formatted: string; try { if (currency && /^[A-Z]{3}$/.test(currency)) { diff --git a/src/lib/atproto/load-funding-summaries.ts b/src/lib/atproto/load-funding-summaries.ts index 1502cb7..079dbe1 100644 --- a/src/lib/atproto/load-funding-summaries.ts +++ b/src/lib/atproto/load-funding-summaries.ts @@ -64,8 +64,8 @@ export type FundingPlanView = { status: string | null; name: string; description: string | null; - /** Smallest currency unit (cents for USD); null when omitted. */ - amount: bigint | null; + /** Smallest currency unit (cents for USD); null when omitted. String when read from jsonb snapshots. */ + amount: bigint | string | null; currency: string | null; frequency: string | null; channelAtUris: Array; diff --git a/src/lib/atproto/tap-listing-sync.ts b/src/lib/atproto/tap-listing-sync.ts index 4b6fd2a..e1c4199 100644 --- a/src/lib/atproto/tap-listing-sync.ts +++ b/src/lib/atproto/tap-listing-sync.ts @@ -21,6 +21,7 @@ import { } from "#/lib/atproto/listing-record"; import { COLLECTION } from "#/lib/atproto/nsids"; import { fetchBlueskyPublicProfileFields } from "#/lib/bluesky-public-profile"; +import { refreshListingPageSnapshot } from "#/lib/listing-page-snapshot"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; @@ -393,7 +394,7 @@ export async function upsertDirectoryListingFromTap(input: { const existingRkey = existingRow?.rkey ?? null; const existingVerificationStatus = existingRow?.verificationStatus ?? null; - const verificationStatus = resolveListingVerificationStatus({ + let verificationStatus = resolveListingVerificationStatus({ trustedPublisher, record, ingestRepoDid: did, @@ -405,6 +406,13 @@ export async function upsertDirectoryListingFromTap(input: { existingVerificationStatus, atstoreDid, }); + if ( + verificationStatus !== "rejected" && + (process.env.LOCAL_VERIFY_ALL_TAP_LISTINGS === "1" || + process.env.LOCAL_VERIFY_ALL_TAP_LISTINGS === "true") + ) { + verificationStatus = "verified"; + } /** Successful claim handshake — clear the pending marker so it cannot be reused. */ const claimPendingForDidNext = @@ -520,6 +528,23 @@ export async function upsertDirectoryListingFromTap(input: { } throw error; } + + const [saved] = await db + .select({ id: schema.storeListings.id }) + .from(schema.storeListings) + .where(eq(schema.storeListings.slug, record.slug)) + .limit(1); + + if (saved?.id) { + try { + await refreshListingPageSnapshot(db, saved.id); + } catch (refreshError) { + console.warn( + `[tap-ingest] page snapshot refresh failed slug=${record.slug} id=${saved.id}`, + refreshError, + ); + } + } } /** diff --git a/src/lib/atproto/tap-review-sync.ts b/src/lib/atproto/tap-review-sync.ts index f03fbd9..dc5450b 100644 --- a/src/lib/atproto/tap-review-sync.ts +++ b/src/lib/atproto/tap-review-sync.ts @@ -2,6 +2,7 @@ import type { Database } from "#/db/index.server"; import * as schema from "#/db/schema"; import { COLLECTION, NSID } from "#/lib/atproto/nsids"; +import { refreshListingPageSnapshot } from "#/lib/listing-page-snapshot"; import { recomputeListingTrending } from "#/lib/trending/recompute-listing-trending"; import { and, eq, or, sql } from "drizzle-orm"; import { z } from "zod"; @@ -121,6 +122,11 @@ export async function recomputeListingReviewAggregates( .where(eq(schema.storeListings.id, storeListingId)); await recomputeListingTrending(db, storeListingId); + try { + await refreshListingPageSnapshot(db, storeListingId); + } catch { + // Non-fatal: aggregates already updated. + } } /** diff --git a/src/lib/listing-page-snapshot.ts b/src/lib/listing-page-snapshot.ts new file mode 100644 index 0000000..d947f34 --- /dev/null +++ b/src/lib/listing-page-snapshot.ts @@ -0,0 +1,8 @@ +export { + refreshListingPageSnapshot, + type ProductPageLoaderResult, +} from "#/integrations/tanstack-query/api-directory-listings.functions"; +export { + LISTING_PAGE_SNAPSHOT_VERSION, + type StoreListingPageSnapshotPayload, +} from "#/lib/listing-page-snapshot.types"; diff --git a/src/lib/listing-page-snapshot.types.ts b/src/lib/listing-page-snapshot.types.ts new file mode 100644 index 0000000..1682592 --- /dev/null +++ b/src/lib/listing-page-snapshot.types.ts @@ -0,0 +1,117 @@ +import type { FundingDetail } from "#/lib/atproto/load-funding-summaries"; +import type { DirectoryCategoryTreeNode } from "#/lib/directory-categories"; +import type { SummaryScopeHumanRow } from "#/lib/oauth-listing-auth-probe"; + +/** Bump when the JSON shape changes; readers should tolerate unknown fields. */ +export const LISTING_PAGE_SNAPSHOT_VERSION = 1; + +/** Mirrors `DirectoryListingOAuthProbe` without importing the API module. */ +export type SnapshotOAuthProbe = { + status: string; + probedAt: string | null; + probedUrl: string | null; + probeError: string | null; + oauthScopesDistinct: Array; + transitionalScopes: Array; + publishesAtprotoScope: boolean | null; + clientScopeRawLine: string | null; + clientScopeSyntaxOk: boolean | null; + oauthClientScopesDistinct: Array; + hasProtectedResourceMetadata: boolean; + hasAuthorizationServerMetadata: boolean; + successfulClientMetadataUrl: string | null; + scopeHumanReadable: Array; + oauthLexiconKeys: Array; +}; + +export type SnapshotListingCard = { + id: string; + name: string; + slug?: string | null; + tagline: string; + description: string; + iconUrl: string | null; + heroImageUrl: string | null; + categorySlug: string | null; + categorySlugs: Array; + category: string; + accent: "blue" | "pink" | "purple" | "green"; + rating: number | null; + reviewCount: number; + priceLabel: string; + productAccountHandle: string | null; + appTags: Array; +}; + +export type SnapshotReview = { + id: string; + authorDid: string; + rating: number; + text: string | null; + reviewCreatedAt: string; + authorDisplayName: string | null; + authorHandle: string | null; + authorAvatarUrl: string | null; + replyCount: number; + canReply: boolean; +}; + +export type SnapshotMention = { + id: string; + postUri: string; + bskyPostUrl: string | null; + authorDid: string; + authorHandle: string | null; + authorDisplayName: string | null; + authorAvatarUrl: string | null; + postText: string | null; + postFacets: Array<{ + index: { byteStart: number; byteEnd: number }; + features: Array<{ + $type: string; + uri?: string; + did?: string; + tag?: string; + }>; + }> | null; + postCreatedAt: string; + matchType: string; + matchConfidence: number; + matchEvidence: Record | null; + postEmbed: { + type: "external_link"; + uri: string; + title: string | null; + description: string | null; + thumbUrl: string | null; + } | null; +}; + +export type SnapshotProductUpdate = { + id: string; + atUri: string; + title: string | null; + description: string | null; + path: string; + publishedAt: string; + canonicalPostUrl: string | null; + coverImageUrl: string | null; +}; + +export type StoreListingPageSnapshotPayload = { + version: typeof LISTING_PAGE_SNAPSHOT_VERSION; + isStoreManaged: boolean; + /** Repo-level Germ DM link when resolvable without a viewer session. */ + germDmHref: string | null; + oauthProbe: SnapshotOAuthProbe | null; + fundingDetail: FundingDetail | null; + reviewPreview: Array; + mentions: Array; + mentionTotal: number; + productUpdates: Array; + productUpdatesPublicationUrl: string | null; + relatedByTag: Array; + relatedInCategory: Array; + relatedByLexicon: Array; + ecosystemChildren: Array | null; +}; diff --git a/src/lib/oauth-listing-auth-probe.ts b/src/lib/oauth-listing-auth-probe.ts index 721de7f..008317e 100644 --- a/src/lib/oauth-listing-auth-probe.ts +++ b/src/lib/oauth-listing-auth-probe.ts @@ -35,8 +35,11 @@ import { parseIncludeScopeToken, parseRepoScopeForStorefront, parseRpcScopeForStorefront, + scopeStringToTokens, } from "./oauth-scope-include-parse"; +export { oauthClientDistinctTokensFromPublishedScopeLine } from "./oauth-scope-include-parse"; + export type { PermissionGrantStructuredLine } from "./oauth-permission-grant-ui"; const FETCH_TIMEOUT_MS = 15_000; @@ -474,28 +477,6 @@ function normalizeScopeWhitespace(raw: string): string { return raw.replaceAll("\u00A0", " ").trim(); } -/** AT Proto `scope` values are typically space-separated; each grant may contain `?` parameters. */ -function scopeStringToTokens(raw: string): Array { - return normalizeScopeWhitespace(raw) - .split(/\s+/) - .map((s) => s.trim()) - .filter(Boolean); -} - -/** - * Distinct scope tokens parsed from app-published OAuth metadata only (backward - * compatible when crawls predate persisted `oauthClientScopesDistinct`; does not include - * `scopes_supported` from an authorization server catalog). - */ -export function oauthClientDistinctTokensFromPublishedScopeLine( - rawLine: string | null | undefined, -): Array { - if (!rawLine?.trim()) return []; - return [...new Set(scopeStringToTokens(rawLine))].toSorted((a, b) => - a.localeCompare(b), - ); -} - /** * Splits a permission scope token into `resource:positional?params`. * Multiple colons appear in collection NSIDs, so resource is **only** the first segment (`repo`, `blob`, …). diff --git a/src/lib/oauth-scope-include-parse.ts b/src/lib/oauth-scope-include-parse.ts index d785c13..e4b329a 100644 --- a/src/lib/oauth-scope-include-parse.ts +++ b/src/lib/oauth-scope-include-parse.ts @@ -332,3 +332,29 @@ export function parseIncludeScopeToken( aud: audRaw?.trim() ? decodeUriComponentSafely(audRaw.trim()).trim() : null, }; } + +function normalizeScopeWhitespace(raw: string): string { + return raw.replaceAll("\u00A0", " ").trim(); +} + +/** AT Proto `scope` values are typically space-separated; each grant may contain `?` parameters. */ +export function scopeStringToTokens(raw: string): Array { + return normalizeScopeWhitespace(raw) + .split(/\s+/) + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Distinct scope tokens parsed from app-published OAuth metadata only (backward + * compatible when crawls predate persisted `oauthClientScopesDistinct`; does not include + * `scopes_supported` from an authorization server catalog). + */ +export function oauthClientDistinctTokensFromPublishedScopeLine( + rawLine: string | null | undefined, +): Array { + if (!rawLine?.trim()) return []; + return [...new Set(scopeStringToTokens(rawLine))].toSorted((a, b) => + a.localeCompare(b), + ); +} diff --git a/src/lib/serialize-for-json-column.ts b/src/lib/serialize-for-json-column.ts new file mode 100644 index 0000000..171d33c --- /dev/null +++ b/src/lib/serialize-for-json-column.ts @@ -0,0 +1,8 @@ +/** Clone for Postgres jsonb — `JSON.stringify` cannot encode BigInt (e.g. fund plan amounts). */ +export function serializeForJsonColumn(value: T): T { + return JSON.parse( + JSON.stringify(value, (_key, v) => + typeof v === "bigint" ? v.toString() : v, + ), + ) as T; +} diff --git a/src/lib/trending/jetstream-ingest.ts b/src/lib/trending/jetstream-ingest.ts index 8d719a7..79e86ff 100644 --- a/src/lib/trending/jetstream-ingest.ts +++ b/src/lib/trending/jetstream-ingest.ts @@ -5,6 +5,7 @@ import type { } from "#/lib/trending/mention-matcher"; import * as schema from "#/db/schema"; +import { refreshListingPageSnapshot } from "#/lib/listing-page-snapshot"; import { buildListingMentionIndex, excerptText, @@ -230,6 +231,11 @@ export async function ingestJetstreamCommitLine( const uniqueIds = [...new Set(affected.map((r) => r.storeListingId))]; for (const id of uniqueIds) { await recomputeListingTrending(db, id); + try { + await refreshListingPageSnapshot(db, id); + } catch { + // Non-fatal. + } } return { time_us: evt.time_us, @@ -341,6 +347,11 @@ export async function ingestJetstreamCommitLine( for (const id of affectedListingIds) { await recomputeListingTrending(db, id); + try { + await refreshListingPageSnapshot(db, id); + } catch { + // Non-fatal. + } } return { diff --git a/src/routes/_header-layout.products.$productId.index.tsx b/src/routes/_header-layout.products.$productId.index.tsx index a741ba2..8fd938b 100644 --- a/src/routes/_header-layout.products.$productId.index.tsx +++ b/src/routes/_header-layout.products.$productId.index.tsx @@ -2,11 +2,7 @@ import type { ListingLink } from "#/lib/atproto/listing-record"; import type { FundingDetail } from "#/lib/atproto/load-funding-summaries"; import * as stylex from "@stylexjs/stylex"; -import { - useMutation, - useQueryClient, - useSuspenseQuery, -} from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link as RouterLink, createFileRoute, @@ -49,7 +45,11 @@ import type { DirectoryListingOAuthProbe, DirectoryListingProductUpdate, } from "../integrations/tanstack-query/api-directory-listings.functions"; -import type { DirectoryCategoryOption } from "../lib/directory-categories"; +import type { + DirectoryCategoryOption, + DirectoryCategoryTreeNode, +} from "../lib/directory-categories"; +import type { StoreListingPageSnapshotPayload } from "../lib/listing-page-snapshot.types"; import { BlueskyMentionCard } from "../components/BlueskyMentionCard"; import { DirectoryListingReviewCard } from "../components/DirectoryListingReviewCard"; @@ -88,7 +88,6 @@ import { directoryListingApi } from "../integrations/tanstack-query/api-director import { user } from "../integrations/tanstack-query/api-user.functions"; import { formatAppTagLabel, getAppTagSlug } from "../lib/app-tag-metadata"; import { - getAppEcosystemRootCategoryId, getAppSegmentFromEcosystemRootCategoryId, getDirectoryCategoryOption, } from "../lib/directory-categories"; @@ -109,99 +108,19 @@ const AppLink = createLink(Link); export const Route = createFileRoute("/_header-layout/products/$productId/")({ loader: async ({ context, params }) => { const legacyListingId = getLegacyDirectoryListingId(params.productId); - const listing = await context.queryClient.ensureQueryData( + const result = await context.queryClient.ensureQueryData( legacyListingId - ? directoryListingApi.getDirectoryListingDetailQueryOptions( - legacyListingId, - ) - : directoryListingApi.getDirectoryListingDetailBySlugQueryOptions( + ? directoryListingApi.getProductPageByIdQueryOptions(params.productId) + : directoryListingApi.getProductPageBySlugQueryOptions( params.productId, ), ); - if (!listing) { + if (!result) { throw notFound(); } - const productSlug = getDirectoryListingSlug(listing); - - const relatedProducts = await context.queryClient.ensureQueryData( - directoryListingApi.getRelatedDirectoryListingsQueryOptions({ - id: listing.id, - limit: 3, - }), - ); - const relatedAppsByOAuthLexicon = await context.queryClient.ensureQueryData( - directoryListingApi.getRelatedAppsBySharedLexiconKeysQueryOptions({ - listingId: listing.id, - limit: 6, - }), - ); - if (relatedAppsByOAuthLexicon.listings.length > 0) { - await context.queryClient.ensureQueryData( - directoryListingApi.getLexiconCompatibleAppsPageQueryOptions({ - listingId: listing.id, - sort: "popular", - }), - ); - } - const categoryGroup = listing.categorySlug - ? await context.queryClient.ensureQueryData( - directoryListingApi.getDirectoryCategoryPageQueryOptions({ - categoryId: listing.categorySlug, - sort: "popular", - }), - ) - : null; - const relatedCategoryListings = - categoryGroup?.listings - .filter((candidate) => candidate.id !== listing.id) - .slice(0, 3) ?? []; - - const listingReviews = await context.queryClient.ensureQueryData( - directoryListingApi.getDirectoryListingReviewsQueryOptions(listing.id), - ); - const listingProductUpdatesPayload = listing.productAccountDid?.trim() - ? await context.queryClient.ensureQueryData( - directoryListingApi.getDirectoryListingProductUpdatesQueryOptions( - listing.id, - ), - ) - : { updates: [], publicationBaseUrl: null }; - const listingProductUpdates = listingProductUpdatesPayload.updates; - const productUpdatesPublicationUrl = - listingProductUpdatesPayload.publicationBaseUrl; - const listingMentionsResult = await context.queryClient.ensureQueryData( - directoryListingApi.getDirectoryListingMentionsQueryOptions( - listing.id, - 3, - ), - ); - const session = await context.queryClient.ensureQueryData( - user.getSessionQueryOptions, - ); - const editAccess = session?.user?.did - ? await context.queryClient.ensureQueryData( - directoryListingApi.getProductListingEditAccessQueryOptions( - listing.id, - ), - ) - : null; - await context.queryClient.ensureQueryData( - directoryListingApi.getDirectoryListingFavoriteStatusQueryOptions( - listing.id, - ), - ); - - const ecosystemRootId = getAppEcosystemRootCategoryId(listing.categorySlug); - if (ecosystemRootId) { - await context.queryClient.ensureQueryData( - directoryListingApi.getDirectoryCategoryPageQueryOptions({ - categoryId: ecosystemRootId, - sort: "popular", - }), - ); - } + const { listing, page, productId, productSlug, ecosystemRootId } = result; if (params.productId !== productSlug) { throw redirect({ @@ -211,6 +130,18 @@ export const Route = createFileRoute("/_header-layout/products/$productId/")({ }); } + // Prefetch viewer-specific queries so SSR does not suspend the layout boundary. + await Promise.all([ + context.queryClient.ensureQueryData( + directoryListingApi.getDirectoryListingFavoriteStatusQueryOptions( + productId, + ), + ), + context.queryClient.ensureQueryData( + directoryListingApi.getProductListingEditAccessQueryOptions(productId), + ), + ]); + const primaryTag = listing.appTags[0] ? formatAppTagLabel(listing.appTags[0]) : null; @@ -227,20 +158,11 @@ export const Route = createFileRoute("/_header-layout/products/$productId/")({ : `${listing.name} · ${listing.rating.toFixed(1)} ★ | at-store`; return { - productId: listing.id, + productId, productSlug, ecosystemRootId, listing, - relatedProducts, - relatedAppsByOAuthLexicon, - relatedCategoryListings, - listingReviews, - listingProductUpdates, - productUpdatesPublicationUrl, - listingMentions: listingMentionsResult.mentions, - listingMentionTotal: listingMentionsResult.total, - session, - editAccess, + page, ogTitle, ogDescription, ogImage: listing.heroImageUrl || null, @@ -712,112 +634,25 @@ function productUpdateExternalHref(update: DirectoryListingProductUpdate) { } function ProductPage() { - const { - productId, - productSlug, - ecosystemRootId, - listing, - relatedProducts, - relatedAppsByOAuthLexicon, - relatedCategoryListings, - listingReviews, - listingProductUpdates, - productUpdatesPublicationUrl, - listingMentions, - listingMentionTotal, - session, - editAccess, - } = Route.useLoaderData(); + const { productId, productSlug, ecosystemRootId, listing, page } = + Route.useLoaderData(); if (!listing) { throw notFound(); } - const previewReviews = listingReviews.slice(0, PRODUCT_REVIEW_PREVIEW_COUNT); - const previewProductUpdates = listingProductUpdates.slice( - 0, - PRODUCT_UPDATES_PREVIEW_COUNT, - ); - const showProductUpdatesViewMore = - listingProductUpdates.length > PRODUCT_UPDATES_PREVIEW_COUNT && - productUpdatesPublicationUrl != null && - productUpdatesPublicationUrl.length > 0; - const compatibleRelatedIds = new Set( - relatedAppsByOAuthLexicon.listings.map((l) => l.id), - ); - const relatedSectionListingsBase = - relatedCategoryListings.length > 0 - ? relatedCategoryListings - : relatedProducts; - const relatedSectionListings = relatedSectionListingsBase.filter( - (l) => !compatibleRelatedIds.has(l.id), - ); - const relatedSectionTitle = - relatedCategoryListings.length > 0 - ? "More in this category" - : "Similar apps"; - const [type, scope, domain] = listing.categoryPathLabel?.split(" / ") || []; const isRootApp = type === "Apps" && scope && !domain; const canGoBack = useCanGoBack(); - const navigate = useNavigate(); const router = useRouter(); - const queryClient = useQueryClient(); const [isScreenshotLightboxOpen, setIsScreenshotLightboxOpen] = useState(false); const [screenshotLightboxIndex, setScreenshotLightboxIndex] = useState(0); - const isAdmin = Boolean(session?.user?.isAdmin); - const canRemoveHero = - isAdmin && - Boolean(editAccess?.isStoreManaged) && - Boolean(listing.heroImageUrl); - const removeHeroMutation = useMutation({ - mutationFn: async () => - directoryListingApi.removeStoreManagedListingHero({ - data: { id: listing.id }, - }), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["storeListings"] }); - await router.invalidate(); - }, - }); - - function handleRemoveHero() { - if (!canRemoveHero || removeHeroMutation.isPending) return; - if ( - globalThis.window !== undefined && - !globalThis.window.confirm( - `Remove the hero image from "${listing.name}"?`, - ) - ) { - return; - } - removeHeroMutation.mutate(); - } - return ( - {listing.isStoreManaged && !editAccess?.canEdit ? ( - - Claim listing - - } - > - This listing is managed by the at-store team. Claim it to update - details, links, and respond to reviews. - - ) : null} + )} - - {canRemoveHero ? ( - - ) : null} - {editAccess?.canEdit ? ( - - Edit listing - - ) : null} - + - + {/* screenshots */} {listing.screenshots.length > 0 ? ( @@ -914,244 +720,40 @@ function ProductPage() { ) : null} {ecosystemRootId && isRootApp ? ( - + ) : null} - - - - - Reviews - - - - - {listing.rating == null ? "—" : listing.rating.toFixed(1)} - - - - - - Create review - - - - - - {previewReviews.length > 0 ? ( - - {previewReviews.map((review) => ( - { - void navigate({ - to: "/products/$productId/reviews/$reviewId/edit", - params: { - productId: productSlug, - reviewId: review.id, - }, - }); - }} - /> - ))} - - ) : ( - - - Be the first to review this product. - - - )} + - {previewReviews.length > 0 ? ( - - View all - + {listing.productAccountDid ? ( + ) : null} - - - {listing.productAccountDid && listingProductUpdates.length > 0 ? ( - - - - Updates - - {showProductUpdatesViewMore ? ( - - View all - - ) : null} - - - - ) : null} - - {listingMentions.length > 0 ? ( - - - - Mentions - - {listingMentionTotal > 3 ? ( - - - View all - - - ) : null} - - - {listingMentions.map((mention) => ( - - ))} - - - ) : null} - {relatedAppsByOAuthLexicon.listings.length > 0 ? ( - - - - Compatible apps - - - View all - - - - {relatedAppsByOAuthLexicon.listings.map((lexListing) => ( - - ))} - - - ) : null} + - {relatedSectionListings.length > 0 ? ( - - ) : null} + ); @@ -1166,8 +768,8 @@ function HeroSection({ }) { const primaryLink = listing.externalUrl || undefined; const buttonStyles = useButtonStyles({ variant: "secondary", size: "lg" }); - const { session } = Route.useLoaderData(); - const { data: favoriteStatus } = useSuspenseQuery( + const { data: session } = useQuery(user.getSessionQueryOptions); + const { data: favoriteStatus } = useQuery( directoryListingApi.getDirectoryListingFavoriteStatusQueryOptions( productId, ), @@ -1293,7 +895,7 @@ function HeroSection({ const actions = ( - {session?.user?.did && canFavorite ? ( + {session?.user?.did && canFavorite && favoriteStatus ? ( ); +} - const appSegment = getAppSegmentFromEcosystemRootCategoryId(ecosystemRootId); +function ProductClaimBanner({ + listing, + productId, +}: { + listing: DirectoryListingDetail; + productId: string; +}) { + const { data: editAccess } = useQuery( + directoryListingApi.getProductListingEditAccessQueryOptions(productId), + ); - if (!data || !appSegment) { + if (!listing.isStoreManaged || editAccess?.canEdit) { return null; } - const { category } = data; + return ( + + Claim listing + + } + > + This listing is managed by the at-store team. Claim it to update details, + links, and respond to reviews. + + ); +} - if (category.children.length === 0) { +function ProductPageEditActions({ + listing, + productSlug, +}: { + listing: DirectoryListingDetail; + productSlug: string; +}) { + const { data: session } = useQuery(user.getSessionQueryOptions); + const { data: editAccess } = useQuery( + directoryListingApi.getProductListingEditAccessQueryOptions(listing.id), + ); + const router = useRouter(); + const queryClient = useQueryClient(); + const isAdmin = Boolean(session?.user?.isAdmin); + const canRemoveHero = + isAdmin && + Boolean(editAccess?.isStoreManaged) && + Boolean(listing.heroImageUrl); + const removeHeroMutation = useMutation({ + mutationFn: async () => + directoryListingApi.removeStoreManagedListingHero({ + data: { id: listing.id }, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["storeListings"] }); + await router.invalidate(); + }, + }); + + function handleRemoveHero() { + if (!canRemoveHero || removeHeroMutation.isPending) return; + if ( + globalThis.window !== undefined && + !globalThis.window.confirm( + `Remove the hero image from "${listing.name}"?`, + ) + ) { + return; + } + removeHeroMutation.mutate(); + } + + if (!canRemoveHero && !editAccess?.canEdit) { + return null; + } + + return ( + + {canRemoveHero ? ( + + ) : null} + {editAccess?.canEdit ? ( + + Edit listing + + ) : null} + + ); +} + +function ProductReviewsSection({ + listing, + productId, + productSlug, + previewReviews, +}: { + listing: DirectoryListingDetail; + productId: string; + productSlug: string; + previewReviews: StoreListingPageSnapshotPayload["reviewPreview"]; +}) { + const navigate = useNavigate(); + const { data: session } = useQuery(user.getSessionQueryOptions); + + return ( + <> + + + + + Reviews + + + + + {listing.rating == null ? "—" : listing.rating.toFixed(1)} + + + + + + Create review + + + + + + {previewReviews.length > 0 ? ( + + {previewReviews.map((review) => ( + { + void navigate({ + to: "/products/$productId/reviews/$reviewId/edit", + params: { + productId: productSlug, + reviewId: review.id, + }, + }); + }} + /> + ))} + + ) : ( + + Be the first to review this product. + + )} + + {(listing.reviewCount ?? 0) > PRODUCT_REVIEW_PREVIEW_COUNT ? ( + + View all + + ) : null} + + ); +} + +function ProductUpdatesSection({ + updates, + publicationUrl, +}: { + updates: StoreListingPageSnapshotPayload["productUpdates"]; + publicationUrl: string | null; +}) { + const listingProductUpdates = updates; + const productUpdatesPublicationUrl = publicationUrl; + const previewProductUpdates = listingProductUpdates.slice( + 0, + PRODUCT_UPDATES_PREVIEW_COUNT, + ); + const showProductUpdatesViewMore = + listingProductUpdates.length > PRODUCT_UPDATES_PREVIEW_COUNT && + productUpdatesPublicationUrl != null && + productUpdatesPublicationUrl.length > 0; + + if (listingProductUpdates.length === 0) { + return null; + } + + return ( + + + + Updates + + {showProductUpdatesViewMore ? ( + + View all + + ) : null} + + + + ); +} + +function ProductMentionsSection({ + mentions, + mentionTotal, + productSlug, +}: { + mentions: StoreListingPageSnapshotPayload["mentions"]; + mentionTotal: number; + productSlug: string; +}) { + const listingMentions = mentions; + const listingMentionTotal = mentionTotal; + + if (listingMentions.length === 0) { + return null; + } + + return ( + + + + Mentions + + {listingMentionTotal > 3 ? ( + + + View all + + + ) : null} + + + {listingMentions.map((mention) => ( + + ))} + + + ); +} + +function ProductRelatedSections({ + relatedByTag, + relatedInCategory, + relatedByLexicon, + productSlug, +}: { + relatedByTag: Array; + relatedInCategory: Array; + relatedByLexicon: Array; + productSlug: string; +}) { + const relatedProducts = relatedByTag; + const relatedAppsByOAuthLexicon = { listings: relatedByLexicon }; + const relatedCategoryListings = relatedInCategory; + + const compatibleRelatedIds = new Set( + relatedAppsByOAuthLexicon.listings.map((l) => l.id), + ); + const relatedSectionListingsBase = + relatedCategoryListings.length > 0 + ? relatedCategoryListings + : relatedProducts; + const relatedSectionListings = relatedSectionListingsBase.filter( + (l) => !compatibleRelatedIds.has(l.id), + ); + const relatedSectionTitle = + relatedCategoryListings.length > 0 + ? "More in this category" + : "Similar apps"; + + return ( + <> + {relatedAppsByOAuthLexicon.listings.length > 0 ? ( + + + + Compatible apps + + + View all + + + + {relatedAppsByOAuthLexicon.listings.map((lexListing) => ( + + ))} + + + ) : null} + + {relatedSectionListings.length > 0 ? ( + + ) : null} + + ); +} + +function ProductEcosystemSection({ + ecosystemRootId, + ecosystemChildren, +}: { + ecosystemRootId: string; + ecosystemChildren: Array | null; +}) { + const appSegment = getAppSegmentFromEcosystemRootCategoryId(ecosystemRootId); + + if (!ecosystemChildren?.length || !appSegment) { return null; } @@ -1436,18 +1478,11 @@ function ProductEcosystemSection({ - {category.children.length > 0 ? ( - - {category.children.map((child) => ( - - ))} - - ) : ( - - Explore this app's directory tree from the ecosystem home page, - or search every listing filed under it. - - )} + + {ecosystemChildren.map((child) => ( + + ))} + ); } diff --git a/src/routes/_header-layout.tsx b/src/routes/_header-layout.tsx index 8426ac2..2f3fbd0 100644 --- a/src/routes/_header-layout.tsx +++ b/src/routes/_header-layout.tsx @@ -9,6 +9,13 @@ export const Route = createFileRoute("/_header-layout")({ component: HeaderLayoutRoute, }); +/** Shown only while a child route suspends; avoids a blank page shell. */ +function HeaderLayoutOutletFallback() { + return ( +
+ ); +} + function HeaderLayoutRoute() { return ( @@ -17,7 +24,7 @@ function HeaderLayoutRoute() { - + }>