Files
lemonspace_app/convex/credits.ts
Matthias Meister 3aaad38e06 Enhance canvas sidebar and toolbar with improved UI and state management
- Integrated NextImage for logo display in the canvas sidebar, enhancing visual consistency.
- Updated canvas name handling in the toolbar to ensure proper display and accessibility.
- Refactored sidebar layout for better responsiveness and user experience.
- Improved state management for category collapsibility in the sidebar, allowing for a more intuitive navigation experience.
2026-04-03 13:51:41 +02:00

921 lines
26 KiB
TypeScript

import { query, mutation, internalMutation } from "./_generated/server";
import { v, ConvexError } from "convex/values";
import { optionalAuth, requireAuth } from "./helpers";
import { internal } from "./_generated/api";
// ============================================================================
// Tier-Konfiguration
// ============================================================================
export const TIER_CONFIG = {
free: {
monthlyCredits: 50,
dailyGenerationCap: 10,
concurrencyLimit: 1,
premiumModels: false,
topUpLimit: 50000,
},
starter: {
monthlyCredits: 400,
dailyGenerationCap: 50,
concurrencyLimit: 2,
premiumModels: true,
topUpLimit: 2000, // €20 pro Monat
},
pro: {
monthlyCredits: 3300,
dailyGenerationCap: 200,
concurrencyLimit: 2,
premiumModels: true,
topUpLimit: 10000, // €100 pro Monat
},
max: {
monthlyCredits: 6700,
dailyGenerationCap: 500,
concurrencyLimit: 2,
premiumModels: true,
topUpLimit: 50000,
},
business: {
monthlyCredits: 6700,
dailyGenerationCap: 500,
concurrencyLimit: 2,
premiumModels: true,
topUpLimit: 50000, // €500 pro Monat
},
} as const;
export type Tier = keyof typeof TIER_CONFIG;
const PERFORMANCE_LOG_THRESHOLD_MS = 250;
// ============================================================================
// Queries
// ============================================================================
/**
* Credit-Balance des eingeloggten Users abrufen.
* Gibt balance, reserved und computed available zurück.
*/
export const getBalance = query({
args: {},
handler: async (ctx) => {
const user = await optionalAuth(ctx);
if (!user) {
return { balance: 0, reserved: 0, available: 0, monthlyAllocation: 0 };
}
const balance = await ctx.db
.query("creditBalances")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.unique();
if (!balance) {
return { balance: 0, reserved: 0, available: 0, monthlyAllocation: 0 };
}
return {
balance: balance.balance,
reserved: balance.reserved,
available: balance.balance - balance.reserved,
monthlyAllocation: balance.monthlyAllocation,
};
},
});
/**
* Letzte Transaktionen des Users abrufen.
*/
export const listTransactions = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, { limit }) => {
const user = await requireAuth(ctx);
return await ctx.db
.query("creditTransactions")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.order("desc")
.take(limit ?? 50);
},
});
/**
* Aktuelle Subscription des Users abrufen (kompakt, immer definiert für die UI).
*/
export const getSubscription = query({
args: {},
handler: async (ctx) => {
const startedAt = Date.now();
try {
console.info("[credits.getSubscription] start", {
durationMs: Date.now() - startedAt,
});
const user = await optionalAuth(ctx);
console.info("[credits.getSubscription] auth resolved", {
durationMs: Date.now() - startedAt,
userId: user?.userId ?? null,
});
if (!user) {
return {
tier: "free" as const,
status: "active" as const,
};
}
const row = await ctx.db
.query("subscriptions")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.order("desc")
.first();
console.info("[credits.getSubscription] subscription query resolved", {
userId: user.userId,
durationMs: Date.now() - startedAt,
foundRow: Boolean(row),
});
if (!row) {
console.info("[credits.getSubscription] no subscription row", {
userId: user.userId,
durationMs: Date.now() - startedAt,
});
return {
tier: "free" as const,
status: "active" as const,
};
}
console.info("[credits.getSubscription] resolved subscription", {
userId: user.userId,
subscriptionId: row._id,
tier: row.tier,
status: row.status,
currentPeriodEnd: row.currentPeriodEnd,
durationMs: Date.now() - startedAt,
});
return {
tier: row.tier,
status: row.status,
currentPeriodEnd: row.currentPeriodEnd,
};
} catch (error) {
const identity = await ctx.auth.getUserIdentity();
console.error("[credits.getSubscription] failed", {
durationMs: Date.now() - startedAt,
hasIdentity: Boolean(identity),
identityIssuer: identity?.issuer ?? null,
identitySubject: identity?.subject ?? null,
message: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
});
/**
* Heutige Nutzung des Users abrufen (für Abuse Prevention).
*/
export const getDailyUsage = query({
args: {},
handler: async (ctx) => {
const user = await requireAuth(ctx);
const today = new Date().toISOString().split("T")[0]; // "2026-03-25"
const usage = await ctx.db
.query("dailyUsage")
.withIndex("by_user_date", (q) =>
q.eq("userId", user.userId).eq("date", today)
)
.unique();
return usage ?? { generationCount: 0, concurrentJobs: 0 };
},
});
/**
* Neueste Transaktionen des Users abrufen (für Dashboard "Recent Activity").
* Ähnlich wie listTransactions, aber als dedizierter Query mit explizitem Limit.
*/
export const getRecentTransactions = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const user = await optionalAuth(ctx);
if (!user) {
return [];
}
const limit = args.limit ?? 10;
return await ctx.db
.query("creditTransactions")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.order("desc")
.take(limit);
},
});
/**
* Monatliche Credit-Statistiken des Users abrufen (für Dashboard Verbrauchsbalken).
* Berechnet: monatlicher Verbrauch (nur committed usage-Transaktionen) + Anzahl Generierungen.
*/
export const getUsageStats = query({
args: {},
handler: async (ctx) => {
const user = await optionalAuth(ctx);
if (!user) {
return {
monthlyUsage: 0,
totalGenerations: 0,
};
}
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
const startedAt = Date.now();
const transactions = await ctx.db
.query("creditTransactions")
.withIndex("by_user_type", (q) =>
q.eq("userId", user.userId).eq("type", "usage")
)
.order("desc")
.collect();
const monthlyTransactions = [] as Array<typeof transactions[0]>;
for (const transaction of transactions) {
if (transaction._creationTime < monthStart) {
break;
}
if (transaction.status === "committed") {
monthlyTransactions.push(transaction);
}
}
const durationMs = Date.now() - startedAt;
if (durationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
console.warn("[credits.getUsageStats] slow usage stats query", {
userId: user.userId,
durationMs,
scannedTransactionCount: transactions.length,
includedCount: monthlyTransactions.length,
});
}
return {
monthlyUsage: monthlyTransactions.reduce(
(sum, t) => sum + Math.abs(t.amount),
0
),
totalGenerations: monthlyTransactions.length,
};
},
});
// ============================================================================
// Mutations — Credit Balance Management
// ============================================================================
/**
* Credit-Balance für einen neuen User initialisieren.
* Wird beim ersten Login / Signup aufgerufen.
*/
export const initBalance = mutation({
args: {},
handler: async (ctx) => {
const user = await requireAuth(ctx);
// Prüfen ob schon existiert
const existing = await ctx.db
.query("creditBalances")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.unique();
if (existing) return existing._id;
// Free-Tier Credits als Startguthaben
const balanceId = await ctx.db.insert("creditBalances", {
userId: user.userId,
balance: TIER_CONFIG.free.monthlyCredits,
reserved: 0,
monthlyAllocation: TIER_CONFIG.free.monthlyCredits,
updatedAt: Date.now(),
});
// Initiale Subscription (Free)
await ctx.db.insert("subscriptions", {
userId: user.userId,
tier: "free",
status: "active",
currentPeriodStart: Date.now(),
currentPeriodEnd: Date.now() + 30 * 24 * 60 * 60 * 1000, // +30 Tage
});
// Initiale Transaktion loggen
await ctx.db.insert("creditTransactions", {
userId: user.userId,
amount: TIER_CONFIG.free.monthlyCredits,
type: "subscription",
status: "committed",
description: "Startguthaben — Free Tier",
});
return balanceId;
},
});
/**
* Nur Testphase: schreibt dem eingeloggten User Gutschrift gut.
* In Produktion deaktiviert, außer ALLOW_TEST_CREDIT_GRANT ist in Convex auf "true" gesetzt.
*/
export const grantTestCredits = mutation({
args: {
amount: v.optional(v.number()),
},
handler: async (ctx, { amount = 2000 }) => {
if (process.env.ALLOW_TEST_CREDIT_GRANT !== "true") {
throw new ConvexError({ code: "CREDITS_TEST_DISABLED" });
}
if (amount <= 0 || amount > 1_000_000) {
throw new ConvexError({ code: "CREDITS_INVALID_AMOUNT" });
}
const user = await requireAuth(ctx);
const balance = await ctx.db
.query("creditBalances")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.unique();
if (!balance) {
throw new ConvexError({ code: "CREDITS_BALANCE_NOT_FOUND" });
}
const next = balance.balance + amount;
await ctx.db.patch(balance._id, {
balance: next,
updatedAt: Date.now(),
});
await ctx.db.insert("creditTransactions", {
userId: user.userId,
amount,
type: "subscription",
status: "committed",
description: `Testphase — Gutschrift (${amount} Cr)`,
});
return { newBalance: next };
},
});
// ============================================================================
// Mutations — Reservation + Commit (Kern des Credit-Systems)
// ============================================================================
/**
* Credits reservieren — vor einem KI-Call.
*
* Prüft: ausreichend verfügbare Credits, Daily Cap, Concurrency Limit.
* Gibt die Transaction-ID zurück (wird zum Commit/Release benötigt).
*/
export const reserve = mutation({
args: {
estimatedCost: v.number(), // Geschätzte Kosten in Cent
description: v.string(),
nodeId: v.optional(v.id("nodes")),
canvasId: v.optional(v.id("canvases")),
model: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await requireAuth(ctx);
// Balance laden
const balance = await ctx.db
.query("creditBalances")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.unique();
if (!balance) throw new Error("No credit balance found. Call initBalance first.");
const available = balance.balance - balance.reserved;
if (available < args.estimatedCost) {
throw new Error(
`Insufficient credits. Available: ${available}, required: ${args.estimatedCost}`
);
}
// Subscription laden für Tier-Checks
const subscription = await ctx.db
.query("subscriptions")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.order("desc")
.first();
const tier = (subscription?.tier ?? "free") as Tier;
const config = TIER_CONFIG[tier];
// Daily Cap prüfen
const today = new Date().toISOString().split("T")[0];
const dailyUsage = await ctx.db
.query("dailyUsage")
.withIndex("by_user_date", (q) =>
q.eq("userId", user.userId).eq("date", today)
)
.unique();
if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) {
throw new ConvexError({
code: "CREDITS_DAILY_CAP_REACHED",
data: { limit: config.dailyGenerationCap, tier },
});
}
// Concurrency Limit prüfen
if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) {
throw new ConvexError({
code: "CREDITS_CONCURRENCY_LIMIT",
data: { limit: config.concurrencyLimit },
});
}
// Credits reservieren
await ctx.db.patch(balance._id, {
reserved: balance.reserved + args.estimatedCost,
updatedAt: Date.now(),
});
// Daily Usage aktualisieren
if (dailyUsage) {
await ctx.db.patch(dailyUsage._id, {
generationCount: dailyUsage.generationCount + 1,
concurrentJobs: dailyUsage.concurrentJobs + 1,
});
} else {
await ctx.db.insert("dailyUsage", {
userId: user.userId,
date: today,
generationCount: 1,
concurrentJobs: 1,
});
}
// Reservation-Transaktion erstellen
const transactionId = await ctx.db.insert("creditTransactions", {
userId: user.userId,
amount: -args.estimatedCost,
type: "reservation",
status: "reserved",
description: args.description,
nodeId: args.nodeId,
canvasId: args.canvasId,
model: args.model,
});
return transactionId;
},
});
/**
* Reservation committen — interne Variante ohne Auth-Kontext.
*/
export const commitInternal = internalMutation({
args: {
transactionId: v.id("creditTransactions"),
actualCost: v.number(),
openRouterCost: v.optional(v.number()),
},
handler: async (ctx, { transactionId, actualCost, openRouterCost }) => {
const transaction = await ctx.db.get(transactionId);
if (!transaction) {
throw new Error("Transaction not found");
}
if (transaction.status === "committed") {
return { status: "already_committed" as const };
}
if (transaction.status === "released") {
return { status: "already_released" as const };
}
if (transaction.status !== "reserved") {
throw new Error(`Transaction is ${transaction.status}, expected reserved`);
}
const estimatedCost = Math.abs(transaction.amount);
// Balance aktualisieren
const balance = await ctx.db
.query("creditBalances")
.withIndex("by_user", (q) => q.eq("userId", transaction.userId))
.unique();
if (!balance) throw new Error("No credit balance found");
await ctx.db.patch(balance._id, {
balance: balance.balance - actualCost,
reserved: Math.max(0, balance.reserved - estimatedCost),
updatedAt: Date.now(),
});
// Transaktion committen
await ctx.db.patch(transactionId, {
amount: -actualCost,
type: "usage",
status: "committed",
openRouterCost,
});
// Concurrent Jobs dekrementieren
const today = new Date().toISOString().split("T")[0];
const dailyUsage = await ctx.db
.query("dailyUsage")
.withIndex("by_user_date", (q) =>
q.eq("userId", transaction.userId).eq("date", today)
)
.unique();
if (dailyUsage && dailyUsage.concurrentJobs > 0) {
await ctx.db.patch(dailyUsage._id, {
concurrentJobs: dailyUsage.concurrentJobs - 1,
});
}
return { status: "committed" as const };
},
});
/**
* Reservation committen — nach erfolgreichem KI-Call.
*
* Schreibt die tatsächlichen Kosten ab (können von Reservation abweichen).
*/
export const commit = mutation({
args: {
transactionId: v.id("creditTransactions"),
actualCost: v.number(),
openRouterCost: v.optional(v.number()),
},
handler: async (
ctx,
{ transactionId, actualCost, openRouterCost }
): Promise<
{ status: "already_committed" } |
{ status: "already_released" } |
{ status: "committed" }
> => {
const user = await requireAuth(ctx);
const transaction = await ctx.db.get(transactionId);
if (!transaction || transaction.userId !== user.userId) {
throw new Error("Transaction not found");
}
return await ctx.runMutation(internal.credits.commitInternal, {
transactionId,
actualCost,
openRouterCost,
});
},
});
/**
* Reservation freigeben — interne Variante ohne Auth-Kontext.
*/
export const releaseInternal = internalMutation({
args: {
transactionId: v.id("creditTransactions"),
},
handler: async (ctx, { transactionId }) => {
const transaction = await ctx.db.get(transactionId);
if (!transaction) {
throw new Error("Transaction not found");
}
if (transaction.status === "released") {
return { status: "already_released" as const };
}
if (transaction.status === "committed") {
return { status: "already_committed" as const };
}
if (transaction.status !== "reserved") {
throw new Error(`Transaction is ${transaction.status}, expected reserved`);
}
const estimatedCost = Math.abs(transaction.amount);
// Credits freigeben
const balance = await ctx.db
.query("creditBalances")
.withIndex("by_user", (q) => q.eq("userId", transaction.userId))
.unique();
if (!balance) throw new Error("No credit balance found");
await ctx.db.patch(balance._id, {
reserved: Math.max(0, balance.reserved - estimatedCost),
updatedAt: Date.now(),
});
// Transaktion als released markieren
await ctx.db.patch(transactionId, {
status: "released",
});
// Concurrent Jobs dekrementieren
const today = new Date().toISOString().split("T")[0];
const dailyUsage = await ctx.db
.query("dailyUsage")
.withIndex("by_user_date", (q) =>
q.eq("userId", transaction.userId).eq("date", today)
)
.unique();
if (dailyUsage && dailyUsage.concurrentJobs > 0) {
await ctx.db.patch(dailyUsage._id, {
concurrentJobs: dailyUsage.concurrentJobs - 1,
});
}
// Generation Count NICHT zurücksetzen — der Versuch zählt
return { status: "released" as const };
},
});
/**
* Reservation freigeben — bei fehlgeschlagenem KI-Call.
*
* Reservierte Credits werden komplett zurückgegeben.
*/
export const release = mutation({
args: {
transactionId: v.id("creditTransactions"),
},
handler: async (
ctx,
{ transactionId }
): Promise<
{ status: "already_released" } |
{ status: "already_committed" } |
{ status: "released" }
> => {
const user = await requireAuth(ctx);
const transaction = await ctx.db.get(transactionId);
if (!transaction || transaction.userId !== user.userId) {
throw new Error("Transaction not found");
}
return await ctx.runMutation(internal.credits.releaseInternal, {
transactionId,
});
},
});
// ============================================================================
// Mutations — Subscription & Top-Up (von Lemon Squeezy Webhooks aufgerufen)
// ============================================================================
/**
* Subscription aktivieren / ändern.
* Wird vom Lemon Squeezy Webhook aufgerufen.
*/
export const activateSubscription = internalMutation({
args: {
userId: v.string(),
tier: v.union(
v.literal("free"),
v.literal("starter"),
v.literal("pro"),
v.literal("max"),
v.literal("business")
),
lemonSqueezySubscriptionId: v.string(),
lemonSqueezyCustomerId: v.string(),
currentPeriodStart: v.number(),
currentPeriodEnd: v.number(),
},
handler: async (ctx, args) => {
const config = TIER_CONFIG[args.tier];
// Bestehende Subscription deaktivieren
const existing = await ctx.db
.query("subscriptions")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.order("desc")
.first();
if (existing) {
await ctx.db.patch(existing._id, { status: "cancelled" });
}
// Neue Subscription erstellen
await ctx.db.insert("subscriptions", {
userId: args.userId,
tier: args.tier,
status: "active",
currentPeriodStart: args.currentPeriodStart,
currentPeriodEnd: args.currentPeriodEnd,
lemonSqueezySubscriptionId: args.lemonSqueezySubscriptionId,
lemonSqueezyCustomerId: args.lemonSqueezyCustomerId,
});
// Credits gutschreiben
const balance = await ctx.db
.query("creditBalances")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.unique();
if (balance) {
await ctx.db.patch(balance._id, {
balance: balance.balance + config.monthlyCredits,
monthlyAllocation: config.monthlyCredits,
updatedAt: Date.now(),
});
} else {
await ctx.db.insert("creditBalances", {
userId: args.userId,
balance: config.monthlyCredits,
reserved: 0,
monthlyAllocation: config.monthlyCredits,
updatedAt: Date.now(),
});
}
// Transaktion loggen
await ctx.db.insert("creditTransactions", {
userId: args.userId,
amount: config.monthlyCredits,
type: "subscription",
status: "committed",
description: `Abo-Gutschrift — ${args.tier} Tier`,
});
},
});
/**
* Credits nachkaufen (Top-Up).
*/
export const topUp = mutation({
args: {
amount: v.number(), // Betrag in Cent
},
handler: async (ctx, { amount }) => {
const user = await requireAuth(ctx);
if (amount <= 0) throw new Error("Amount must be positive");
// Tier-Limit prüfen
const subscription = await ctx.db
.query("subscriptions")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.order("desc")
.first();
const tier = (subscription?.tier ?? "free") as Tier;
const config = TIER_CONFIG[tier];
// Monatliches Top-Up-Limit prüfen
const monthStart = new Date();
monthStart.setDate(1);
monthStart.setHours(0, 0, 0, 0);
const monthlyTopUps = await ctx.db
.query("creditTransactions")
.withIndex("by_user_type", (q) =>
q.eq("userId", user.userId).eq("type", "topup")
)
.collect();
const thisMonthTopUps = monthlyTopUps
.filter((t) => t._creationTime >= monthStart.getTime())
.reduce((sum, t) => sum + t.amount, 0);
if (thisMonthTopUps + amount > config.topUpLimit) {
throw new Error(
`Monthly top-up limit reached. Limit: ${config.topUpLimit}, used: ${thisMonthTopUps}`
);
}
// Credits gutschreiben
const balance = await ctx.db
.query("creditBalances")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.unique();
if (!balance) throw new Error("No credit balance found");
await ctx.db.patch(balance._id, {
balance: balance.balance + amount,
updatedAt: Date.now(),
});
await ctx.db.insert("creditTransactions", {
userId: user.userId,
amount,
type: "topup",
status: "committed",
description: `Credit-Nachkauf — ${(amount / 100).toFixed(2)}`,
});
},
});
// ============================================================================
// Internal Mutations — Abuse Prevention (für ai.ts Action)
// ============================================================================
/**
* Prüft Daily Cap und Concurrency — wirft Fehler bei Verstoß.
* Wird von generateImage aufgerufen BEVOR Credits reserviert werden.
* Wird benötigt, wenn INTERNAL_CREDITS_ENABLED !== "true".
*/
export const checkAbuseLimits = internalMutation({
args: {},
handler: async (ctx) => {
const user = await requireAuth(ctx);
const subscription = await ctx.db
.query("subscriptions")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.order("desc")
.first();
const tier = (subscription?.tier ?? "free") as Tier;
const config = TIER_CONFIG[tier];
const today = new Date().toISOString().split("T")[0];
const usage = await ctx.db
.query("dailyUsage")
.withIndex("by_user_date", (q) =>
q.eq("userId", user.userId).eq("date", today)
)
.unique();
const dailyCount = usage?.generationCount ?? 0;
if (dailyCount >= config.dailyGenerationCap) {
throw new ConvexError({
code: "CREDITS_DAILY_CAP_REACHED",
data: { limit: config.dailyGenerationCap, tier },
});
}
const currentConcurrency = usage?.concurrentJobs ?? 0;
if (currentConcurrency >= config.concurrencyLimit) {
throw new ConvexError({
code: "CREDITS_CONCURRENCY_LIMIT",
data: { limit: config.concurrencyLimit },
});
}
},
});
/**
* Erhöht generationCount und concurrentJobs atomar.
* Nur aufrufen wenn INTERNAL_CREDITS_ENABLED !== "true"
* (reserve übernimmt das bei aktivierten Credits).
*/
export const incrementUsage = internalMutation({
args: {},
handler: async (ctx) => {
const user = await requireAuth(ctx);
const today = new Date().toISOString().split("T")[0];
const usage = await ctx.db
.query("dailyUsage")
.withIndex("by_user_date", (q) =>
q.eq("userId", user.userId).eq("date", today)
)
.unique();
if (usage) {
await ctx.db.patch(usage._id, {
generationCount: usage.generationCount + 1,
concurrentJobs: usage.concurrentJobs + 1,
});
} else {
await ctx.db.insert("dailyUsage", {
userId: user.userId,
date: today,
generationCount: 1,
concurrentJobs: 1,
});
}
},
});
/**
* Verringert concurrentJobs um 1 (Minimum 0).
* Nur aufrufen wenn INTERNAL_CREDITS_ENABLED !== "true"
* (commit/release übernehmen das bei aktivierten Credits).
*/
export const decrementConcurrency = internalMutation({
args: {
userId: v.optional(v.string()),
},
handler: async (ctx, args) => {
const resolvedUserId =
args.userId ?? (await requireAuth(ctx)).userId;
const today = new Date().toISOString().split("T")[0];
const usage = await ctx.db
.query("dailyUsage")
.withIndex("by_user_date", (q) =>
q.eq("userId", resolvedUserId).eq("date", today)
)
.unique();
if (usage && usage.concurrentJobs > 0) {
await ctx.db.patch(usage._id, {
concurrentJobs: usage.concurrentJobs - 1,
});
}
},
});