Comprehensive admin + user dashboards (production-ready)
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import "server-only";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { periodKey } from "@/lib/utils";
|
||||
import type { UsageMetric } from "@/lib/billing/plans";
|
||||
|
||||
const METRICS: UsageMetric[] = ["script", "audio", "art", "repurpose"];
|
||||
|
||||
export interface UsageHistoryPoint {
|
||||
/** Monthly bucket key, e.g. "2026-06". */
|
||||
period: string;
|
||||
script: number;
|
||||
audio: number;
|
||||
art: number;
|
||||
repurpose: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only usage history for a billing subject: the last `months` monthly
|
||||
* buckets (oldest → newest), with per-metric counts. Missing buckets are zero
|
||||
* so the series is always dense (handy for a usage chart). `ownerId` is a
|
||||
* user.id or organization.id (the billing subject).
|
||||
*/
|
||||
export async function getUsageHistory(
|
||||
ownerId: string,
|
||||
months = 6,
|
||||
now = new Date()
|
||||
): Promise<UsageHistoryPoint[]> {
|
||||
const n = Math.max(1, Math.min(36, months));
|
||||
|
||||
// Build the ordered list of period keys we care about (oldest first).
|
||||
const periods: string[] = [];
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1));
|
||||
periods.push(periodKey(d));
|
||||
}
|
||||
|
||||
const rows = await prisma.usageRecord.findMany({
|
||||
where: { ownerId, periodKey: { in: periods } },
|
||||
select: { periodKey: true, metric: true, count: true },
|
||||
});
|
||||
|
||||
const byPeriod = new Map<string, UsageHistoryPoint>();
|
||||
for (const period of periods) {
|
||||
byPeriod.set(period, { period, script: 0, audio: 0, art: 0, repurpose: 0 });
|
||||
}
|
||||
for (const row of rows) {
|
||||
const point = byPeriod.get(row.periodKey);
|
||||
if (point && (METRICS as string[]).includes(row.metric)) {
|
||||
point[row.metric as UsageMetric] = row.count;
|
||||
}
|
||||
}
|
||||
|
||||
return periods.map((p) => byPeriod.get(p)!);
|
||||
}
|
||||
+31
-1
@@ -1,5 +1,5 @@
|
||||
import { getEffectivePlan } from "@/lib/billing/subscription";
|
||||
import { getUsage } from "./meter";
|
||||
import { getUsage, reserveUsage } from "./meter";
|
||||
import { PLANS, UNLIMITED, withinLimit, type PlanKey, type UsageMetric } from "@/lib/billing/plans";
|
||||
|
||||
export interface LimitCheck {
|
||||
@@ -46,6 +46,36 @@ export async function enforceLimit(
|
||||
return check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically RESERVE one unit of `metric` against the subject's plan cap.
|
||||
* Unlike `enforceLimit` (a read-only check), this consumes quota up front and
|
||||
* is race-safe — the canonical write path for any action that will generate
|
||||
* content. Throws `LimitExceededError` when the cap is already reached.
|
||||
*
|
||||
* Callers that proceed to enqueue/generate must REFUND (see `refundUsage`) if
|
||||
* the downstream work fails, so quota isn't permanently consumed by a failure.
|
||||
*/
|
||||
export async function reserveLimit(
|
||||
userId: string,
|
||||
metric: UsageMetric,
|
||||
activeOrgId?: string | null
|
||||
): Promise<LimitCheck> {
|
||||
const { key, subjectId, subjectType } = await getEffectivePlan(userId, activeOrgId);
|
||||
const limit = PLANS[key].limits[metric];
|
||||
const ok = await reserveUsage(subjectId, subjectType, metric, limit);
|
||||
// Re-read for an accurate `used` in the result; reservation already enforced.
|
||||
const used = await getUsage(subjectId, metric);
|
||||
const check: LimitCheck = {
|
||||
allowed: ok,
|
||||
used,
|
||||
limit,
|
||||
plan: key,
|
||||
metric,
|
||||
};
|
||||
if (!ok) throw new LimitExceededError(check);
|
||||
return check;
|
||||
}
|
||||
|
||||
export function isUnlimited(limit: number): boolean {
|
||||
return limit === UNLIMITED;
|
||||
}
|
||||
|
||||
+80
-1
@@ -1,9 +1,32 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { periodKey } from "@/lib/utils";
|
||||
import type { UsageMetric } from "@/lib/billing/plans";
|
||||
import { UNLIMITED, type UsageMetric } from "@/lib/billing/plans";
|
||||
|
||||
export type OwnerType = "user" | "organization";
|
||||
|
||||
/**
|
||||
* METERING INVARIANT (reserve-then-verify; see security finding M2)
|
||||
* ----------------------------------------------------------------
|
||||
* Usage is RESERVED atomically up front by the enqueuing caller (server
|
||||
* actions / API route) via `reserveUsage` (through `reserveLimit`), BEFORE the
|
||||
* episode is created/enqueued. Reservation atomically increments the counter
|
||||
* and rejects (refunding itself) when the post-increment count exceeds the cap,
|
||||
* which closes the time-of-check/time-of-use race that let concurrent requests
|
||||
* all pass a plain read-check and then blow past the monthly cap.
|
||||
*
|
||||
* Consequences for the rest of the system:
|
||||
* - The worker / generation pipeline DOES NOT increment usage. The metric was
|
||||
* already counted at enqueue time. Double counting is therefore impossible.
|
||||
* - On TERMINAL generation failure the worker REFUNDS the reserved metrics via
|
||||
* `refundUsage`, so a failed job does not permanently consume quota.
|
||||
* - Inline (synchronous) generations that don't go through the worker
|
||||
* (e.g. regenerate-section, repurpose) likewise RESERVE once up front and
|
||||
* never call `incrementUsage` afterwards; they refund on failure.
|
||||
*
|
||||
* In short: "the enqueuing caller reserves; the worker refunds on terminal
|
||||
* failure; the worker does not increment."
|
||||
*/
|
||||
|
||||
/** Increment a monthly usage counter for a billing subject. */
|
||||
export async function incrementUsage(
|
||||
ownerId: string,
|
||||
@@ -19,6 +42,62 @@ export async function incrementUsage(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically reserve `by` units of `metric` and verify the post-increment count
|
||||
* against `limit`. The upsert's `increment` is serialized by the
|
||||
* (ownerId, periodKey, metric) unique constraint and returns the row's
|
||||
* POST-increment count, so concurrent reservations can't both slip under the
|
||||
* cap. If the reservation would exceed `limit`, it is REFUNDED (decremented)
|
||||
* and `false` is returned. UNLIMITED (-1) is always allowed.
|
||||
*
|
||||
* Returns `true` when the reservation succeeded (quota consumed), `false` when
|
||||
* it was rejected (and already refunded — caller need not undo anything).
|
||||
*/
|
||||
export async function reserveUsage(
|
||||
ownerId: string,
|
||||
ownerType: OwnerType,
|
||||
metric: UsageMetric,
|
||||
limit: number,
|
||||
by = 1
|
||||
): Promise<boolean> {
|
||||
const key = periodKey(new Date());
|
||||
const row = await prisma.usageRecord.upsert({
|
||||
where: { ownerId_periodKey_metric: { ownerId, periodKey: key, metric } },
|
||||
create: { ownerId, ownerType, periodKey: key, metric, count: by },
|
||||
update: { count: { increment: by } },
|
||||
});
|
||||
|
||||
if (limit !== UNLIMITED && row.count > limit) {
|
||||
// Over cap — undo our own increment and reject.
|
||||
await refundUsage(ownerId, ownerType, metric, by);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund `by` units of a previously reserved metric (floored at 0). Used when a
|
||||
* reserved generation fails downstream, or by `reserveUsage` to undo a
|
||||
* rejected reservation.
|
||||
*/
|
||||
export async function refundUsage(
|
||||
ownerId: string,
|
||||
ownerType: OwnerType,
|
||||
metric: UsageMetric,
|
||||
by = 1
|
||||
): Promise<void> {
|
||||
const key = periodKey(new Date());
|
||||
const existing = await prisma.usageRecord.findUnique({
|
||||
where: { ownerId_periodKey_metric: { ownerId, periodKey: key, metric } },
|
||||
});
|
||||
if (!existing) return;
|
||||
const next = Math.max(0, existing.count - by);
|
||||
await prisma.usageRecord.update({
|
||||
where: { ownerId_periodKey_metric: { ownerId, periodKey: key, metric } },
|
||||
data: { count: next },
|
||||
});
|
||||
}
|
||||
|
||||
/** Current-period count for a single metric. */
|
||||
export async function getUsage(
|
||||
ownerId: string,
|
||||
|
||||
Reference in New Issue
Block a user