305 lines
7.5 KiB
TypeScript
305 lines
7.5 KiB
TypeScript
import { ConvexHttpClient } from "convex/browser"
|
|
import { fetchQuery } from "convex/nextjs"
|
|
import { makeFunctionReference } from "convex/server"
|
|
import { api } from "@/convex/_generated/api"
|
|
import { hasConvexUrl } from "@/lib/convex-config"
|
|
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
|
|
import { normalizeManualAssetValue } from "@/lib/manuals-asset-paths"
|
|
import { deriveThumbnailPathFromManualPath } from "@/lib/manuals-thumbnail-fallback"
|
|
import type { Product } from "@/lib/products/types"
|
|
import type { Manual } from "@/lib/manuals-types"
|
|
|
|
type ConvexProductDoc = {
|
|
_id: string
|
|
name: string
|
|
description?: string
|
|
price: number
|
|
currency: string
|
|
images: string[]
|
|
metadata?: Record<string, string>
|
|
stripeProductId?: string
|
|
stripePriceId?: string
|
|
active: boolean
|
|
}
|
|
|
|
type ConvexManualDoc = {
|
|
filename: string
|
|
path: string
|
|
manufacturer: string
|
|
category: string
|
|
size?: number
|
|
lastModified?: number
|
|
searchTerms?: string[]
|
|
commonNames?: string[]
|
|
thumbnailUrl?: string
|
|
manualUrl?: string
|
|
}
|
|
|
|
const LIST_MANUALS = makeFunctionReference<"query">("manuals:list")
|
|
const MANUALS_DASHBOARD = makeFunctionReference<"query">("manuals:dashboard")
|
|
|
|
let cachedServerConvexAdminClient: ConvexHttpClient | null | undefined
|
|
let cachedServerConvexPublicClient: ConvexHttpClient | null | undefined
|
|
|
|
function getServerConvexClient(useAdminAuth: boolean) {
|
|
const cacheKey = useAdminAuth ? "admin" : "public"
|
|
const cachedClient = useAdminAuth
|
|
? cachedServerConvexAdminClient
|
|
: cachedServerConvexPublicClient
|
|
if (cachedClient !== undefined) {
|
|
return cachedClient
|
|
}
|
|
|
|
const convexUrl = process.env.CONVEX_URL || process.env.NEXT_PUBLIC_CONVEX_URL
|
|
if (!convexUrl) {
|
|
if (useAdminAuth) {
|
|
cachedServerConvexAdminClient = null
|
|
return cachedServerConvexAdminClient
|
|
}
|
|
|
|
cachedServerConvexPublicClient = null
|
|
return cachedServerConvexPublicClient
|
|
}
|
|
|
|
const client = new ConvexHttpClient(convexUrl)
|
|
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY
|
|
if (useAdminAuth && adminKey) {
|
|
;(
|
|
client as ConvexHttpClient & {
|
|
setAdminAuth?: (token: string) => void
|
|
}
|
|
).setAdminAuth?.(adminKey)
|
|
}
|
|
|
|
if (cacheKey === "admin") {
|
|
cachedServerConvexAdminClient = client
|
|
return cachedServerConvexAdminClient
|
|
}
|
|
|
|
cachedServerConvexPublicClient = client
|
|
return cachedServerConvexPublicClient
|
|
}
|
|
|
|
async function queryManualsWithAuthFallback<TData>(
|
|
label: string,
|
|
queryRef: ReturnType<typeof makeFunctionReference<"query">>,
|
|
fallback: TData,
|
|
args: Record<string, unknown> = {}
|
|
): Promise<TData> {
|
|
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY
|
|
const adminClient = getServerConvexClient(true)
|
|
|
|
if (adminClient) {
|
|
try {
|
|
return (await adminClient.query(queryRef, args)) as TData
|
|
} catch (error) {
|
|
console.error(`[convex-service] ${label} admin query failed`, error)
|
|
if (!adminKey) {
|
|
return fallback
|
|
}
|
|
}
|
|
}
|
|
|
|
const publicClient = getServerConvexClient(false)
|
|
if (!publicClient) {
|
|
return fallback
|
|
}
|
|
|
|
return await safeFetchQuery(
|
|
`${label}.public`,
|
|
publicClient.query(queryRef, args),
|
|
fallback
|
|
)
|
|
}
|
|
|
|
type ConvexOrderItem = {
|
|
productId?: string | null
|
|
productName: string
|
|
price: number
|
|
quantity: number
|
|
priceId: string
|
|
image?: string
|
|
}
|
|
|
|
export type ConvexAdminOrder = {
|
|
id: string
|
|
customerEmail: string
|
|
customerName?: string
|
|
totalAmount: number
|
|
currency: string
|
|
status: "pending" | "paid" | "fulfilled" | "cancelled" | "refunded"
|
|
stripeSessionId: string | null
|
|
paymentIntentId: string | null
|
|
createdAt: string
|
|
updatedAt: string
|
|
shippingAddress?: {
|
|
name?: string
|
|
address?: string
|
|
city?: string
|
|
state?: string
|
|
zipCode?: string
|
|
country?: string
|
|
}
|
|
items: ConvexOrderItem[]
|
|
}
|
|
|
|
async function safeFetchQuery<TData>(
|
|
label: string,
|
|
query: Promise<TData>,
|
|
fallback: TData
|
|
): Promise<TData> {
|
|
try {
|
|
return await query
|
|
} catch (error) {
|
|
console.error(`[convex-service] ${label} failed`, error)
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
function mapConvexProduct(product: ConvexProductDoc): Product {
|
|
return {
|
|
id: product._id,
|
|
stripeId: product.stripeProductId ?? product._id,
|
|
name: product.name,
|
|
description: product.description ?? null,
|
|
price: product.price,
|
|
currency: product.currency,
|
|
images: product.images ?? [],
|
|
metadata: product.metadata,
|
|
priceId: product.stripePriceId,
|
|
}
|
|
}
|
|
|
|
function mapConvexManual(manual: ConvexManualDoc): Manual {
|
|
const normalizedThumbnailUrl = normalizeManualAssetValue(
|
|
manual.thumbnailUrl,
|
|
"thumbnail"
|
|
)
|
|
const thumbnailUrl =
|
|
normalizedThumbnailUrl || deriveThumbnailPathFromManualPath(manual.path)
|
|
|
|
return {
|
|
filename: manual.filename,
|
|
path: manual.path,
|
|
manufacturer: manual.manufacturer,
|
|
category: manual.category,
|
|
size: manual.size,
|
|
lastModified: manual.lastModified
|
|
? new Date(manual.lastModified)
|
|
: undefined,
|
|
searchTerms: manual.searchTerms,
|
|
commonNames: manual.commonNames,
|
|
thumbnailUrl: thumbnailUrl || undefined,
|
|
}
|
|
}
|
|
|
|
export async function listConvexProducts(): Promise<Product[]> {
|
|
if (!hasConvexUrl()) {
|
|
return []
|
|
}
|
|
|
|
const products = await safeFetchQuery(
|
|
"products.listActive",
|
|
fetchQuery(api.products.listActive, {}),
|
|
[] as ConvexProductDoc[]
|
|
)
|
|
return (products as ConvexProductDoc[]).map(mapConvexProduct)
|
|
}
|
|
|
|
export async function listConvexAdminProducts(
|
|
search?: string
|
|
): Promise<Product[]> {
|
|
if (!hasConvexUrl()) {
|
|
return []
|
|
}
|
|
|
|
const products = await safeFetchQuery(
|
|
"products.listAdmin",
|
|
fetchQuery(api.products.listAdmin, { search }),
|
|
[] as ConvexProductDoc[]
|
|
)
|
|
return (products as ConvexProductDoc[]).map(mapConvexProduct)
|
|
}
|
|
|
|
export async function getConvexProduct(id: string): Promise<Product | null> {
|
|
if (!hasConvexUrl()) {
|
|
return null
|
|
}
|
|
|
|
const products = await safeFetchQuery(
|
|
"products.listAdmin",
|
|
fetchQuery(api.products.listAdmin, {}),
|
|
[] as ConvexProductDoc[]
|
|
)
|
|
const match = (products as ConvexProductDoc[]).find((product) => {
|
|
return product._id === id || product.stripeProductId === id
|
|
})
|
|
|
|
return match ? mapConvexProduct(match) : null
|
|
}
|
|
|
|
export async function listConvexManuals(domain?: string): Promise<Manual[]> {
|
|
if (!hasConvexUrl()) {
|
|
return []
|
|
}
|
|
|
|
const tenantDomain = resolveManualsTenantDomain({
|
|
requestHost: domain,
|
|
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
|
|
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
|
|
})
|
|
|
|
if (!tenantDomain) {
|
|
return []
|
|
}
|
|
|
|
const manuals = await queryManualsWithAuthFallback(
|
|
"manuals.list",
|
|
LIST_MANUALS,
|
|
[] as ConvexManualDoc[],
|
|
{ domain: tenantDomain }
|
|
)
|
|
return (manuals as ConvexManualDoc[]).map(mapConvexManual)
|
|
}
|
|
|
|
export async function getConvexManualDashboard() {
|
|
if (!hasConvexUrl()) {
|
|
return null
|
|
}
|
|
|
|
return await queryManualsWithAuthFallback(
|
|
"manuals.dashboard",
|
|
MANUALS_DASHBOARD,
|
|
null
|
|
)
|
|
}
|
|
|
|
export async function getConvexOrderMetrics() {
|
|
if (!hasConvexUrl()) {
|
|
return null
|
|
}
|
|
|
|
return await safeFetchQuery(
|
|
"orders.getMetrics",
|
|
fetchQuery(api.orders.getMetrics, {}),
|
|
null
|
|
)
|
|
}
|
|
|
|
export async function listConvexOrders(options?: {
|
|
status?: ConvexAdminOrder["status"]
|
|
search?: string
|
|
}): Promise<ConvexAdminOrder[]> {
|
|
if (!hasConvexUrl()) {
|
|
return []
|
|
}
|
|
|
|
return await safeFetchQuery(
|
|
"orders.listAdmin",
|
|
fetchQuery(api.orders.listAdmin, {
|
|
status: options?.status,
|
|
search: options?.search,
|
|
}),
|
|
[] as ConvexAdminOrder[]
|
|
)
|
|
}
|