Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
2026-07-22 19:10:11 +02:00
29 changed files with 1462 additions and 190 deletions

View File

@@ -0,0 +1,93 @@
import { createHash, randomBytes } from "node:crypto"
import jwt from "jsonwebtoken"
import { FastifyInstance } from "fastify"
import { and, eq, gt, isNull } from "drizzle-orm"
import { authRefreshTokens, authUsers } from "../../db/schema"
import { secrets } from "./secrets"
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 6
const REFRESH_TOKEN_TTL_DAYS = 90
export function createAccessToken(user: { id: string; email: string }, tenantId: number | null) {
return jwt.sign(
{
user_id: user.id,
email: user.email,
tenant_id: tenantId,
},
secrets.JWT_SECRET!,
{ expiresIn: ACCESS_TOKEN_TTL_SECONDS }
)
}
export function hashRefreshToken(token: string) {
return createHash("sha256").update(token, "utf8").digest("hex")
}
export async function issueRefreshToken(
server: FastifyInstance,
userId: string,
tenantId: number | null
) {
const token = randomBytes(48).toString("base64url")
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000)
await server.db.insert(authRefreshTokens).values({
userId,
tenantId,
tokenHash: hashRefreshToken(token),
expiresAt,
})
return token
}
export async function rotateRefreshToken(
server: FastifyInstance,
token: string,
tenantOverride?: number | null,
expectedUserId?: string
) {
const tokenHash = hashRefreshToken(token)
const [session] = await server.db
.update(authRefreshTokens)
.set({ revokedAt: new Date() })
.where(and(
eq(authRefreshTokens.tokenHash, tokenHash),
isNull(authRefreshTokens.revokedAt),
gt(authRefreshTokens.expiresAt, new Date())
))
.returning({
id: authRefreshTokens.id,
userId: authRefreshTokens.userId,
tenantId: authRefreshTokens.tenantId,
})
if (!session) return null
if (expectedUserId && session.userId !== expectedUserId) return null
const [user] = await server.db
.select({ id: authUsers.id, email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, session.userId))
.limit(1)
if (!user) return null
const tenantId = tenantOverride === undefined ? session.tenantId : tenantOverride
const refreshToken = await issueRefreshToken(server, session.userId, tenantId)
const accessToken = createAccessToken(user, tenantId)
return { token: accessToken, refreshToken }
}
export async function revokeRefreshToken(server: FastifyInstance, token: string) {
await server.db
.update(authRefreshTokens)
.set({ revokedAt: new Date() })
.where(and(
eq(authRefreshTokens.tokenHash, hashRefreshToken(token)),
isNull(authRefreshTokens.revokedAt)
))
}

View File

@@ -0,0 +1,26 @@
import { GetObjectCommand } from "@aws-sdk/client-s3"
import { s3 } from "./s3"
import { secrets } from "./secrets"
export const streamToBuffer = async (stream: any): Promise<Buffer> => {
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return Buffer.concat(chunks)
}
export const loadFileBuffer = async (path: string): Promise<Buffer> => {
const response = await s3.send(new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: path,
}))
if (!response.Body) {
throw new Error(`S3-Datei '${path}' enthält keinen lesbaren Inhalt.`)
}
return streamToBuffer(response.Body)
}

View File

@@ -2,12 +2,11 @@ import dayjs from "dayjs";
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { FastifyInstance } from "fastify";
import { s3 } from "./s3";
import { secrets } from "./secrets";
import { storeExtractedTextForFile } from "./documentText";
import { loadFileBuffer } from "./fileBuffer";
import { secrets } from "./secrets";
// Drizzle schema
import { vendors, accounts, tenants } from "../../db/schema";
@@ -27,18 +26,6 @@ export const initOpenAi = async () => {
});
};
// ---------------------------------------------------------
// STREAM → BUFFER
// ---------------------------------------------------------
async function streamToBuffer(stream: any): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(Buffer.concat(chunks)));
});
}
// ---------------------------------------------------------
// GPT RESPONSE FORMAT (Zod Schema)
// ---------------------------------------------------------
@@ -93,7 +80,8 @@ const InstructionFormat = z.object({
export const getInvoiceDataFromGPT = async function (
server: FastifyInstance,
file: any,
tenantId: number
tenantId: number,
suppliedFileData?: Buffer,
) {
await initOpenAi();
@@ -109,13 +97,7 @@ export const getInvoiceDataFromGPT = async function (
let fileData: Buffer;
try {
const command = new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: file.path,
});
const response: any = await s3.send(command);
fileData = await streamToBuffer(response.Body);
fileData = suppliedFileData || await loadFileBuffer(file.path);
} catch (err) {
console.log(`❌ S3 Download failed for file ${file.id}`, err);
return null;