Files
lemonspace_app/convex/dashboard.ts

249 lines
7.3 KiB
TypeScript

import { query } 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 = 200;
const MEDIA_LIBRARY_MIN_LIMIT = 1;
const MEDIA_LIBRARY_MAX_LIMIT = 500;
type MediaPreviewItem = {
storageId: Id<"_storage">;
previewStorageId?: Id<"_storage">;
filename?: string;
mimeType?: string;
width?: number;
height?: number;
previewWidth?: number;
previewHeight?: number;
sourceCanvasId: Id<"canvases">;
sourceNodeId: Id<"nodes">;
createdAt: number;
};
function readImageMediaPreview(node: Doc<"nodes">): MediaPreviewItem | null {
if (node.type !== "image") {
return null;
}
const data = (node.data as Record<string, unknown> | 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 {
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<Doc<"nodes">>, 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<Id<"_storage">, MediaPreviewItem>();
for (const item of candidates) {
if (deduped.has(item.storageId)) {
continue;
}
deduped.set(item.storageId, 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)));
}
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] =
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(),
]);
const imageNodesByCanvas = await Promise.all(
canvases.map((canvas) =>
ctx.db
.query("nodes")
.withIndex("by_canvas_type", (q) => q.eq("canvasId", canvas._id).eq("type", "image"))
.order("desc")
.collect(),
),
);
const mediaPreview = buildMediaPreview(imageNodesByCanvas.flat(), 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: {
limit: v.optional(v.number()),
},
handler: async (ctx, { limit }) => {
const user = await optionalAuth(ctx);
if (!user) {
return [];
}
const normalizedLimit = normalizeMediaLibraryLimit(limit);
const canvases = await ctx.db
.query("canvases")
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
.order("desc")
.collect();
if (canvases.length === 0) {
return [];
}
const imageNodesByCanvas = await Promise.all(
canvases.map((canvas) =>
ctx.db
.query("nodes")
.withIndex("by_canvas_type", (q) => q.eq("canvasId", canvas._id).eq("type", "image"))
.order("desc")
.collect(),
),
);
return buildMediaPreview(imageNodesByCanvas.flat(), normalizedLimit);
},
});