diff --git a/backend/db/migrations/0051_instance_scan_agents.sql b/backend/db/migrations/0051_instance_scan_agents.sql new file mode 100644 index 0000000..9e171b9 --- /dev/null +++ b/backend/db/migrations/0051_instance_scan_agents.sql @@ -0,0 +1,43 @@ +CREATE TABLE "instance_agents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "name" text NOT NULL, + "description" text, + "token_prefix" text NOT NULL, + "token_hash" text NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "capabilities" jsonb DEFAULT '{"scan":true,"print":false}'::jsonb NOT NULL, + "scanner_names" jsonb DEFAULT '[]'::jsonb NOT NULL, + "printer_names" jsonb DEFAULT '[]'::jsonb NOT NULL, + "last_seen_at" timestamp with time zone, + "last_debug_info" jsonb, + CONSTRAINT "instance_agents_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +CREATE TABLE "instance_agent_scan_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "tenant_id" bigint NOT NULL, + "agent_id" uuid NOT NULL, + "requested_by" uuid, + "status" text DEFAULT 'pending' NOT NULL, + "scanner_name" text, + "requested_filename" text, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "target" jsonb DEFAULT '{}'::jsonb NOT NULL, + "agent_message" text, + "attempts" integer DEFAULT 0 NOT NULL, + "claimed_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "file_id" uuid, + CONSTRAINT "instance_agent_scan_jobs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade, + CONSTRAINT "instance_agent_scan_jobs_agent_id_instance_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."instance_agents"("id") ON DELETE cascade ON UPDATE cascade, + CONSTRAINT "instance_agent_scan_jobs_requested_by_auth_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade, + CONSTRAINT "instance_agent_scan_jobs_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE cascade +); +--> statement-breakpoint +CREATE INDEX "instance_agent_scan_jobs_agent_status_idx" ON "instance_agent_scan_jobs" USING btree ("agent_id","status","created_at"); +--> statement-breakpoint +CREATE INDEX "instance_agent_scan_jobs_tenant_idx" ON "instance_agent_scan_jobs" USING btree ("tenant_id","created_at"); diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 2be79b0..8fabe61 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -48,6 +48,8 @@ export * from "./historyitems" export * from "./holidays" export * from "./hourrates" export * from "./incominginvoices" +export * from "./instance_agents" +export * from "./instance_agent_scan_jobs" export * from "./inventoryitemgroups" export * from "./inventoryitems" export * from "./letterheads" diff --git a/backend/db/schema/instance_agent_scan_jobs.ts b/backend/db/schema/instance_agent_scan_jobs.ts new file mode 100644 index 0000000..32f182a --- /dev/null +++ b/backend/db/schema/instance_agent_scan_jobs.ts @@ -0,0 +1,59 @@ +import { + pgTable, + uuid, + timestamp, + text, + bigint, + jsonb, + integer, +} from "drizzle-orm/pg-core" + +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" +import { files } from "./files" +import { instanceAgents } from "./instance_agents" + +export const instanceAgentScanJobs = pgTable("instance_agent_scan_jobs", { + 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" }), + + agentId: uuid("agent_id") + .notNull() + .references(() => instanceAgents.id, { onDelete: "cascade", onUpdate: "cascade" }), + + requestedBy: uuid("requested_by").references(() => authUsers.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + + status: text("status").notNull().default("pending"), + scannerName: text("scanner_name"), + requestedFilename: text("requested_filename"), + + settings: jsonb("settings").notNull().default({}), + target: jsonb("target").notNull().default({}), + agentMessage: text("agent_message"), + + attempts: integer("attempts").notNull().default(0), + claimedAt: timestamp("claimed_at", { withTimezone: true }), + finishedAt: timestamp("finished_at", { withTimezone: true }), + + fileId: uuid("file_id").references(() => files.id, { + onDelete: "set null", + onUpdate: "cascade", + }), +}) + +export type InstanceAgentScanJob = typeof instanceAgentScanJobs.$inferSelect +export type NewInstanceAgentScanJob = typeof instanceAgentScanJobs.$inferInsert diff --git a/backend/db/schema/instance_agents.ts b/backend/db/schema/instance_agents.ts new file mode 100644 index 0000000..adea099 --- /dev/null +++ b/backend/db/schema/instance_agents.ts @@ -0,0 +1,38 @@ +import { + pgTable, + uuid, + timestamp, + text, + boolean, + jsonb, +} from "drizzle-orm/pg-core" + +export const instanceAgents = pgTable("instance_agents", { + id: uuid("id").primaryKey().defaultRandom(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + name: text("name").notNull(), + description: text("description"), + + tokenPrefix: text("token_prefix").notNull(), + tokenHash: text("token_hash").notNull().unique(), + + active: boolean("active").notNull().default(true), + + capabilities: jsonb("capabilities").notNull().default({ scan: true, print: false }), + scannerNames: jsonb("scanner_names").notNull().default([]), + printerNames: jsonb("printer_names").notNull().default([]), + + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), + lastDebugInfo: jsonb("last_debug_info"), +}) + +export type InstanceAgent = typeof instanceAgents.$inferSelect +export type NewInstanceAgent = typeof instanceAgents.$inferInsert diff --git a/backend/src/index.ts b/backend/src/index.ts index aa90483..e6830b3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -33,6 +33,8 @@ import portalContractRoutes from "./routes/portal/contracts"; import mcpRoutes from "./routes/mcp"; import communicationRoutes from "./routes/communication"; import telephonyRoutes from "./routes/telephony"; +import instanceAgentRoutes from "./routes/instanceAgents"; +import instanceAgentGatewayRoutes from "./routes/instanceAgentGateway"; //Public Links import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated"; @@ -128,6 +130,10 @@ async function main() { await devicesApp.register(devicesManagementRoutes) },{prefix: "/devices"}) + await app.register(async (agentApp) => { + await agentApp.register(instanceAgentGatewayRoutes) + },{prefix: "/instance-agent"}) + await app.register(corsPlugin); //Geschützte Routes @@ -158,6 +164,7 @@ async function main() { await subApp.register(mcpRoutes); await subApp.register(communicationRoutes); await subApp.register(telephonyRoutes); + await subApp.register(instanceAgentRoutes); },{prefix: "/api"}) diff --git a/backend/src/routes/instanceAgentGateway.ts b/backend/src/routes/instanceAgentGateway.ts new file mode 100644 index 0000000..b707b2b --- /dev/null +++ b/backend/src/routes/instanceAgentGateway.ts @@ -0,0 +1,235 @@ +import { FastifyInstance, FastifyRequest } from "fastify" +import multipart from "@fastify/multipart" +import { createHash } from "node:crypto" +import { and, asc, eq, sql } from "drizzle-orm" +import { instanceAgentScanJobs, instanceAgents } from "../../db/schema" +import { saveFile } from "../utils/files" + +const hashToken = (token: string) => + createHash("sha256").update(token, "utf8").digest("hex") + +const readAgentToken = (req: FastifyRequest) => { + const headerToken = req.headers["x-agent-token"] + if (typeof headerToken === "string" && headerToken.length > 0) return headerToken + + const authHeader = req.headers.authorization + if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7) + + return null +} + +const pickFileTargets = (target: unknown) => { + if (!target || typeof target !== "object" || Array.isArray(target)) return {} + + const allowedFields = [ + "project", + "customer", + "contract", + "vendor", + "incominginvoice", + "plant", + "createddocument", + "vehicle", + "product", + "check", + "inventoryitem", + "space", + "documentbox", + "authProfile", + ] + + return Object.fromEntries( + Object.entries(target as Record) + .filter(([key, value]) => allowedFields.includes(key) && value !== undefined && value !== null) + ) +} + +export default async function instanceAgentGatewayRoutes(server: FastifyInstance) { + await server.register(multipart, { + limits: { fileSize: 100 * 1024 * 1024 }, + }) + + const authenticateAgent = async (req: FastifyRequest, reply: any) => { + const token = readAgentToken(req) + if (!token) { + reply.code(401).send({ error: "Agent token required" }) + return null + } + + const [agent] = await server.db + .select() + .from(instanceAgents) + .where(and( + eq(instanceAgents.tokenHash, hashToken(token)), + eq(instanceAgents.active, true) + )) + .limit(1) + + if (!agent) { + reply.code(401).send({ error: "Invalid agent token" }) + return null + } + + return agent + } + + server.post("/heartbeat", async (req, reply) => { + const agent = await authenticateAgent(req, reply) + if (!agent) return + + const body = (req.body || {}) as { + capabilities?: Record + scannerNames?: string[] + printerNames?: string[] + debugInfo?: Record + } + + await server.db + .update(instanceAgents) + .set({ + capabilities: body.capabilities || agent.capabilities, + scannerNames: body.scannerNames || agent.scannerNames, + printerNames: body.printerNames || agent.printerNames, + lastDebugInfo: body.debugInfo || null, + lastSeenAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(instanceAgents.id, agent.id)) + + const [pending] = await server.db + .select({ count: sql`count(*)::int` }) + .from(instanceAgentScanJobs) + .where(and( + eq(instanceAgentScanJobs.agentId, agent.id), + eq(instanceAgentScanJobs.status, "pending") + )) + + return { + status: "ok", + pendingScanJobs: pending?.count || 0, + } + }) + + server.get("/scan-jobs/next", async (req, reply) => { + const agent = await authenticateAgent(req, reply) + if (!agent) return + + const [pendingJob] = await server.db + .select() + .from(instanceAgentScanJobs) + .where(and( + eq(instanceAgentScanJobs.agentId, agent.id), + eq(instanceAgentScanJobs.status, "pending") + )) + .orderBy(asc(instanceAgentScanJobs.createdAt)) + .limit(1) + + if (!pendingJob) return { job: null } + + const [claimedJob] = await server.db + .update(instanceAgentScanJobs) + .set({ + status: "running", + claimedAt: new Date(), + updatedAt: new Date(), + attempts: pendingJob.attempts + 1, + }) + .where(and( + eq(instanceAgentScanJobs.id, pendingJob.id), + eq(instanceAgentScanJobs.status, "pending") + )) + .returning() + + return { job: claimedJob || null } + }) + + server.post<{ Params: { id: string } }>("/scan-jobs/:id/status", async (req, reply) => { + const agent = await authenticateAgent(req, reply) + if (!agent) return + + const body = (req.body || {}) as { status?: string; message?: string } + const allowedStatuses = ["running", "failed", "canceled"] + + if (!body.status || !allowedStatuses.includes(body.status)) { + return reply.code(400).send({ error: "Invalid status" }) + } + + const [job] = await server.db + .update(instanceAgentScanJobs) + .set({ + status: body.status, + agentMessage: body.message, + finishedAt: ["failed", "canceled"].includes(body.status) ? new Date() : undefined, + updatedAt: new Date(), + }) + .where(and( + eq(instanceAgentScanJobs.id, req.params.id), + eq(instanceAgentScanJobs.agentId, agent.id) + )) + .returning() + + if (!job) return reply.code(404).send({ error: "Scan job not found" }) + + return { job } + }) + + server.post<{ Params: { id: string } }>("/scan-jobs/:id/upload", async (req, reply) => { + const agent = await authenticateAgent(req, reply) + if (!agent) return + + const [job] = await server.db + .select() + .from(instanceAgentScanJobs) + .where(and( + eq(instanceAgentScanJobs.id, req.params.id), + eq(instanceAgentScanJobs.agentId, agent.id) + )) + .limit(1) + + if (!job) return reply.code(404).send({ error: "Scan job not found" }) + if (!["running", "pending"].includes(job.status)) { + return reply.code(409).send({ error: "Scan job is not uploadable" }) + } + + const data: any = await req.file() + if (!data?.file) return reply.code(400).send({ error: "No file uploaded" }) + + const fileBuffer = await data.toBuffer() + const filename = job.requestedFilename || data.filename || `${job.id}.pdf` + + const createdFile = await saveFile( + server, + job.tenantId, + null, + { + filename, + content: fileBuffer, + contentType: data.mimetype || "application/pdf", + }, + null, + null, + { + ...pickFileTargets(job.target), + createdBy: job.requestedBy, + } + ) + + if (!createdFile) return reply.code(500).send({ error: "Could not save scan file" }) + + const [updatedJob] = await server.db + .update(instanceAgentScanJobs) + .set({ + status: "completed", + fileId: createdFile.id, + finishedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(instanceAgentScanJobs.id, job.id)) + .returning() + + return { + job: updatedJob, + file: createdFile, + } + }) +} diff --git a/backend/src/routes/instanceAgents.ts b/backend/src/routes/instanceAgents.ts new file mode 100644 index 0000000..b516f8b --- /dev/null +++ b/backend/src/routes/instanceAgents.ts @@ -0,0 +1,193 @@ +import { FastifyInstance } from "fastify" +import { createHash, randomBytes } from "node:crypto" +import { and, desc, eq } from "drizzle-orm" +import { z } from "zod" +import { instanceAgentScanJobs, instanceAgents } from "../../db/schema" + +const createAgentSchema = z.object({ + name: z.string().min(1), + description: z.string().optional().nullable(), +}) + +const updateAgentSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().optional().nullable(), + active: z.boolean().optional(), +}) + +const createScanJobSchema = z.object({ + agentId: z.string().uuid(), + tenantId: z.number().int().positive().optional(), + scannerName: z.string().optional().nullable(), + requestedFilename: z.string().optional().nullable(), + settings: z.record(z.string(), z.any()).optional(), + target: z.record(z.string(), z.any()).optional(), +}) + +const hashToken = (token: string) => + createHash("sha256").update(token, "utf8").digest("hex") + +const createAgentToken = () => `fedeo_agent_${randomBytes(32).toString("hex")}` + +const requireAdmin = (req: any, reply: any) => { + if (!req.user?.is_admin) { + reply.code(403).send({ error: "Admin required" }) + return false + } + + return true +} + +export default async function instanceAgentRoutes(server: FastifyInstance) { + server.get("/instance-agents", async () => { + const rows = await server.db + .select({ + id: instanceAgents.id, + createdAt: instanceAgents.createdAt, + updatedAt: instanceAgents.updatedAt, + name: instanceAgents.name, + description: instanceAgents.description, + tokenPrefix: instanceAgents.tokenPrefix, + active: instanceAgents.active, + capabilities: instanceAgents.capabilities, + scannerNames: instanceAgents.scannerNames, + printerNames: instanceAgents.printerNames, + lastSeenAt: instanceAgents.lastSeenAt, + lastDebugInfo: instanceAgents.lastDebugInfo, + }) + .from(instanceAgents) + .orderBy(desc(instanceAgents.createdAt)) + + return { agents: rows } + }) + + server.post("/instance-agents", async (req, reply) => { + if (!requireAdmin(req, reply)) return + + const body = createAgentSchema.parse(req.body) + const token = createAgentToken() + + const [agent] = await server.db + .insert(instanceAgents) + .values({ + name: body.name, + description: body.description, + tokenPrefix: token.slice(0, 24), + tokenHash: hashToken(token), + }) + .returning({ + id: instanceAgents.id, + name: instanceAgents.name, + description: instanceAgents.description, + tokenPrefix: instanceAgents.tokenPrefix, + active: instanceAgents.active, + createdAt: instanceAgents.createdAt, + }) + + return { + agent, + token, + } + }) + + server.patch<{ Params: { id: string } }>("/instance-agents/:id", async (req, reply) => { + if (!requireAdmin(req, reply)) return + + const body = updateAgentSchema.parse(req.body) + const [agent] = await server.db + .update(instanceAgents) + .set({ + ...body, + updatedAt: new Date(), + }) + .where(eq(instanceAgents.id, req.params.id)) + .returning({ + id: instanceAgents.id, + name: instanceAgents.name, + description: instanceAgents.description, + active: instanceAgents.active, + updatedAt: instanceAgents.updatedAt, + }) + + if (!agent) return reply.code(404).send({ error: "Agent not found" }) + + return { agent } + }) + + server.post("/scan-jobs", async (req, reply) => { + const body = createScanJobSchema.parse(req.body) + const requestedTenantId = body.tenantId || req.user?.tenant_id + + if (!requestedTenantId) { + return reply.code(400).send({ error: "tenantId required" }) + } + + if (body.tenantId && body.tenantId !== req.user?.tenant_id && !req.user?.is_admin) { + return reply.code(403).send({ error: "Cannot create scan job for another tenant" }) + } + + const [agent] = await server.db + .select({ id: instanceAgents.id, active: instanceAgents.active }) + .from(instanceAgents) + .where(eq(instanceAgents.id, body.agentId)) + .limit(1) + + if (!agent || !agent.active) { + return reply.code(404).send({ error: "Active agent not found" }) + } + + const [job] = await server.db + .insert(instanceAgentScanJobs) + .values({ + tenantId: requestedTenantId, + agentId: body.agentId, + requestedBy: req.user?.user_id, + scannerName: body.scannerName, + requestedFilename: body.requestedFilename, + settings: body.settings || {}, + target: body.target || {}, + }) + .returning() + + return { job } + }) + + server.get("/scan-jobs", async (req) => { + const query = req.query as { tenantId?: string } + const tenantId = req.user?.is_admin && query.tenantId + ? Number(query.tenantId) + : req.user?.tenant_id + + const rows = tenantId + ? await server.db + .select() + .from(instanceAgentScanJobs) + .where(eq(instanceAgentScanJobs.tenantId, tenantId)) + .orderBy(desc(instanceAgentScanJobs.createdAt)) + : await server.db + .select() + .from(instanceAgentScanJobs) + .orderBy(desc(instanceAgentScanJobs.createdAt)) + + return { jobs: rows } + }) + + server.get<{ Params: { id: string } }>("/scan-jobs/:id", async (req, reply) => { + const conditions = [eq(instanceAgentScanJobs.id, req.params.id)] + + if (!req.user?.is_admin) { + if (!req.user?.tenant_id) return reply.code(400).send({ error: "tenant required" }) + conditions.push(eq(instanceAgentScanJobs.tenantId, req.user.tenant_id)) + } + + const [job] = await server.db + .select() + .from(instanceAgentScanJobs) + .where(and(...conditions)) + .limit(1) + + if (!job) return reply.code(404).send({ error: "Scan job not found" }) + + return { job } + }) +}