Compare commits
3 Commits
f596b46364
...
db4e9612a0
| Author | SHA1 | Date | |
|---|---|---|---|
| db4e9612a0 | |||
| cb4917c536 | |||
| 9f32eb5439 |
@@ -43,6 +43,7 @@ export * from "./inventoryitemgroups"
|
|||||||
export * from "./inventoryitems"
|
export * from "./inventoryitems"
|
||||||
export * from "./letterheads"
|
export * from "./letterheads"
|
||||||
export * from "./movements"
|
export * from "./movements"
|
||||||
|
export * from "./m2m_api_keys"
|
||||||
export * from "./notifications_event_types"
|
export * from "./notifications_event_types"
|
||||||
export * from "./notifications_items"
|
export * from "./notifications_items"
|
||||||
export * from "./notifications_preferences"
|
export * from "./notifications_preferences"
|
||||||
@@ -72,4 +73,4 @@ export * from "./staff_time_events"
|
|||||||
export * from "./serialtypes"
|
export * from "./serialtypes"
|
||||||
export * from "./serialexecutions"
|
export * from "./serialexecutions"
|
||||||
export * from "./public_links"
|
export * from "./public_links"
|
||||||
export * from "./wikipages"
|
export * from "./wikipages"
|
||||||
|
|||||||
48
backend/db/schema/m2m_api_keys.ts
Normal file
48
backend/db/schema/m2m_api_keys.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
bigint,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
boolean,
|
||||||
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
|
||||||
|
export const m2mApiKeys = pgTable("m2m_api_keys", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
|
||||||
|
tenantId: bigint("tenant_id", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||||
|
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }),
|
||||||
|
|
||||||
|
createdBy: uuid("created_by").references(() => authUsers.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
onUpdate: "cascade",
|
||||||
|
}),
|
||||||
|
|
||||||
|
name: text("name").notNull(),
|
||||||
|
keyPrefix: text("key_prefix").notNull(),
|
||||||
|
keyHash: text("key_hash").notNull().unique(),
|
||||||
|
|
||||||
|
active: boolean("active").notNull().default(true),
|
||||||
|
|
||||||
|
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type M2mApiKey = typeof m2mApiKeys.$inferSelect
|
||||||
|
export type NewM2mApiKey = typeof m2mApiKeys.$inferInsert
|
||||||
@@ -42,6 +42,7 @@ import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email";
|
|||||||
import deviceRoutes from "./routes/internal/devices";
|
import deviceRoutes from "./routes/internal/devices";
|
||||||
import tenantRoutesInternal from "./routes/internal/tenant";
|
import tenantRoutesInternal from "./routes/internal/tenant";
|
||||||
import staffTimeRoutesInternal from "./routes/internal/time";
|
import staffTimeRoutesInternal from "./routes/internal/time";
|
||||||
|
import authM2mInternalRoutes from "./routes/internal/auth.m2m";
|
||||||
|
|
||||||
//Devices
|
//Devices
|
||||||
import devicesRFIDRoutes from "./routes/devices/rfid";
|
import devicesRFIDRoutes from "./routes/devices/rfid";
|
||||||
@@ -107,6 +108,7 @@ async function main() {
|
|||||||
|
|
||||||
await app.register(async (m2mApp) => {
|
await app.register(async (m2mApp) => {
|
||||||
await m2mApp.register(authM2m)
|
await m2mApp.register(authM2m)
|
||||||
|
await m2mApp.register(authM2mInternalRoutes)
|
||||||
await m2mApp.register(helpdeskInboundEmailRoutes)
|
await m2mApp.register(helpdeskInboundEmailRoutes)
|
||||||
await m2mApp.register(deviceRoutes)
|
await m2mApp.register(deviceRoutes)
|
||||||
await m2mApp.register(tenantRoutesInternal)
|
await m2mApp.register(tenantRoutesInternal)
|
||||||
@@ -167,4 +169,4 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import fp from "fastify-plugin";
|
import fp from "fastify-plugin";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { authUsers, m2mApiKeys } from "../../db/schema";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fastify Plugin für Machine-to-Machine Authentifizierung.
|
* Fastify Plugin für Machine-to-Machine Authentifizierung.
|
||||||
@@ -12,26 +15,99 @@ import { secrets } from "../utils/secrets";
|
|||||||
* server.register(m2mAuthPlugin, { allowedPrefix: '/internal' })
|
* server.register(m2mAuthPlugin, { allowedPrefix: '/internal' })
|
||||||
*/
|
*/
|
||||||
export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string } = {}) => {
|
export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string } = {}) => {
|
||||||
//const allowedPrefix = opts.allowedPrefix || "/internal";
|
const hashApiKey = (apiKey: string) =>
|
||||||
|
createHash("sha256").update(apiKey, "utf8").digest("hex")
|
||||||
|
|
||||||
server.addHook("preHandler", async (req, reply) => {
|
server.addHook("preHandler", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
// Nur prüfen, wenn Route unterhalb des Prefix liegt
|
const apiKeyHeader = req.headers["x-api-key"];
|
||||||
//if (!req.url.startsWith(allowedPrefix)) return;
|
const apiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader;
|
||||||
|
|
||||||
const apiKey = req.headers["x-api-key"];
|
if (!apiKey) {
|
||||||
|
|
||||||
if (!apiKey || apiKey !== secrets.M2M_API_KEY) {
|
|
||||||
server.log.warn(`[M2M Auth] Ungültiger oder fehlender API-Key bei ${req.url}`);
|
server.log.warn(`[M2M Auth] Ungültiger oder fehlender API-Key bei ${req.url}`);
|
||||||
return reply.status(401).send({ error: "Unauthorized" });
|
return reply.status(401).send({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zusatzinformationen im Request (z. B. interne Kennung)
|
const keyHash = hashApiKey(apiKey);
|
||||||
|
|
||||||
|
const keyRows = await server.db
|
||||||
|
.select({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
tenantId: m2mApiKeys.tenantId,
|
||||||
|
userId: m2mApiKeys.userId,
|
||||||
|
active: m2mApiKeys.active,
|
||||||
|
expiresAt: m2mApiKeys.expiresAt,
|
||||||
|
name: m2mApiKeys.name,
|
||||||
|
userEmail: authUsers.email,
|
||||||
|
})
|
||||||
|
.from(m2mApiKeys)
|
||||||
|
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
|
||||||
|
.where(and(
|
||||||
|
eq(m2mApiKeys.keyHash, keyHash),
|
||||||
|
eq(m2mApiKeys.active, true)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
let key = keyRows[0]
|
||||||
|
if (!key) {
|
||||||
|
const fallbackValid = apiKey === secrets.M2M_API_KEY
|
||||||
|
if (!fallbackValid) {
|
||||||
|
server.log.warn(`[M2M Auth] Ungültiger API-Key bei ${req.url}`)
|
||||||
|
return reply.status(401).send({ error: "Unauthorized" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility mode for one global key.
|
||||||
|
// The caller must provide user/tenant identifiers in headers.
|
||||||
|
const tenantIdHeader = req.headers["x-tenant-id"]
|
||||||
|
const userIdHeader = req.headers["x-user-id"]
|
||||||
|
const tenantId = Number(Array.isArray(tenantIdHeader) ? tenantIdHeader[0] : tenantIdHeader)
|
||||||
|
const userId = Array.isArray(userIdHeader) ? userIdHeader[0] : userIdHeader
|
||||||
|
|
||||||
|
if (!tenantId || !userId) {
|
||||||
|
return reply.status(401).send({ error: "Missing x-tenant-id or x-user-id for legacy M2M key" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await server.db
|
||||||
|
.select({ email: authUsers.email })
|
||||||
|
.from(authUsers)
|
||||||
|
.where(eq(authUsers.id, userId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!users[0]) {
|
||||||
|
return reply.status(401).send({ error: "Unknown user for legacy M2M key" })
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
user_id: userId,
|
||||||
|
email: users[0].email,
|
||||||
|
tenant_id: tenantId
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (key.expiresAt && new Date(key.expiresAt).getTime() < Date.now()) {
|
||||||
|
return reply.status(401).send({ error: "Expired API key" })
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
user_id: key.userId,
|
||||||
|
email: key.userEmail,
|
||||||
|
tenant_id: key.tenantId
|
||||||
|
}
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.update(m2mApiKeys)
|
||||||
|
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(m2mApiKeys.id, key.id))
|
||||||
|
}
|
||||||
|
|
||||||
(req as any).m2m = {
|
(req as any).m2m = {
|
||||||
verified: true,
|
verified: true,
|
||||||
type: "internal",
|
type: "internal",
|
||||||
key: apiKey,
|
key: apiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
req.role = "m2m"
|
||||||
|
req.permissions = []
|
||||||
|
req.hasPermission = () => false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
server.log.error("[M2M Auth] Fehler beim Prüfen des API-Keys:", err);
|
server.log.error("[M2M Auth] Fehler beim Prüfen des API-Keys:", err);
|
||||||
|
|||||||
@@ -1,31 +1,25 @@
|
|||||||
import fp from "fastify-plugin"
|
// src/plugins/db.ts
|
||||||
import {drizzle, NodePgDatabase} from "drizzle-orm/node-postgres"
|
import fp from "fastify-plugin";
|
||||||
import * as schema from "../../db/schema"
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import {secrets} from "../utils/secrets";
|
import * as schema from "../../db/schema";
|
||||||
import { Pool } from "pg"
|
import { db, pool } from "../../db"; // <--- Importiert jetzt die globale Instanz
|
||||||
|
|
||||||
export default fp(async (server, opts) => {
|
export default fp(async (server, opts) => {
|
||||||
|
|
||||||
const pool = new Pool({
|
// Wir nutzen die db, die wir in src/db/index.ts erstellt haben
|
||||||
connectionString: secrets.DATABASE_URL,
|
server.decorate("db", db);
|
||||||
max: 10, // je nach Last
|
|
||||||
})
|
|
||||||
|
|
||||||
const db = drizzle(pool , {schema})
|
// Graceful Shutdown: Wenn Fastify ausgeht, schließen wir den Pool
|
||||||
|
|
||||||
// Dekorieren -> überall server.db
|
|
||||||
server.decorate("db", db)
|
|
||||||
|
|
||||||
// Graceful Shutdown
|
|
||||||
server.addHook("onClose", async () => {
|
server.addHook("onClose", async () => {
|
||||||
await pool.end()
|
console.log("[DB] Closing connection pool...");
|
||||||
})
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
console.log("Drizzle database connected")
|
console.log("[Fastify] Database attached from shared instance");
|
||||||
})
|
});
|
||||||
|
|
||||||
declare module "fastify" {
|
declare module "fastify" {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
db:NodePgDatabase<typeof schema>
|
db: NodePgDatabase<typeof schema>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
63
backend/src/routes/internal/auth.m2m.ts
Normal file
63
backend/src/routes/internal/auth.m2m.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import jwt from "jsonwebtoken"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { authTenantUsers } from "../../../db/schema"
|
||||||
|
import { secrets } from "../../utils/secrets"
|
||||||
|
|
||||||
|
export default async function authM2mInternalRoutes(server: FastifyInstance) {
|
||||||
|
server.post("/auth/m2m/token", {
|
||||||
|
schema: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
summary: "Exchange M2M API key for a short-lived JWT",
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
expires_in_seconds: { type: "number" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.user_id || !req.user?.tenant_id || !req.user?.email) {
|
||||||
|
return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authTenantUsers)
|
||||||
|
.where(and(
|
||||||
|
eq(authTenantUsers.user_id, req.user.user_id),
|
||||||
|
eq(authTenantUsers.tenant_id, Number(req.user.tenant_id))
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!membership[0]) {
|
||||||
|
return reply.code(403).send({ error: "User is not assigned to tenant" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedTtl = Number((req.body as any)?.expires_in_seconds ?? 900)
|
||||||
|
const ttlSeconds = Math.min(3600, Math.max(60, requestedTtl))
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
user_id: req.user.user_id,
|
||||||
|
email: req.user.email,
|
||||||
|
tenant_id: req.user.tenant_id,
|
||||||
|
},
|
||||||
|
secrets.JWT_SECRET!,
|
||||||
|
{ expiresIn: ttlSeconds }
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
token_type: "Bearer",
|
||||||
|
access_token: token,
|
||||||
|
expires_in_seconds: ttlSeconds,
|
||||||
|
user_id: req.user.user_id,
|
||||||
|
tenant_id: req.user.tenant_id
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("POST /internal/auth/m2m/token ERROR:", err)
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
|
|
||||||
import { resourceConfig } from "../../utils/resource.config";
|
import { resourceConfig } from "../../utils/resource.config";
|
||||||
import { useNextNumberRangeNumber } from "../../utils/functions";
|
import { useNextNumberRangeNumber } from "../../utils/functions";
|
||||||
|
import { insertHistoryItem } from "../../utils/history";
|
||||||
|
import { diffObjects } from "../../utils/diff";
|
||||||
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
|
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@@ -31,6 +33,42 @@ function buildSearchCondition(columns: any[], search: string) {
|
|||||||
return or(...conditions)
|
return or(...conditions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDiffValue(value: any): string {
|
||||||
|
if (value === null || value === undefined) return "-"
|
||||||
|
if (typeof value === "boolean") return value ? "Ja" : "Nein"
|
||||||
|
if (typeof value === "object") {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch {
|
||||||
|
return "[Objekt]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TECHNICAL_HISTORY_KEYS = new Set([
|
||||||
|
"id",
|
||||||
|
"tenant",
|
||||||
|
"tenant_id",
|
||||||
|
"createdAt",
|
||||||
|
"created_at",
|
||||||
|
"createdBy",
|
||||||
|
"created_by",
|
||||||
|
"updatedAt",
|
||||||
|
"updated_at",
|
||||||
|
"updatedBy",
|
||||||
|
"updated_by",
|
||||||
|
"archived",
|
||||||
|
])
|
||||||
|
|
||||||
|
function getUserVisibleChanges(oldRecord: Record<string, any>, updated: Record<string, any>) {
|
||||||
|
return diffObjects(oldRecord, updated).filter((c) => !TECHNICAL_HISTORY_KEYS.has(c.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldUpdateHistoryText(resource: string, label: string, oldValue: any, newValue: any) {
|
||||||
|
return `${resource}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"`
|
||||||
|
}
|
||||||
|
|
||||||
export default async function resourceRoutes(server: FastifyInstance) {
|
export default async function resourceRoutes(server: FastifyInstance) {
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@@ -349,6 +387,23 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
await recalculateServicePricesForTenant(server, req.user.tenant_id, req.user?.user_id || null);
|
await recalculateServicePricesForTenant(server, req.user.tenant_id, req.user?.user_id || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
try {
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
tenant_id: req.user.tenant_id,
|
||||||
|
created_by: req.user?.user_id || null,
|
||||||
|
entity: resource,
|
||||||
|
entityId: created.id,
|
||||||
|
action: "created",
|
||||||
|
oldVal: null,
|
||||||
|
newVal: created,
|
||||||
|
text: `Neuer Eintrag in ${resource} erstellt`,
|
||||||
|
})
|
||||||
|
} catch (historyError) {
|
||||||
|
server.log.warn({ err: historyError, resource }, "Failed to write create history entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -369,6 +424,12 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
const table = resourceConfig[resource].table
|
const table = resourceConfig[resource].table
|
||||||
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
|
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
|
||||||
|
|
||||||
|
const [oldRecord] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(table)
|
||||||
|
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
let data = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
|
let data = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
delete data.updatedBy; delete data.updatedAt;
|
delete data.updatedBy; delete data.updatedAt;
|
||||||
@@ -385,6 +446,39 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
await recalculateServicePricesForTenant(server, tenantId, userId);
|
await recalculateServicePricesForTenant(server, tenantId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
try {
|
||||||
|
const changes = oldRecord ? getUserVisibleChanges(oldRecord, updated) : []
|
||||||
|
if (!changes.length) {
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
created_by: userId,
|
||||||
|
entity: resource,
|
||||||
|
entityId: updated.id,
|
||||||
|
action: "updated",
|
||||||
|
oldVal: oldRecord || null,
|
||||||
|
newVal: updated,
|
||||||
|
text: `Eintrag in ${resource} geändert`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
for (const change of changes) {
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
created_by: userId,
|
||||||
|
entity: resource,
|
||||||
|
entityId: updated.id,
|
||||||
|
action: "updated",
|
||||||
|
oldVal: change.oldValue,
|
||||||
|
newVal: change.newValue,
|
||||||
|
text: buildFieldUpdateHistoryText(resource, change.label, change.oldValue, change.newValue),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (historyError) {
|
||||||
|
server.log.warn({ err: historyError, resource, id }, "Failed to write update history entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return updated
|
return updated
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import jwt from "jsonwebtoken"
|
import jwt from "jsonwebtoken"
|
||||||
import { secrets } from "../utils/secrets"
|
import { secrets } from "../utils/secrets"
|
||||||
|
import { createHash, randomBytes } from "node:crypto"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
authTenantUsers,
|
authTenantUsers,
|
||||||
authUsers,
|
authUsers,
|
||||||
authProfiles,
|
authProfiles,
|
||||||
tenants
|
tenants,
|
||||||
|
m2mApiKeys
|
||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
|
|
||||||
import {and, eq, inArray} from "drizzle-orm"
|
import {and, desc, eq, inArray} from "drizzle-orm"
|
||||||
|
|
||||||
|
|
||||||
export default async function tenantRoutes(server: FastifyInstance) {
|
export default async function tenantRoutes(server: FastifyInstance) {
|
||||||
|
const generateApiKey = () => {
|
||||||
|
const raw = randomBytes(32).toString("base64url")
|
||||||
|
return `fedeo_m2m_${raw}`
|
||||||
|
}
|
||||||
|
const hashApiKey = (apiKey: string) =>
|
||||||
|
createHash("sha256").update(apiKey, "utf8").digest("hex")
|
||||||
|
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@@ -73,7 +81,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
maxAge: 60 * 60 * 3,
|
maxAge: 60 * 60 * 6,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { token }
|
return { token }
|
||||||
@@ -241,4 +249,172 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// M2M API KEYS
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
server.get("/tenant/api-keys", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user?.tenant_id
|
||||||
|
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const keys = await server.db
|
||||||
|
.select({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
name: m2mApiKeys.name,
|
||||||
|
tenant_id: m2mApiKeys.tenantId,
|
||||||
|
user_id: m2mApiKeys.userId,
|
||||||
|
active: m2mApiKeys.active,
|
||||||
|
key_prefix: m2mApiKeys.keyPrefix,
|
||||||
|
created_at: m2mApiKeys.createdAt,
|
||||||
|
updated_at: m2mApiKeys.updatedAt,
|
||||||
|
expires_at: m2mApiKeys.expiresAt,
|
||||||
|
last_used_at: m2mApiKeys.lastUsedAt,
|
||||||
|
})
|
||||||
|
.from(m2mApiKeys)
|
||||||
|
.where(eq(m2mApiKeys.tenantId, tenantId))
|
||||||
|
.orderBy(desc(m2mApiKeys.createdAt))
|
||||||
|
|
||||||
|
return keys
|
||||||
|
} catch (err) {
|
||||||
|
console.error("/tenant/api-keys GET ERROR:", err)
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/tenant/api-keys", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user?.tenant_id
|
||||||
|
const creatorUserId = req.user?.user_id
|
||||||
|
if (!tenantId || !creatorUserId) {
|
||||||
|
return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, user_id, expires_at } = req.body as {
|
||||||
|
name: string
|
||||||
|
user_id: string
|
||||||
|
expires_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || !user_id) {
|
||||||
|
return reply.code(400).send({ error: "name and user_id are required" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMembership = await server.db
|
||||||
|
.select()
|
||||||
|
.from(authTenantUsers)
|
||||||
|
.where(and(
|
||||||
|
eq(authTenantUsers.tenant_id, tenantId),
|
||||||
|
eq(authTenantUsers.user_id, user_id)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!userMembership[0]) {
|
||||||
|
return reply.code(400).send({ error: "user_id is not assigned to this tenant" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainApiKey = generateApiKey()
|
||||||
|
const keyPrefix = plainApiKey.slice(0, 16)
|
||||||
|
const keyHash = hashApiKey(plainApiKey)
|
||||||
|
|
||||||
|
const inserted = await server.db
|
||||||
|
.insert(m2mApiKeys)
|
||||||
|
.values({
|
||||||
|
tenantId,
|
||||||
|
userId: user_id,
|
||||||
|
createdBy: creatorUserId,
|
||||||
|
name,
|
||||||
|
keyPrefix,
|
||||||
|
keyHash,
|
||||||
|
expiresAt: expires_at ? new Date(expires_at) : null,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
name: m2mApiKeys.name,
|
||||||
|
tenant_id: m2mApiKeys.tenantId,
|
||||||
|
user_id: m2mApiKeys.userId,
|
||||||
|
key_prefix: m2mApiKeys.keyPrefix,
|
||||||
|
created_at: m2mApiKeys.createdAt,
|
||||||
|
expires_at: m2mApiKeys.expiresAt,
|
||||||
|
active: m2mApiKeys.active,
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.code(201).send({
|
||||||
|
...inserted[0],
|
||||||
|
api_key: plainApiKey, // only returned once
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error("/tenant/api-keys POST ERROR:", err)
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.patch("/tenant/api-keys/:id", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user?.tenant_id
|
||||||
|
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const { name, active, expires_at } = req.body as {
|
||||||
|
name?: string
|
||||||
|
active?: boolean
|
||||||
|
expires_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
if (name !== undefined) updateData.name = name
|
||||||
|
if (active !== undefined) updateData.active = active
|
||||||
|
if (expires_at !== undefined) updateData.expiresAt = expires_at ? new Date(expires_at) : null
|
||||||
|
|
||||||
|
const updated = await server.db
|
||||||
|
.update(m2mApiKeys)
|
||||||
|
.set(updateData)
|
||||||
|
.where(and(
|
||||||
|
eq(m2mApiKeys.id, id),
|
||||||
|
eq(m2mApiKeys.tenantId, tenantId)
|
||||||
|
))
|
||||||
|
.returning({
|
||||||
|
id: m2mApiKeys.id,
|
||||||
|
name: m2mApiKeys.name,
|
||||||
|
tenant_id: m2mApiKeys.tenantId,
|
||||||
|
user_id: m2mApiKeys.userId,
|
||||||
|
active: m2mApiKeys.active,
|
||||||
|
key_prefix: m2mApiKeys.keyPrefix,
|
||||||
|
updated_at: m2mApiKeys.updatedAt,
|
||||||
|
expires_at: m2mApiKeys.expiresAt,
|
||||||
|
last_used_at: m2mApiKeys.lastUsedAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!updated[0]) {
|
||||||
|
return reply.code(404).send({ error: "API key not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated[0]
|
||||||
|
} catch (err) {
|
||||||
|
console.error("/tenant/api-keys PATCH ERROR:", err)
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.delete("/tenant/api-keys/:id", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user?.tenant_id
|
||||||
|
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
await server.db
|
||||||
|
.delete(m2mApiKeys)
|
||||||
|
.where(and(
|
||||||
|
eq(m2mApiKeys.id, id),
|
||||||
|
eq(m2mApiKeys.tenantId, tenantId)
|
||||||
|
))
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
console.error("/tenant/api-keys DELETE ERROR:", err)
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import {diffTranslations} from "./diffTranslations";
|
import {diffTranslations, getDiffLabel} from "./diffTranslations";
|
||||||
|
|
||||||
export type DiffChange = {
|
export type DiffChange = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -43,8 +43,6 @@ export function diffObjects(
|
|||||||
const oldVal = obj1?.[key];
|
const oldVal = obj1?.[key];
|
||||||
const newVal = obj2?.[key];
|
const newVal = obj2?.[key];
|
||||||
|
|
||||||
console.log(oldVal, key, newVal);
|
|
||||||
|
|
||||||
// Wenn beides null/undefined → ignorieren
|
// Wenn beides null/undefined → ignorieren
|
||||||
if (
|
if (
|
||||||
(oldVal === null || oldVal === undefined || oldVal === "" || JSON.stringify(oldVal) === "[]") &&
|
(oldVal === null || oldVal === undefined || oldVal === "" || JSON.stringify(oldVal) === "[]") &&
|
||||||
@@ -72,12 +70,11 @@ export function diffObjects(
|
|||||||
if (type === "unchanged") continue;
|
if (type === "unchanged") continue;
|
||||||
|
|
||||||
const translation = diffTranslations[key];
|
const translation = diffTranslations[key];
|
||||||
let label = key;
|
let label = getDiffLabel(key);
|
||||||
let resolvedOld = oldVal;
|
let resolvedOld = oldVal;
|
||||||
let resolvedNew = newVal;
|
let resolvedNew = newVal;
|
||||||
|
|
||||||
if (translation) {
|
if (translation) {
|
||||||
label = translation.label;
|
|
||||||
if (translation.resolve) {
|
if (translation.resolve) {
|
||||||
const { oldVal: resOld, newVal: resNew } = translation.resolve(
|
const { oldVal: resOld, newVal: resNew } = translation.resolve(
|
||||||
oldVal,
|
oldVal,
|
||||||
@@ -100,4 +97,4 @@ export function diffObjects(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return diffs;
|
return diffs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,149 @@ type ValueResolver = (
|
|||||||
ctx?: Record<string, any>
|
ctx?: Record<string, any>
|
||||||
) => { oldVal: any; newVal: any };
|
) => { oldVal: any; newVal: any };
|
||||||
|
|
||||||
|
const TOKEN_TRANSLATIONS: Record<string, string> = {
|
||||||
|
account: "Konto",
|
||||||
|
active: "Aktiv",
|
||||||
|
address: "Adresse",
|
||||||
|
amount: "Betrag",
|
||||||
|
archived: "Archiviert",
|
||||||
|
article: "Artikel",
|
||||||
|
bank: "Bank",
|
||||||
|
barcode: "Barcode",
|
||||||
|
birthday: "Geburtstag",
|
||||||
|
category: "Kategorie",
|
||||||
|
city: "Ort",
|
||||||
|
color: "Farbe",
|
||||||
|
comment: "Kommentar",
|
||||||
|
company: "Firma",
|
||||||
|
contact: "Kontakt",
|
||||||
|
contract: "Vertrag",
|
||||||
|
cost: "Kosten",
|
||||||
|
country: "Land",
|
||||||
|
created: "Erstellt",
|
||||||
|
customer: "Kunde",
|
||||||
|
date: "Datum",
|
||||||
|
default: "Standard",
|
||||||
|
deleted: "Gelöscht",
|
||||||
|
delivery: "Lieferung",
|
||||||
|
description: "Beschreibung",
|
||||||
|
document: "Dokument",
|
||||||
|
driver: "Fahrer",
|
||||||
|
due: "Fällig",
|
||||||
|
duration: "Dauer",
|
||||||
|
email: "E-Mail",
|
||||||
|
employee: "Mitarbeiter",
|
||||||
|
enabled: "Aktiviert",
|
||||||
|
end: "Ende",
|
||||||
|
event: "Ereignis",
|
||||||
|
file: "Datei",
|
||||||
|
first: "Vorname",
|
||||||
|
fixed: "Festgeschrieben",
|
||||||
|
group: "Gruppe",
|
||||||
|
hour: "Stunde",
|
||||||
|
iban: "IBAN",
|
||||||
|
id: "ID",
|
||||||
|
incoming: "Eingang",
|
||||||
|
invoice: "Rechnung",
|
||||||
|
item: "Eintrag",
|
||||||
|
language: "Sprache",
|
||||||
|
last: "Nachname",
|
||||||
|
license: "Kennzeichen",
|
||||||
|
link: "Link",
|
||||||
|
list: "Liste",
|
||||||
|
location: "Standort",
|
||||||
|
manufacturer: "Hersteller",
|
||||||
|
markup: "Verkaufsaufschlag",
|
||||||
|
message: "Nachricht",
|
||||||
|
mobile: "Mobil",
|
||||||
|
name: "Name",
|
||||||
|
note: "Notiz",
|
||||||
|
notes: "Notizen",
|
||||||
|
number: "Nummer",
|
||||||
|
order: "Bestellung",
|
||||||
|
own: "Eigen",
|
||||||
|
payment: "Zahlung",
|
||||||
|
phone: "Telefon",
|
||||||
|
plant: "Objekt",
|
||||||
|
postal: "Post",
|
||||||
|
price: "Preis",
|
||||||
|
percentage: "%",
|
||||||
|
product: "Produkt",
|
||||||
|
profile: "Profil",
|
||||||
|
project: "Projekt",
|
||||||
|
purchase: "Kauf",
|
||||||
|
quantity: "Menge",
|
||||||
|
rate: "Satz",
|
||||||
|
reference: "Referenz",
|
||||||
|
requisition: "Anfrage",
|
||||||
|
resource: "Ressource",
|
||||||
|
role: "Rolle",
|
||||||
|
serial: "Serien",
|
||||||
|
service: "Leistung",
|
||||||
|
selling: "Verkauf",
|
||||||
|
sellign: "Verkauf",
|
||||||
|
space: "Lagerplatz",
|
||||||
|
start: "Start",
|
||||||
|
statement: "Buchung",
|
||||||
|
status: "Status",
|
||||||
|
street: "Straße",
|
||||||
|
surcharge: "Aufschlag",
|
||||||
|
tax: "Steuer",
|
||||||
|
tel: "Telefon",
|
||||||
|
tenant: "Mandant",
|
||||||
|
time: "Zeit",
|
||||||
|
title: "Titel",
|
||||||
|
total: "Gesamt",
|
||||||
|
type: "Typ",
|
||||||
|
unit: "Einheit",
|
||||||
|
updated: "Aktualisiert",
|
||||||
|
user: "Benutzer",
|
||||||
|
ustid: "USt-ID",
|
||||||
|
value: "Wert",
|
||||||
|
vendor: "Lieferant",
|
||||||
|
vehicle: "Fahrzeug",
|
||||||
|
weekly: "Wöchentlich",
|
||||||
|
working: "Arbeits",
|
||||||
|
zip: "Postleitzahl",
|
||||||
|
composed: "Zusammensetzung",
|
||||||
|
material: "Material",
|
||||||
|
worker: "Arbeit",
|
||||||
|
};
|
||||||
|
|
||||||
|
function tokenizeKey(key: string): string[] {
|
||||||
|
return key
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||||
|
.replace(/[^a-zA-Z0-9]+/g, "_")
|
||||||
|
.split("_")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((p) => p.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(word: string) {
|
||||||
|
if (!word) return word;
|
||||||
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackLabelFromKey(key: string): string {
|
||||||
|
const parts = tokenizeKey(key);
|
||||||
|
if (!parts.length) return key;
|
||||||
|
|
||||||
|
if (parts.length > 1 && parts[parts.length - 1] === "id") {
|
||||||
|
const base = parts.slice(0, -1).map((p) => TOKEN_TRANSLATIONS[p] || capitalize(p)).join(" ");
|
||||||
|
return `${base} ID`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
.map((p) => TOKEN_TRANSLATIONS[p] || capitalize(p))
|
||||||
|
.join(" ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDiffLabel(key: string): string {
|
||||||
|
return diffTranslations[key]?.label || fallbackLabelFromKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
export const diffTranslations: Record<
|
export const diffTranslations: Record<
|
||||||
string,
|
string,
|
||||||
{ label: string; resolve?: ValueResolver }
|
{ label: string; resolve?: ValueResolver }
|
||||||
@@ -44,7 +187,7 @@ export const diffTranslations: Record<
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
resources: {
|
resources: {
|
||||||
label: "Resourcen",
|
label: "Ressourcen",
|
||||||
resolve: (o, n) => ({
|
resolve: (o, n) => ({
|
||||||
oldVal: Array.isArray(o) ? o.map((i: any) => i.title).join(", ") : "-",
|
oldVal: Array.isArray(o) ? o.map((i: any) => i.title).join(", ") : "-",
|
||||||
newVal: Array.isArray(n) ? n.map((i: any) => i.title).join(", ") : "-",
|
newVal: Array.isArray(n) ? n.map((i: any) => i.title).join(", ") : "-",
|
||||||
@@ -86,6 +229,11 @@ export const diffTranslations: Record<
|
|||||||
approved: { label: "Genehmigt" },
|
approved: { label: "Genehmigt" },
|
||||||
manufacturer: { label: "Hersteller" },
|
manufacturer: { label: "Hersteller" },
|
||||||
purchasePrice: { label: "Kaufpreis" },
|
purchasePrice: { label: "Kaufpreis" },
|
||||||
|
markupPercentage: { label: "Verkaufsaufschlag in %" },
|
||||||
|
markup_percentage: { label: "Verkaufsaufschlag in %" },
|
||||||
|
sellingPrice: { label: "Verkaufspreis" },
|
||||||
|
selling_price: { label: "Verkaufspreis" },
|
||||||
|
sellingPriceComposed: { label: "Verkaufspreis Zusammensetzung" },
|
||||||
purchaseDate: { label: "Kaufdatum" },
|
purchaseDate: { label: "Kaufdatum" },
|
||||||
serialNumber: { label: "Seriennummer" },
|
serialNumber: { label: "Seriennummer" },
|
||||||
usePlanning: { label: "In Plantafel verwenden" },
|
usePlanning: { label: "In Plantafel verwenden" },
|
||||||
@@ -108,6 +256,7 @@ export const diffTranslations: Record<
|
|||||||
|
|
||||||
description: { label: "Beschreibung" },
|
description: { label: "Beschreibung" },
|
||||||
categorie: { label: "Kategorie" },
|
categorie: { label: "Kategorie" },
|
||||||
|
category: { label: "Kategorie" },
|
||||||
|
|
||||||
profile: {
|
profile: {
|
||||||
label: "Mitarbeiter",
|
label: "Mitarbeiter",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export async function insertHistoryItem(
|
|||||||
const textMap = {
|
const textMap = {
|
||||||
created: `Neuer Eintrag in ${params.entity} erstellt`,
|
created: `Neuer Eintrag in ${params.entity} erstellt`,
|
||||||
updated: `Eintrag in ${params.entity} geändert`,
|
updated: `Eintrag in ${params.entity} geändert`,
|
||||||
|
unchanged: `Eintrag in ${params.entity} unverändert`,
|
||||||
archived: `Eintrag in ${params.entity} archiviert`,
|
archived: `Eintrag in ${params.entity} archiviert`,
|
||||||
deleted: `Eintrag in ${params.entity} gelöscht`
|
deleted: `Eintrag in ${params.entity} gelöscht`
|
||||||
}
|
}
|
||||||
@@ -45,7 +46,9 @@ export async function insertHistoryItem(
|
|||||||
trackingtrips: "trackingtrip",
|
trackingtrips: "trackingtrip",
|
||||||
createddocuments: "createddocument",
|
createddocuments: "createddocument",
|
||||||
inventoryitemgroups: "inventoryitemgroup",
|
inventoryitemgroups: "inventoryitemgroup",
|
||||||
bankstatements: "bankstatement"
|
bankstatements: "bankstatement",
|
||||||
|
incominginvoices: "incomingInvoice",
|
||||||
|
files: "file",
|
||||||
}
|
}
|
||||||
|
|
||||||
const fkColumn = columnMap[params.entity]
|
const fkColumn = columnMap[params.entity]
|
||||||
@@ -54,14 +57,19 @@ export async function insertHistoryItem(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stringifyHistoryValue = (value: any) => {
|
||||||
|
if (value === undefined || value === null) return null
|
||||||
|
return typeof value === "string" ? value : JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
tenant: params.tenant_id,
|
tenant: params.tenant_id,
|
||||||
created_by: params.created_by,
|
createdBy: params.created_by,
|
||||||
text: params.text || textMap[params.action],
|
text: params.text || textMap[params.action],
|
||||||
action: params.action,
|
action: params.action,
|
||||||
[fkColumn]: params.entityId,
|
[fkColumn]: params.entityId,
|
||||||
oldVal: params.oldVal ? JSON.stringify(params.oldVal) : null,
|
oldVal: stringifyHistoryValue(params.oldVal),
|
||||||
newVal: params.newVal ? JSON.stringify(params.newVal) : null
|
newVal: stringifyHistoryValue(params.newVal)
|
||||||
}
|
}
|
||||||
|
|
||||||
await server.db.insert(historyitems).values(entry as any)
|
await server.db.insert(historyitems).values(entry as any)
|
||||||
|
|||||||
Reference in New Issue
Block a user