feat: zentrale selfhost-dienste bereitstellen

This commit is contained in:
2026-08-02 16:34:10 +02:00
parent d3ad53bcf0
commit 8b8e0c97d3
30 changed files with 1818 additions and 74 deletions

View File

@@ -0,0 +1,84 @@
import { randomUUID } from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify";
import { and, eq, gte, sql } from "drizzle-orm";
import { serviceEntitlements, serviceUsageEvents } from "@fedeo/push-db";
import { db } from "../db/client.js";
export type CentralServiceName = "ai" | "banking";
export async function requireCentralService(
request: FastifyRequest,
reply: FastifyReply,
service: CentralServiceName,
) {
const instance = request.pushInstance!;
const [entitlement] = await db
.select()
.from(serviceEntitlements)
.where(and(eq(serviceEntitlements.instanceId, instance.id), eq(serviceEntitlements.service, service)))
.limit(1);
if (!entitlement?.enabled) {
await reply.code(403).send({
error: "service_not_enabled",
message: `Der zentrale Dienst '${service}' ist für diese Instanz nicht freigeschaltet.`,
});
return null;
}
if (entitlement.monthlyLimit !== null) {
const monthStart = new Date();
monthStart.setUTCDate(1);
monthStart.setUTCHours(0, 0, 0, 0);
const [usage] = await db
.select({ units: sql<number>`coalesce(sum(${serviceUsageEvents.units}), 0)::bigint` })
.from(serviceUsageEvents)
.where(and(
eq(serviceUsageEvents.instanceId, instance.id),
eq(serviceUsageEvents.service, service),
eq(serviceUsageEvents.status, "succeeded"),
gte(serviceUsageEvents.createdAt, monthStart),
));
if (Number(usage?.units || 0) >= entitlement.monthlyLimit) {
await reply.code(429).send({
error: "service_monthly_limit_reached",
message: `Das Monatslimit für den zentralen Dienst '${service}' ist erreicht.`,
limit: entitlement.monthlyLimit,
used: Number(usage?.units || 0),
});
return null;
}
}
return entitlement;
}
export async function recordServiceUsage(input: {
request: FastifyRequest;
service: CentralServiceName;
operation: string;
units?: number;
status: "succeeded" | "failed";
unitPriceMicros: number;
provider?: string;
providerRequestId?: string | null;
errorCode?: string;
metadata?: Record<string, unknown>;
}) {
const requestId = String(input.request.headers["x-fedeo-request-id"] || randomUUID());
const units = Math.max(0, Math.trunc(input.units ?? 1));
await db.insert(serviceUsageEvents).values({
requestId,
instanceId: input.request.pushInstance!.id,
service: input.service,
operation: input.operation,
units,
costMicros: input.status === "succeeded" ? units * input.unitPriceMicros : 0,
status: input.status,
provider: input.provider,
providerRequestId: input.providerRequestId || null,
errorCode: input.errorCode,
metadata: input.metadata || {},
});
return requestId;
}