From 8b8e0c97d314f32533d26e02fe34037a5e1d7605 Mon Sep 17 00:00:00 2001 From: flfeders Date: Sun, 2 Aug 2026 16:34:10 +0200 Subject: [PATCH] feat: zentrale selfhost-dienste bereitstellen --- README.md | 6 + .../modules/cron/bankstatementsync.service.ts | 8 + backend/src/modules/push-server.client.ts | 40 +- backend/src/routes/banking.ts | 66 +- backend/src/utils/gpt.ts | 17 +- backend/src/utils/liquidityForecast.ts | 20 +- backend/src/utils/secrets.ts | 4 +- docker-compose.selfhost.yml | 4 + push-server/.env.example | 7 + push-server/README.md | 40 +- .../apps/admin/composables/usePushApi.ts | 2 +- push-server/apps/admin/pages/index.vue | 11 +- .../apps/admin/pages/instances/[id].vue | 65 +- push-server/apps/api/Dockerfile | 4 +- push-server/apps/api/src/config/env.ts | 5 + push-server/apps/api/src/index.ts | 2 + push-server/apps/api/src/routes/admin.ts | 49 +- push-server/apps/api/src/routes/public.ts | 4 +- push-server/apps/api/src/routes/services.ts | 131 ++ .../apps/api/src/services/central-services.ts | 84 ++ push-server/docker-compose.yml | 20 +- push-server/docs/instance-client.md | 19 +- push-server/package.json | 2 +- .../db/drizzle/0001_central_services.sql | 36 + .../db/drizzle/meta/0001_snapshot.json | 1159 +++++++++++++++++ .../packages/db/drizzle/meta/_journal.json | 7 + push-server/packages/db/package.json | 4 +- push-server/packages/db/src/migrate.ts | 4 +- push-server/packages/db/src/schema.ts | 50 + scripts/selfhost-setup.sh | 22 +- 30 files changed, 1818 insertions(+), 74 deletions(-) create mode 100644 push-server/apps/api/src/routes/services.ts create mode 100644 push-server/apps/api/src/services/central-services.ts create mode 100644 push-server/packages/db/drizzle/0001_central_services.sql create mode 100644 push-server/packages/db/drizzle/meta/0001_snapshot.json diff --git a/README.md b/README.md index a5f85ac..036b716 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,12 @@ DOKUBOX_IMAP_PASSWORD=change-this-imap-password OPENAI_API_KEY=replace-this STIRLING_API_KEY=replace-this +# Optional: zentrale, durch den FEDEO-Betreiber bereitgestellte Dienste +FEDEO_CENTRAL_SERVICES_ENABLED=false +PUSH_SERVER_URL=https://services.fedeo.de +PUSH_SERVER_INSTANCE_ID= +PUSH_SERVER_SECRET= + NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com diff --git a/backend/src/modules/cron/bankstatementsync.service.ts b/backend/src/modules/cron/bankstatementsync.service.ts index 66863b0..0841dff 100644 --- a/backend/src/modules/cron/bankstatementsync.service.ts +++ b/backend/src/modules/cron/bankstatementsync.service.ts @@ -1,5 +1,6 @@ // /services/bankStatementService.ts import axios from "axios" +import { centralServicesClient } from "../push-server.client" import dayjs from "dayjs" import utc from "dayjs/plugin/utc.js" import {secrets} from "../../utils/secrets" @@ -57,11 +58,13 @@ const normalizeDate = (val: any) => { export function bankStatementService(server: FastifyInstance) { let accessToken: string | null = null + const useCentralBanking = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured()) // ----------------------------------------------- // ✔ TOKEN LADEN // ----------------------------------------------- const getToken = async () => { + if (useCentralBanking) return console.log("Fetching GoCardless token…") const response = await axios.post( @@ -80,6 +83,7 @@ export function bankStatementService(server: FastifyInstance) { // ----------------------------------------------- const getBalanceData = async (accountId: string): Promise => { try { + if (useCentralBanking) return await centralServicesClient.getBankingBalances(accountId) const {data} = await axios.get( `${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/balances`, { @@ -114,6 +118,10 @@ export function bankStatementService(server: FastifyInstance) { // ----------------------------------------------- const getTransactionData = async (accountId: string) => { try { + if (useCentralBanking) { + const data = await centralServicesClient.getBankingTransactions(accountId) + return data.transactions.booked + } const {data} = await axios.get( `${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/transactions`, { diff --git a/backend/src/modules/push-server.client.ts b/backend/src/modules/push-server.client.ts index 3413609..a552150 100644 --- a/backend/src/modules/push-server.client.ts +++ b/backend/src/modules/push-server.client.ts @@ -1,4 +1,4 @@ -import { createHash, createHmac } from "node:crypto" +import { createHash, createHmac, randomUUID } from "node:crypto" import { secrets } from "../utils/secrets" @@ -54,13 +54,14 @@ function signature(method: string, path: string, timestamp: string, body: string return createHmac("sha256", secrets.PUSH_SERVER_SECRET).update(canonical).digest("hex") } -async function requestPushServer(method: "POST" | "DELETE", path: string, payload?: unknown): Promise { +async function requestPushServer(method: "GET" | "POST" | "DELETE", path: string, payload?: unknown): Promise { if (!configured()) { throw new Error("Zentraler Push-Server ist nicht konfiguriert") } const body = payload === undefined ? "" : JSON.stringify(payload) const timestamp = new Date().toISOString() + const signedPath = path.split("?")[0] const response = await fetch(`${normalizeBaseUrl()}${path}`, { method, headers: { @@ -68,7 +69,8 @@ async function requestPushServer(method: "POST" | "DELETE", path: string, pay "Content-Type": "application/json", "X-Fedeo-Instance-Id": secrets.PUSH_SERVER_INSTANCE_ID, "X-Fedeo-Timestamp": timestamp, - "X-Fedeo-Signature": signature(method, path, timestamp, body), + "X-Fedeo-Signature": signature(method, signedPath, timestamp, body), + "X-Fedeo-Request-Id": randomUUID(), }, body: body || undefined, }) @@ -99,3 +101,35 @@ export const pushServerClient = { }) }, } + +export const centralServicesClient = { + configured, + + aiChatCompletions(payload: Record) { + return requestPushServer("POST", "/v1/services/ai/chat-completions", payload) + }, + + bankingInstitutions(country = "DE") { + return requestPushServer("GET", `/v1/services/banking/institutions?country=${encodeURIComponent(country)}`) + }, + + createBankingRequisition(input: { institutionId: string; redirect: string; userLanguage?: string }) { + return requestPushServer("POST", "/v1/services/banking/requisitions", input) + }, + + getBankingRequisition(id: string) { + return requestPushServer("GET", `/v1/services/banking/requisitions/${encodeURIComponent(id)}`) + }, + + getBankingAccount(id: string) { + return requestPushServer("GET", `/v1/services/banking/accounts/${encodeURIComponent(id)}`) + }, + + getBankingBalances(id: string) { + return requestPushServer("GET", `/v1/services/banking/accounts/${encodeURIComponent(id)}/balances`) + }, + + getBankingTransactions(id: string) { + return requestPushServer("GET", `/v1/services/banking/accounts/${encodeURIComponent(id)}/transactions`) + }, +} diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index a7ff102..9fb60bb 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -7,6 +7,7 @@ import { insertHistoryItem } from "../utils/history" import { decrypt, encrypt } from "../utils/crypt" import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes" import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics" +import { centralServicesClient } from "../modules/push-server.client" import { bankrequisitions, @@ -902,6 +903,7 @@ export default async function bankingRoutes(server: FastifyInstance) { const goCardLessBaseUrl = secrets.GOCARDLESS_BASE_URL const goCardLessSecretId = secrets.GOCARDLESS_SECRET_ID const goCardLessSecretKey = secrets.GOCARDLESS_SECRET_KEY + const useCentralBanking = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured()) let tokenData: any = null @@ -918,6 +920,7 @@ export default async function bankingRoutes(server: FastifyInstance) { } const checkToken = async () => { + if (useCentralBanking) return if (!tokenData) return await getToken() const expired = dayjs(tokenData.created_at) @@ -935,24 +938,23 @@ export default async function bankingRoutes(server: FastifyInstance) { // ------------------------------------------------------------------ server.get("/banking/link/:institutionid", async (req, reply) => { try { - await checkToken() - const { institutionid } = req.params as { institutionid: string } const tenantId = req.user?.tenant_id if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) - const { data } = await axios.post( - `${goCardLessBaseUrl}/requisitions/`, - { - redirect: "https://app.fedeo.de/settings/banking", - institution_id: institutionid, - user_language: "de", - }, - { - headers: { Authorization: `Bearer ${tokenData.access}` }, - } - ) + const redirect = new URL("/settings/banking", secrets.API_BASE_URL).toString() + let data: any + if (useCentralBanking) { + data = await centralServicesClient.createBankingRequisition({ institutionId: institutionid, redirect, userLanguage: "de" }) + } else { + await checkToken() + ;({ data } = await axios.post( + `${goCardLessBaseUrl}/requisitions/`, + { redirect, institution_id: institutionid, user_language: "de" }, + { headers: { Authorization: `Bearer ${tokenData.access}` } } + )) + } // DB: Requisition speichern await server.db.insert(bankrequisitions).values({ @@ -977,12 +979,16 @@ export default async function bankingRoutes(server: FastifyInstance) { const { bic } = req.params as { bic: string } if (!bic) return reply.code(400).send("BIC missing") - await checkToken() - - const { data } = await axios.get( - `${goCardLessBaseUrl}/institutions/?country=de`, - { headers: { Authorization: `Bearer ${tokenData.access}` } } - ) + let data: any[] + if (useCentralBanking) { + data = await centralServicesClient.bankingInstitutions("DE") + } else { + await checkToken() + ;({ data } = await axios.get( + `${goCardLessBaseUrl}/institutions/?country=de`, + { headers: { Authorization: `Bearer ${tokenData.access}` } } + )) + } const bank = data.find((i: any) => i.bic.toLowerCase() === bic.toLowerCase()) @@ -1004,21 +1010,23 @@ export default async function bankingRoutes(server: FastifyInstance) { const { reqId } = req.params as { reqId: string } if (!reqId) return reply.code(400).send("Requisition ID missing") - await checkToken() - - const { data } = await axios.get( - `${goCardLessBaseUrl}/requisitions/${reqId}`, - { headers: { Authorization: `Bearer ${tokenData.access}` } } - ) + let data: any + if (useCentralBanking) { + data = await centralServicesClient.getBankingRequisition(reqId) + } else { + await checkToken() + ;({ data } = await axios.get( + `${goCardLessBaseUrl}/requisitions/${reqId}`, + { headers: { Authorization: `Bearer ${tokenData.access}` } } + )) + } // Load account details if (data.accounts) { data.accounts = await Promise.all( data.accounts.map(async (accId: string) => { - const { data: acc } = await axios.get( - `${goCardLessBaseUrl}/accounts/${accId}`, - { headers: { Authorization: `Bearer ${tokenData.access}` } } - ) + if (useCentralBanking) return centralServicesClient.getBankingAccount(accId) + const { data: acc } = await axios.get(`${goCardLessBaseUrl}/accounts/${accId}`, { headers: { Authorization: `Bearer ${tokenData.access}` } }) return acc }) ) diff --git a/backend/src/utils/gpt.ts b/backend/src/utils/gpt.ts index 3577faa..d91b610 100644 --- a/backend/src/utils/gpt.ts +++ b/backend/src/utils/gpt.ts @@ -7,6 +7,7 @@ import { FastifyInstance } from "fastify"; import { storeExtractedTextForFile } from "./documentText"; import { loadFileBuffer } from "./fileBuffer"; import { secrets } from "./secrets"; +import { centralServicesClient } from "../modules/push-server.client"; // Drizzle schema import { vendors, accounts, tenants } from "../../db/schema"; @@ -21,6 +22,7 @@ const nullableNumber = z.number().nullable(); // INITIALIZE OPENAI // --------------------------------------------------------- export const initOpenAi = async () => { + if (secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured()) return; openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY, }); @@ -84,8 +86,9 @@ export const getInvoiceDataFromGPT = async function ( suppliedFileData?: Buffer, ) { await initOpenAi(); + const useCentralAi = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured()); - if (!openai) { + if (!useCentralAi && !openai) { throw new Error("OpenAI not initialized. Call initOpenAi() first."); } @@ -164,7 +167,7 @@ export const getInvoiceDataFromGPT = async function ( - const completion = await openai.chat.completions.parse({ + const completionRequest: any = { model: "gpt-4o", store: true, response_format: zodResponseFormat(InstructionFormat as any, "instruction"), @@ -190,9 +193,13 @@ export const getInvoiceDataFromGPT = async function ( "Keep invoice items in original order.\n", }, ], - }); - - const parsed = completion.choices[0].message.parsed; + }; + const completion = useCentralAi + ? await centralServicesClient.aiChatCompletions(completionRequest) + : await openai!.chat.completions.parse(completionRequest); + const parsed = useCentralAi + ? InstructionFormat.parse(JSON.parse(completion.choices[0]?.message?.content || "{}")) + : completion.choices[0].message.parsed; console.log(`🧾 Extracted invoice data for file ${file.id}`); diff --git a/backend/src/utils/liquidityForecast.ts b/backend/src/utils/liquidityForecast.ts index b10dcd3..21d10a1 100644 --- a/backend/src/utils/liquidityForecast.ts +++ b/backend/src/utils/liquidityForecast.ts @@ -5,6 +5,7 @@ import { zodResponseFormat } from "openai/helpers/zod"; import { and, desc, eq, gte } from "drizzle-orm"; import { FastifyInstance } from "fastify"; import { createHash } from "node:crypto"; +import { centralServicesClient } from "../modules/push-server.client"; import { bankaccounts, @@ -382,9 +383,10 @@ const detectRecurringHeuristically = (statements: any[]): RecurringCandidate[] = }; const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise => { - if (!secrets.OPENAI_API_KEY || statements.length < 6) return []; + const useCentralAi = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured()); + if ((!secrets.OPENAI_API_KEY && !useCentralAi) || statements.length < 6) return []; - const openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY }); + const openai = useCentralAi ? null : new OpenAI({ apiKey: secrets.OPENAI_API_KEY }); const compactStatements = statements.slice(0, 220).map((statement) => ({ date: statement.valueDate || statement.date, amount: roundMoney(Number(statement.amount || 0)), @@ -393,7 +395,7 @@ const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]) })); try { - const completion = await openai.chat.completions.parse({ + const completionRequest: any = { model: "gpt-4o", store: true, response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"), @@ -416,9 +418,15 @@ const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]) }), }, ], - }); + }; + const completion = useCentralAi + ? await centralServicesClient.aiChatCompletions(completionRequest) + : await openai!.chat.completions.parse(completionRequest); + const parsed = useCentralAi + ? AiRecurringFormat.parse(JSON.parse(completion.choices[0]?.message?.content || "{}")) + : completion.choices[0].message.parsed; - return (completion.choices[0].message.parsed?.candidates || []) + return (parsed?.candidates || []) .filter((candidate) => dayjs(candidate.nextDate).isValid()) .filter((candidate) => Number(candidate.amount || 0) < 0) .map((candidate) => ({ @@ -832,7 +840,7 @@ export const generateLiquidityForecast = async ( }, points, ai: { - enabled: Boolean(secrets.OPENAI_API_KEY), + enabled: Boolean(secrets.OPENAI_API_KEY || (secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured())), candidates: aiRecurring.length, }, }; diff --git a/backend/src/utils/secrets.ts b/backend/src/utils/secrets.ts index 62c77d5..aa4b4f4 100644 --- a/backend/src/utils/secrets.ts +++ b/backend/src/utils/secrets.ts @@ -53,6 +53,7 @@ export let secrets = { PUSH_SERVER_URL?: string PUSH_SERVER_INSTANCE_ID?: string PUSH_SERVER_SECRET?: string + FEDEO_CENTRAL_SERVICES_ENABLED?: boolean } const secretKeys = [ @@ -100,10 +101,11 @@ const secretKeys = [ "PUSH_SERVER_URL", "PUSH_SERVER_INSTANCE_ID", "PUSH_SERVER_SECRET", + "FEDEO_CENTRAL_SERVICES_ENABLED", ] as const const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"]) -const booleanKeys = new Set(["DOKUBOX_IMAP_SECURE"]) +const booleanKeys = new Set(["DOKUBOX_IMAP_SECURE", "FEDEO_CENTRAL_SERVICES_ENABLED"]) function normalizeEnvValue(key: string, value: string) { if (numberKeys.has(key)) return Number(value) diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 9bdc459..19fdb36 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -129,6 +129,10 @@ services: DOKUBOX_IMAP_USER: ${DOKUBOX_IMAP_USER} DOKUBOX_IMAP_PASSWORD: ${DOKUBOX_IMAP_PASSWORD} OPENAI_API_KEY: ${OPENAI_API_KEY} + FEDEO_CENTRAL_SERVICES_ENABLED: ${FEDEO_CENTRAL_SERVICES_ENABLED:-false} + PUSH_SERVER_URL: ${PUSH_SERVER_URL:-} + PUSH_SERVER_INSTANCE_ID: ${PUSH_SERVER_INSTANCE_ID:-} + PUSH_SERVER_SECRET: ${PUSH_SERVER_SECRET:-} STIRLING_API_KEY: ${STIRLING_API_KEY} FEDEO_BOOTSTRAP_ADMIN_EMAIL: ${FEDEO_BOOTSTRAP_ADMIN_EMAIL:-} FEDEO_BOOTSTRAP_ADMIN_PASSWORD: ${FEDEO_BOOTSTRAP_ADMIN_PASSWORD:-} diff --git a/push-server/.env.example b/push-server/.env.example index c43c964..e0991dc 100644 --- a/push-server/.env.example +++ b/push-server/.env.example @@ -15,5 +15,12 @@ APNS_PRODUCTION=false ANDROID_SENDER_ID= FCM_PROJECT_ID= FCM_SERVICE_ACCOUNT_JSON= + +# Zentral betriebene, abrechenbare Dienste +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.openai.com/v1 +GOCARDLESS_BASE_URL=https://bankaccountdata.gocardless.com/api/v2 +GOCARDLESS_SECRET_ID= +GOCARDLESS_SECRET_KEY= NUXT_PUBLIC_API_BASE=http://localhost:4020 NUXT_ADMIN_TOKEN=change-me-admin-token diff --git a/push-server/README.md b/push-server/README.md index 453bab3..2f73b74 100644 --- a/push-server/README.md +++ b/push-server/README.md @@ -1,6 +1,6 @@ -# FEDEO Push Server +# FEDEO Central Services -Eigenständiger Stack für den zentralen FEDEO Push-Transportdienst. Selfhosted FEDEO-Instanzen registrieren Geräte lokal und leiten nur technische Push-Aufträge an diesen Dienst weiter. Die Instanzen authentifizieren sich mit einem rotierbaren Schlüssel, der im Admin-Dashboard gepflegt wird. +Eigenständiger Stack für zentrale FEDEO-Dienste. Neben Push können Selfhost-Instanzen darüber freigeschaltete und verbrauchsabhängig erfasste KI- und Banking-Dienste verwenden. Die Instanzen authentifizieren sich mit einem rotierbaren HMAC-Schlüssel, der im Admin-Dashboard gepflegt wird. ## Bestandteile @@ -9,6 +9,8 @@ Eigenständiger Stack für den zentralen FEDEO Push-Transportdienst. Selfhosted - `packages/db`: Drizzle Schema und Migrationen für PostgreSQL - `docker-compose.yml`: lokaler Stack aus Postgres, API und Admin +Provider-Schlüssel wie `OPENAI_API_KEY` und die GoCardless-Zugangsdaten liegen ausschließlich in diesem zentralen Stack. Selfhost-Instanzen erhalten nur Instanz-ID und Instanzschlüssel. + ## Entwicklung ```bash @@ -20,6 +22,16 @@ npm run dev:api npm run dev:admin ``` +Für den dauerhaften Betrieb: + +```bash +cp .env.example .env +# .env mit sicheren Zugangsdaten und Provider-Schlüsseln befüllen +docker compose up -d --build +``` + +Der API-Container wendet Datenbankmigrationen vor jedem Start automatisch an. PostgreSQL-Daten liegen im Volume `push_postgres_data`. Für ein öffentliches Deployment müssen API und Admin hinter einem TLS-Reverse-Proxy betrieben und `PUBLIC_API_BASE_URL` auf die öffentliche API-Adresse gesetzt werden. + Standardports: - API: `http://localhost:4020` @@ -70,6 +82,13 @@ const signature = createHmac("sha256", clientSecret).update(canonical).digest("h - `DELETE /v1/devices/:centralDeviceId` - `POST /v1/push` - `GET /v1/push/:deliveryJobId` +- `POST /v1/services/ai/chat-completions` +- `GET /v1/services/banking/institutions` +- `POST /v1/services/banking/requisitions` +- `GET /v1/services/banking/requisitions/:id` +- `GET /v1/services/banking/accounts/:id` +- `GET /v1/services/banking/accounts/:id/balances` +- `GET /v1/services/banking/accounts/:id/transactions` Admin: @@ -80,6 +99,19 @@ Admin: - `POST /admin/instances/:id/rotate-secret` - `GET /admin/instances/:id/devices` - `GET /admin/instances/:id/jobs` +- `GET /admin/instances/:id/services` +- `PUT /admin/instances/:id/services/:service` +- `GET /admin/instances/:id/usage` +- `GET /admin/usage/summary` + +## Freischaltung und Abrechnung + +Die Dienste `ai` und `banking` sind für neue Instanzen standardmäßig gesperrt. Im Instanzdetail des Admin-Dashboards werden sie einzeln aktiviert. Pro Dienst können ein Monatslimit und ein Preis in Mikro-Euro je Einheit gepflegt werden. + +- KI wird anhand der von OpenAI gemeldeten Gesamt-Token erfasst. +- Banking wird je erfolgreichem Provider-Aufruf erfasst. +- Fehlgeschlagene Aufrufe werden technisch protokolliert, aber mit `0` Einheiten und `0` Kosten verbucht. +- Usage Events enthalten technische Metadaten, aber keine Prompts, Bankumsätze oder sonstigen fachlichen Nutzdaten. ## Apple Push Notification service @@ -106,6 +138,9 @@ Implementiert: - Zustelljobs mit Idempotenzschlüssel - APNs-Zustellung für iOS - technische Status- und Fehlererfassung +- dienstbezogene Freischaltungen und Monatslimits +- unveränderliche Usage Events samt Kostenwert +- zentraler OpenAI- und GoCardless-Zugang Vorbereitet, aber noch nicht vollständig implementiert: @@ -113,3 +148,4 @@ Vorbereitet, aber noch nicht vollständig implementiert: - FCM Zustellung - asynchrone Queue/Worker-Verarbeitung - produktive Rate-Limits pro Instanz +- Rechnungsstellung beziehungsweise Export an ein Buchhaltungssystem diff --git a/push-server/apps/admin/composables/usePushApi.ts b/push-server/apps/admin/composables/usePushApi.ts index f9904c5..208ed99 100644 --- a/push-server/apps/admin/composables/usePushApi.ts +++ b/push-server/apps/admin/composables/usePushApi.ts @@ -1,5 +1,5 @@ type FetchOptions = { - method?: "GET" | "POST" | "PATCH" | "DELETE"; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; body?: unknown; }; diff --git a/push-server/apps/admin/pages/index.vue b/push-server/apps/admin/pages/index.vue index 445c44d..487b8d8 100644 --- a/push-server/apps/admin/pages/index.vue +++ b/push-server/apps/admin/pages/index.vue @@ -6,7 +6,8 @@ const { data, pending, refresh, error } = await useAsyncData("summary", () => pu instances: number; devices: number; jobs: number; - failedJobs: number; + failedJobs: number; + serviceUnits: number; }>("/admin/summary"), { immediate: false }); onMounted(async () => { @@ -26,12 +27,12 @@ watch(error, (nextError) => {

Übersicht

-

Technischer Zustand des zentralen Push-Gateways.

+

Technischer Zustand des zentralen FEDEO-Service-Gateways.

Aktualisieren
-
+

Instanzen

{{ data?.instances ?? 0 }}

@@ -48,6 +49,10 @@ watch(error, (nextError) => {

Fehler

{{ data?.failedJobs ?? 0 }}

+ +

Service-Einheiten

+

{{ data?.serviceUnits ?? 0 }}

+
diff --git a/push-server/apps/admin/pages/instances/[id].vue b/push-server/apps/admin/pages/instances/[id].vue index f197f32..f4817a7 100644 --- a/push-server/apps/admin/pages/instances/[id].vue +++ b/push-server/apps/admin/pages/instances/[id].vue @@ -4,6 +4,7 @@ const pushApi = usePushApi(); const toast = useToast(); const id = route.params.id as string; const testSubmitting = ref(false); +const serviceSaving = ref(""); const selectedDeviceIds = ref([]); const testForm = reactive({ title: "FEDEO Push-Test", @@ -13,6 +14,12 @@ const testForm = reactive({ const { data: instance, refresh: refreshInstance } = await useAsyncData(`instance-${id}`, () => pushApi.request>(`/admin/instances/${id}`), { immediate: false }); const { data: devices, refresh: refreshDevices } = await useAsyncData(`devices-${id}`, () => pushApi.request[]>(`/admin/instances/${id}/devices`), { default: () => [], immediate: false }); const { data: jobs, refresh: refreshJobs } = await useAsyncData(`jobs-${id}`, () => pushApi.request[]>(`/admin/instances/${id}/jobs`), { default: () => [], immediate: false }); +const { data: entitlements, refresh: refreshEntitlements } = await useAsyncData(`services-${id}`, () => pushApi.request[]>(`/admin/instances/${id}/services`), { default: () => [], immediate: false }); +const { data: usage, refresh: refreshUsage } = await useAsyncData(`usage-${id}`, () => pushApi.request[]>(`/admin/instances/${id}/usage?limit=100`), { default: () => [], immediate: false }); +const serviceForms = reactive({ + ai: { enabled: false, monthlyLimit: null as number | null, unitPriceMicros: 0 }, + banking: { enabled: false, monthlyLimit: null as number | null, unitPriceMicros: 0 }, +}); const activeDevices = computed(() => (devices.value || []).filter((device) => device.status === "active")); onMounted(async () => { @@ -23,7 +30,30 @@ onMounted(async () => { }); async function refreshAll() { - await Promise.all([refreshInstance(), refreshDevices(), refreshJobs()]); + await Promise.all([refreshInstance(), refreshDevices(), refreshJobs(), refreshEntitlements(), refreshUsage()]); + for (const entitlement of entitlements.value || []) { + const form = serviceForms[entitlement.service as "ai" | "banking"]; + if (form) Object.assign(form, { enabled: entitlement.enabled, monthlyLimit: entitlement.monthlyLimit, unitPriceMicros: entitlement.unitPriceMicros }); + } +} + +async function saveService(service: "ai" | "banking") { + serviceSaving.value = service; + try { + const form = serviceForms[service]; + await pushApi.request(`/admin/instances/${id}/services/${service}`, { + method: "PUT", + body: { + enabled: form.enabled, + monthlyLimit: Number(form.monthlyLimit) > 0 ? Number(form.monthlyLimit) : null, + unitPriceMicros: Math.max(0, Number(form.unitPriceMicros) || 0), + }, + }); + await Promise.all([refreshEntitlements(), refreshUsage()]); + toast.add({ title: `${service === "ai" ? "KI" : "Banking"}-Dienst gespeichert`, color: "success" }); + } finally { + serviceSaving.value = ""; + } } function toggleDevice(centralDeviceId: string, checked: boolean) { @@ -96,6 +126,39 @@ async function sendTestPush() { + + +
+
+
+
+

{{ service === 'ai' ? 'KI' : 'Banking' }}

+

{{ service === 'ai' ? 'Einheit: Provider-Token' : 'Einheit: Provider-Aufruf' }}

+
+ +
+ + + + + + + Speichern +
+
+
+ + + + + + + + +
ZeitDienstVorgangStatusEinheitenKosten
{{ new Date(event.createdAt).toLocaleString() }}{{ event.service }}{{ event.operation }}{{ event.status }}{{ event.units }}{{ (Number(event.costMicros || 0) / 1_000_000).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) }}
Noch keine Nutzung erfasst.
+
+
+