import { query, type QueryCtx } from "./_generated/server"; import type { Doc, Id } from "./_generated/dataModel"; import { v } from "convex/values"; import { optionalAuth } from "./helpers"; import { prioritizeRecentCreditTransactions } from "../lib/credits-activity"; import { MONTHLY_TIER_CREDITS, normalizeBillingTier } from "../lib/tier-credits"; const DEFAULT_TIER = "free" as const; const DEFAULT_SUBSCRIPTION_STATUS = "active" as const; const DASHBOARD_MEDIA_PREVIEW_LIMIT = 8; const MEDIA_LIBRARY_DEFAULT_LIMIT = 8; const MEDIA_LIBRARY_MIN_LIMIT = 1; const MEDIA_LIBRARY_MAX_LIMIT = 500; const MEDIA_ARCHIVE_FETCH_MULTIPLIER = 4; type MediaPreviewItem = { kind: "image" | "video" | "asset"; source: "upload" | "ai-image" | "ai-video" | "freepik-asset" | "pexels-video"; storageId?: Id<"_storage">; previewStorageId?: Id<"_storage">; originalUrl?: string; previewUrl?: string; sourceUrl?: string; filename?: string; mimeType?: string; width?: number; height?: number; previewWidth?: number; previewHeight?: number; sourceCanvasId?: Id<"canvases">; sourceNodeId?: Id<"nodes">; createdAt: number; }; function readArchivedMediaPreview(item: Doc<"mediaItems">): MediaPreviewItem | null { if (!item.storageId && !item.previewStorageId && !item.previewUrl && !item.originalUrl && !item.sourceUrl) { return null; } return { kind: item.kind, source: item.source, storageId: item.storageId, previewStorageId: item.previewStorageId, originalUrl: item.originalUrl, previewUrl: item.previewUrl, sourceUrl: item.sourceUrl, filename: item.filename ?? item.title, mimeType: item.mimeType, width: item.width, height: item.height, sourceCanvasId: item.firstSourceCanvasId, sourceNodeId: item.firstSourceNodeId, createdAt: item.updatedAt, }; } function buildMediaPreviewFromArchive( mediaItems: Array>, limit: number, kindFilter?: "image" | "video" | "asset", ): MediaPreviewItem[] { const sortedRows = mediaItems .filter((item) => (kindFilter ? item.kind === kindFilter : true)) .sort((a, b) => b.updatedAt - a.updatedAt); const deduped = new Map(); for (const item of sortedRows) { const dedupeKey = item.storageId ?? item.dedupeKey; if (deduped.has(dedupeKey)) { continue; } const preview = readArchivedMediaPreview(item); if (!preview) { continue; } deduped.set(dedupeKey, preview); if (deduped.size >= limit) { break; } } return [...deduped.values()]; } function readImageMediaPreview(node: Doc<"nodes">): MediaPreviewItem | null { if (node.type !== "image") { return null; } const data = (node.data as Record | undefined) ?? {}; const storageId = data.storageId; if (typeof storageId !== "string" || storageId.length === 0) { return null; } const previewStorageId = typeof data.previewStorageId === "string" && data.previewStorageId.length > 0 ? (data.previewStorageId as Id<"_storage">) : undefined; const filename = typeof data.filename === "string" ? data.filename : typeof data.originalFilename === "string" ? data.originalFilename : undefined; const mimeType = typeof data.mimeType === "string" ? data.mimeType : undefined; const width = typeof data.width === "number" && Number.isFinite(data.width) ? data.width : undefined; const height = typeof data.height === "number" && Number.isFinite(data.height) ? data.height : undefined; const previewWidth = typeof data.previewWidth === "number" && Number.isFinite(data.previewWidth) ? data.previewWidth : undefined; const previewHeight = typeof data.previewHeight === "number" && Number.isFinite(data.previewHeight) ? data.previewHeight : undefined; return { kind: "image", source: "upload", storageId: storageId as Id<"_storage">, previewStorageId, filename, mimeType, width, height, previewWidth, previewHeight, sourceCanvasId: node.canvasId, sourceNodeId: node._id, createdAt: node._creationTime, }; } function buildMediaPreview(nodes: Array>, limit: number): MediaPreviewItem[] { const candidates = nodes .map(readImageMediaPreview) .filter((item): item is MediaPreviewItem => item !== null) .sort((a, b) => b.createdAt - a.createdAt); const deduped = new Map(); for (const item of candidates) { const dedupeKey = item.storageId ?? `${item.sourceCanvasId}:${item.sourceNodeId}`; if (deduped.has(dedupeKey)) { continue; } deduped.set(dedupeKey, item); if (deduped.size >= limit) { break; } } return [...deduped.values()]; } function normalizeMediaLibraryLimit(limit: number | undefined): number { if (typeof limit !== "number" || !Number.isFinite(limit)) { return MEDIA_LIBRARY_DEFAULT_LIMIT; } return Math.min(MEDIA_LIBRARY_MAX_LIMIT, Math.max(MEDIA_LIBRARY_MIN_LIMIT, Math.floor(limit))); } function normalizeMediaLibraryPage(page: number): number { if (!Number.isFinite(page)) { return 1; } return Math.max(1, Math.floor(page)); } async function buildMediaPreviewFromNodeFallback( ctx: QueryCtx, canvases: Array>, limit: number, ): Promise { if (canvases.length === 0 || limit <= 0) { return []; } const deduped = new Map(); for (const canvas of canvases.slice(0, 12)) { if (deduped.size >= limit) { break; } const nodes = await ctx.db .query("nodes") .withIndex("by_canvas_type", (q) => q.eq("canvasId", canvas._id).eq("type", "image")) .order("desc") .take(Math.max(limit * 2, 16)); const candidates = buildMediaPreview(nodes, limit); for (const candidate of candidates) { const dedupeKey = candidate.storageId ?? `${candidate.sourceCanvasId}:${candidate.sourceNodeId}`; if (deduped.has(dedupeKey)) { continue; } deduped.set(dedupeKey, candidate); if (deduped.size >= limit) { break; } } } return [...deduped.values()].slice(0, limit); } export const getSnapshot = query({ args: {}, handler: async (ctx) => { const user = await optionalAuth(ctx); if (!user) { return { balance: { balance: 0, reserved: 0, available: 0, monthlyAllocation: 0 }, subscription: { tier: DEFAULT_TIER, status: DEFAULT_SUBSCRIPTION_STATUS, currentPeriodEnd: undefined, }, usageStats: { monthlyUsage: 0, totalGenerations: 0, monthlyCredits: MONTHLY_TIER_CREDITS[DEFAULT_TIER], }, recentTransactions: [], canvases: [], mediaPreview: [], generatedAt: Date.now(), }; } const [balanceRow, subscriptionRow, usageTransactions, recentTransactionsRaw, canvases, mediaArchiveRows] = await Promise.all([ ctx.db .query("creditBalances") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .unique(), ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .order("desc") .first(), ctx.db .query("creditTransactions") .withIndex("by_user_type", (q) => q.eq("userId", user.userId).eq("type", "usage")) .order("desc") .collect(), ctx.db .query("creditTransactions") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .order("desc") .take(80), ctx.db .query("canvases") .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) .order("desc") .collect(), ctx.db .query("mediaItems") .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) .order("desc") .take(Math.max(DASHBOARD_MEDIA_PREVIEW_LIMIT * MEDIA_ARCHIVE_FETCH_MULTIPLIER, 32)), ]); let mediaPreview = buildMediaPreviewFromArchive(mediaArchiveRows, DASHBOARD_MEDIA_PREVIEW_LIMIT); if (mediaPreview.length === 0 && mediaArchiveRows.length === 0) { mediaPreview = await buildMediaPreviewFromNodeFallback(ctx, canvases, DASHBOARD_MEDIA_PREVIEW_LIMIT); } const tier = normalizeBillingTier(subscriptionRow?.tier); const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime(); let monthlyUsage = 0; let totalGenerations = 0; for (const transaction of usageTransactions) { if (transaction._creationTime < monthStart) { break; } if (transaction.status === "committed") { monthlyUsage += Math.abs(transaction.amount); totalGenerations += 1; } } const balance = { balance: balanceRow?.balance ?? 0, reserved: balanceRow?.reserved ?? 0, available: (balanceRow?.balance ?? 0) - (balanceRow?.reserved ?? 0), monthlyAllocation: balanceRow?.monthlyAllocation ?? MONTHLY_TIER_CREDITS[tier], }; return { balance, subscription: { tier: subscriptionRow?.tier ?? DEFAULT_TIER, status: subscriptionRow?.status ?? DEFAULT_SUBSCRIPTION_STATUS, currentPeriodEnd: subscriptionRow?.currentPeriodEnd, }, usageStats: { monthlyUsage, totalGenerations, monthlyCredits: MONTHLY_TIER_CREDITS[tier], }, recentTransactions: prioritizeRecentCreditTransactions(recentTransactionsRaw, 20), canvases, mediaPreview, generatedAt: Date.now(), }; }, }); export const listMediaLibrary = query({ args: { page: v.number(), pageSize: v.optional(v.number()), kindFilter: v.optional(v.union(v.literal("image"), v.literal("video"), v.literal("asset"))), }, handler: async (ctx, { page, pageSize, kindFilter }) => { const normalizedPage = normalizeMediaLibraryPage(page); const normalizedPageSize = normalizeMediaLibraryLimit(pageSize); const user = await optionalAuth(ctx); if (!user) { return { items: [], page: normalizedPage, pageSize: normalizedPageSize, totalPages: 0, totalCount: 0, }; } const mediaArchiveRows = kindFilter ? await ctx.db .query("mediaItems") .withIndex("by_owner_kind_updated", (q) => q.eq("ownerId", user.userId).eq("kind", kindFilter)) .order("desc") .collect() : await ctx.db .query("mediaItems") .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) .order("desc") .collect(); const mediaFromArchive = buildMediaPreviewFromArchive(mediaArchiveRows, mediaArchiveRows.length, kindFilter); if (mediaFromArchive.length > 0 || mediaArchiveRows.length > 0) { const totalCount = mediaFromArchive.length; const totalPages = totalCount > 0 ? Math.ceil(totalCount / normalizedPageSize) : 0; const offset = (normalizedPage - 1) * normalizedPageSize; return { items: mediaFromArchive.slice(offset, offset + normalizedPageSize), page: normalizedPage, pageSize: normalizedPageSize, totalPages, totalCount, }; } if (kindFilter && kindFilter !== "image") { return { items: [], page: normalizedPage, pageSize: normalizedPageSize, totalPages: 0, totalCount: 0, }; } const canvases = await ctx.db .query("canvases") .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) .order("desc") .collect(); const mediaFromNodeFallback = await buildMediaPreviewFromNodeFallback( ctx, canvases, Math.max(normalizedPage * normalizedPageSize * MEDIA_ARCHIVE_FETCH_MULTIPLIER, normalizedPageSize), ); const totalCount = mediaFromNodeFallback.length; const totalPages = totalCount > 0 ? Math.ceil(totalCount / normalizedPageSize) : 0; const offset = (normalizedPage - 1) * normalizedPageSize; return { items: mediaFromNodeFallback.slice(offset, offset + normalizedPageSize), page: normalizedPage, pageSize: normalizedPageSize, totalPages, totalCount, }; }, });