redone routes

This commit is contained in:
2025-12-07 22:06:37 +01:00
parent dc0b49355d
commit b90e056e7c
10 changed files with 895 additions and 1555 deletions

View File

@@ -17,7 +17,6 @@ import resourceRoutesSpecial from "./routes/resourcesSpecial";
import fastifyCookie from "@fastify/cookie";
import historyRoutes from "./routes/history";
import fileRoutes from "./routes/files";
import userRoutes from "./routes/auth/user"
import functionRoutes from "./routes/functions";
import bankingRoutes from "./routes/banking";
import exportRoutes from "./routes/exports"
@@ -30,8 +29,6 @@ import staffTimeRoutes from "./routes/staff/time";
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
//Resources
import productsAndServicesRoutes from "./routes/resources/productsServices";
import resourceRoutes from "./routes/resources/main";
//M2M
@@ -106,7 +103,6 @@ async function main() {
await subApp.register(resourceRoutesSpecial);
await subApp.register(historyRoutes);
await subApp.register(fileRoutes);
await subApp.register(userRoutes);
await subApp.register(functionRoutes);
await subApp.register(bankingRoutes);
await subApp.register(exportRoutes);
@@ -118,7 +114,6 @@ async function main() {
await subApp.register(staffTimeConnectRoutes);
await subApp.register(productsAndServicesRoutes);
await subApp.register(resourceRoutes);
},{prefix: "/api"})

114
src/resource.config.ts Normal file
View File

@@ -0,0 +1,114 @@
import {
contacts,
contracts, costcentres, createddocuments,
customers,
files, filetags, folders, hourrates, inventoryitemgroups,
inventoryitems, letterheads, ownaccounts,
plants, productcategories, products,
projects,
projecttypes, servicecategories, services, spaces, tasks, texttemplates, units, vehicles,
vendors
} from "../db/schema";
export const resourceConfig = {
projects: {
searchColumns: ["name"],
mtoLoad: ["customer","plant","contract","projecttype"],
mtmLoad: ["tasks", "files"],
table: projects
},
customers: {
searchColumns: ["name", "customerNumber", "firstname", "lastname", "notes"],
mtmLoad: ["contacts","projects"],
table: customers,
},
contacts: {
searchColumns: ["firstName", "lastName", "email", "phone", "notes"],
table: contacts,
mtoLoad: ["customer","vendor"]
},
contracts: {
table: contracts,
searchColumns: ["name", "notes", "contractNumber", "paymentType", "sepaRef", "bankingName"]
},
plants: {
table: plants,
mtoLoad: ["customer"],
mtmLoad: ["projects","tasks","files"],
},
projecttypes: {
table: projecttypes
},
vendors: {
table: vendors,
searchColumns: ["name","vendorNumber","notes","defaultPaymentType"],
},
files: {
table: files
},
folders: {
table: folders
},
filetags: {
table: filetags
},
inventoryitems: {
table: inventoryitems
},
inventoryitemgroups: {
table: inventoryitemgroups
},
products: {
table: products,
searchColumns: ["name","manufacturer","ean","barcode","description","manfacturer_number","article_number"],
},
productcategories: {
table: productcategories
},
services: {
table: services,
mtoLoad: ["unit"],
searchColumns: ["name","description"],
},
servicecategories: {
table: servicecategories
},
units: {
table: units,
},
vehicles: {
table: vehicles,
searchColumns: ["name","license_plate","vin","color"],
},
hourrates: {
table: hourrates,
searchColumns: ["name"],
},
spaces: {
table: spaces,
searchColumns: ["name","space_number","type","info_data"],
},
ownaccounts: {
table: ownaccounts,
searchColumns: ["name","description","number"],
},
costcentres: {
table: costcentres,
searchColumns: ["name","number","description"],
mtoLoad: ["vehicle","project","inventoryitem"]
},
tasks: {
table: tasks,
},
letterheads: {
table: letterheads,
},
createddocuments: {
table: createddocuments,
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead",]
},
texttemplates: {
table: texttemplates
}
}

View File

@@ -1,94 +1,117 @@
import { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import {
authTenantUsers,
authUsers,
tenants,
} from "../../db/schema";
export default async function adminRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// POST /admin/add-user-to-tenant
// -------------------------------------------------------------
server.post("/admin/add-user-to-tenant", async (req, reply) => {
const body = req.body as {
user_id: string;
tenant_id: string;
role?: string;
mode?: "single" | "multi";
};
try {
const body = req.body as {
user_id: string;
tenant_id: number;
role?: string;
mode?: "single" | "multi";
};
if (!body.user_id || !body.tenant_id) {
return reply.code(400).send({ error: "user_id and tenant_id required" });
if (!body.user_id || !body.tenant_id) {
return reply.code(400).send({
error: "user_id and tenant_id required"
});
}
const mode = body.mode ?? "multi";
// ----------------------------
// SINGLE MODE → alte Verknüpfungen löschen
// ----------------------------
if (mode === "single") {
await server.db
.delete(authTenantUsers)
.where(eq(authTenantUsers.user_id, body.user_id));
}
// ----------------------------
// Neue Verknüpfung hinzufügen
// ----------------------------
await server.db
.insert(authTenantUsers)
// @ts-ignore
.values({
user_id: body.user_id,
tenantId: body.tenant_id,
role: body.role ?? "member",
});
return { success: true, mode };
} catch (err) {
console.error("ERROR /admin/add-user-to-tenant:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
// Default: "multi"
const mode = body.mode ?? "multi";
if (mode === "single") {
// Erst alle alten Verknüpfungen löschen
await server.supabase
.from("auth_tenant_users")
.delete()
.eq("user_id", body.user_id);
}
const { error } = await server.supabase
.from("auth_tenant_users")
.insert({
tenant_id: body.tenant_id,
user_id: body.user_id,
role: body.role ?? "member",
});
if (error) {
return reply.code(400).send({ error: error.message });
}
// Neuen Eintrag setzen
return { success: true, mode };
});
/**
* Alle Tenants eines Users abfragen
*/
// -------------------------------------------------------------
// GET /admin/user-tenants/:user_id
// -------------------------------------------------------------
server.get("/admin/user-tenants/:user_id", async (req, reply) => {
const { user_id } = req.params as { user_id: string };
try {
const { user_id } = req.params as { user_id: string };
if (!user_id) {
return reply.code(400).send({ error: "user_id required" });
if (!user_id) {
return reply.code(400).send({ error: "user_id required" });
}
// ----------------------------
// 1) User existiert?
// ----------------------------
const [user] = await server.db
.select()
.from(authUsers)
.where(eq(authUsers.id, user_id))
.limit(1);
if (!user) {
return reply.code(400).send({ error: "faulty user_id presented" });
}
// ----------------------------
// 2) Tenants Join über auth_tenant_users
// ----------------------------
const tenantRecords = await server.db
.select({
id: tenants.id,
name: tenants.name,
short: tenants.short,
locked: tenants.locked,
numberRanges: tenants.numberRanges,
extraModules: tenants.extraModules,
})
.from(authTenantUsers)
.innerJoin(
tenants,
eq(authTenantUsers.tenant_id, tenants.id)
)
.where(eq(authTenantUsers.user_id, user_id));
return {
user_id,
tenants: tenantRecords,
};
} catch (err) {
console.error("ERROR /admin/user-tenants:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
const {data:user, error: userError} = await server.supabase.from("auth_users").select("*,tenants(*)").eq("id", user_id).single();
console.log(userError)
console.log(user)
if(!user) {
return reply.code(400).send({ error: "faulty user_id presented" });
} else {
return { user_id, tenants: user.tenants };
}
});
/**
* Alle User eines Tenants abfragen
* TODO: Aktuell nur Multi Tenant
*/
/*server.get("/admin/tenant-users/:tenant_id", async (req, reply) => {
const { tenant_id } = req.params as { tenant_id: string };
if (!tenant_id) {
return reply.code(400).send({ error: "tenant_id required" });
}
const { data, error } = await server.supabase
.from("auth_tenant_users")
.select(`
user_id,
role,
users ( id, email, created_at )
`)
.eq("tenant_id", tenant_id);
if (error) {
return reply.code(400).send({ error: error.message });
}
return { tenant_id, users: data };
});*/
}
}

View File

@@ -1,12 +1,15 @@
import { FastifyInstance } from "fastify";
import bcrypt from "bcrypt";
import { FastifyInstance } from "fastify"
import bcrypt from "bcrypt"
import { eq } from "drizzle-orm"
import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren!
export default async function authRoutesAuthenticated(server: FastifyInstance) {
server.post("/auth/password/change", {
schema: {
tags: ["Auth"],
summary: "Reset Password after forced change",
summary: "Change password (after login or forced reset)",
body: {
type: "object",
required: ["old_password", "new_password"],
@@ -25,54 +28,69 @@ export default async function authRoutesAuthenticated(server: FastifyInstance) {
},
},
}, async (req, reply) => {
const { old_password, new_password } = req.body as { old_password: string; new_password: string };
console.log(req.user)
try {
const { old_password, new_password } = req.body as {
old_password: string
new_password: string
}
const user_id = req.user?.user_id; // kommt aus JWT Middleware
if (!user_id) {
// @ts-ignore
return reply.code(401).send({ error: "Unauthorized" });
const userId = req.user?.user_id
if (!userId) {
//@ts-ignore
return reply.code(401).send({ error: "Unauthorized" })
}
// -----------------------------------------------------
// 1) User laden
// -----------------------------------------------------
const [user] = await server.db
.select({
id: authUsers.id,
passwordHash: authUsers.passwordHash,
mustChangePassword: authUsers.must_change_password
})
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
if (!user) {
//@ts-ignore
return reply.code(404).send({ error: "User not found" })
}
// -----------------------------------------------------
// 2) Altes PW prüfen
// -----------------------------------------------------
const valid = await bcrypt.compare(old_password, user.passwordHash)
if (!valid) {
//@ts-ignore
return reply.code(401).send({ error: "Old password incorrect" })
}
// -----------------------------------------------------
// 3) Neues PW hashen
// -----------------------------------------------------
const newHash = await bcrypt.hash(new_password, 10)
// -----------------------------------------------------
// 4) Updaten
// -----------------------------------------------------
await server.db
.update(authUsers)
.set({
passwordHash: newHash,
must_change_password: false,
updatedAt: new Date(),
})
.where(eq(authUsers.id, userId))
return { success: true }
} catch (err) {
console.error("POST /auth/password/change ERROR:", err)
//@ts-ignore
return reply.code(500).send({ error: "Internal Server Error" })
}
// Nutzer laden
const { data: user, error } = await server.supabase
.from("auth_users")
.select("id, password_hash, must_change_password")
.eq("id", user_id)
.single();
if (error || !user) {
// @ts-ignore
return reply.code(404).send({ error: "User not found" });
}
// Altes Passwort prüfen
const valid = await bcrypt.compare(old_password, user.password_hash);
if (!valid) {
// @ts-ignore
return reply.code(401).send({ error: "Old password incorrect" });
}
// Neues Passwort hashen
const newHash = await bcrypt.hash(new_password, 10);
// Speichern + Flag zurücksetzen
const { error: updateError } = await server.supabase
.from("auth_users")
.update({
password_hash: newHash,
must_change_password: false,
updated_at: new Date().toISOString(),
})
.eq("id", user_id);
if (updateError) {
console.log(updateError);
// @ts-ignore
return reply.code(500).send({ error: "Password update failed" });
}
return { success: true };
});
}
})
}

View File

@@ -1,169 +1,31 @@
import nodemailer from "nodemailer"
import { FastifyInstance } from "fastify"
import { eq } from "drizzle-orm"
import { sendMailAsUser } from "../utils/emailengine"
import { encrypt, decrypt } from "../utils/crypt"
import { userCredentials } from "../../db/schema"
// Pfad ggf. anpassen
import { FastifyInstance } from "fastify";
import {sendMailAsUser} from "../utils/emailengine";
import {encrypt, decrypt} from "../utils/crypt"
import {secrets} from "../utils/secrets";
// @ts-ignore
import MailComposer from 'nodemailer/lib/mail-composer/index.js'
import {ImapFlow} from "imapflow"
import MailComposer from "nodemailer/lib/mail-composer/index.js"
import { ImapFlow } from "imapflow"
export default async function emailAsUserRoutes(server: FastifyInstance) {
// Create E-Mail Account
// ======================================================================
// CREATE OR UPDATE EMAIL ACCOUNT
// ======================================================================
server.post("/email/accounts/:id?", async (req, reply) => {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" });
}
const { id } = req.params as { id: string };
const body = req.body as {
email: string
password: string
smtp_host: string
smtp_port: number
smtp_ssl: boolean
imap_host: string
imap_port: number
imap_ssl: boolean
};
if(id) {
//SAVE Existing
let saveData = {
email_encrypted: body.email ? encrypt(body.email) : undefined,
password_encrypted: body.password ? encrypt(body.password) : undefined,
smtp_host_encrypted: body.smtp_host ? encrypt(body.smtp_host) : undefined,
smtp_port: body.smtp_port,
smtp_ssl: body.smtp_ssl,
imap_host_encrypted: body.imap_host ? encrypt(body.imap_host) : undefined,
imap_port: body.imap_port,
imap_ssl: body.imap_ssl,
}
const { data, error } = await server.supabase
.from("user_credentials")
.update(saveData)
.eq("id", id)
.select("*")
.single();
if (error) {
return reply.code(400).send({ error: error.message });
} else {
return reply.send({success: true})
}
} else {
//Create New
let createData = {
user_id: req.user.user_id,
email_encrypted: encrypt(body.email),
password_encrypted: encrypt(body.password),
tenant_id: req.user.tenant_id,
smtp_host_encrypted: encrypt(body.smtp_host),
smtp_port: body.smtp_port,
smtp_ssl: body.smtp_ssl,
type: "mail",
imap_host_encrypted: encrypt(body.imap_host),
imap_port: body.imap_port,
imap_ssl: body.imap_ssl,
}
const { data, error } = await server.supabase
.from("user_credentials")
.insert(createData)
.select("*")
.single();
if (error) {
return reply.code(400).send({ error: error.message });
} else {
return reply.send({success: true})
}
}
});
server.get("/email/accounts/:id?", async (req, reply) => {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" });
}
const { id } = req.params as { id: string };
if(id) {
let returnData = {}
// @ts-ignore
const { data, error } = await server.supabase
.from("user_credentials")
.select("id, email_encrypted, smtp_host_encrypted, smtp_port, smtp_ssl, imap_host_encrypted, imap_port, imap_ssl, user_id, tenant_id")
.eq("id", id)
.eq("tenant_id", req.user.tenant_id)
.eq("type", "mail")
.single();
if (error || !data) {
return reply.code(404).send({ error: "Not found" });
} else {
Object.keys(data).forEach((key) => {
if(key.includes("encrypted")){
returnData[key.substring(0,key.length-10)] = decrypt(data[key])
} else {
returnData[key] = data[key]
}
})
}
return returnData;
} else {
const { data, error } = await server.supabase
.from("user_credentials")
.select("id, email_encrypted, user_id, tenant_id")
.eq("tenant_id", req.user.tenant_id)
.eq("type", "mail")
let accounts = []
data.forEach(item => {
let temp = {}
Object.keys(item).forEach((key) => {
if(key.includes("encrypted")){
temp[key.substring(0,key.length-10)] = decrypt(item[key])
} else {
temp[key] = item[key]
}
})
accounts.push(temp)
})
return accounts
}
});
server.post("/email/send", async (req, reply) => {
const body = req.body as {
to: string
cc?: string
bcc?: string
subject?: string
text?: string
html?: string
attachments?: any,
account: string
}
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
let accountData = {} as {
const { id } = req.params as { id?: string }
const body = req.body as {
email: string
password: string
smtp_host: string
@@ -173,32 +35,173 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
imap_port: number
imap_ssl: boolean
}
// @ts-ignore
const { data, error } = await server.supabase
.from("user_credentials")
.select("id, email_encrypted,password_encrypted, smtp_host_encrypted, smtp_port, smtp_ssl,imap_host_encrypted,imap_port, imap_ssl, user_id, tenant_id")
.eq("id", body.account)
.eq("tenant_id", req.user.tenant_id)
.eq("type", "mail")
.single();
if (error || !data) {
return reply.code(404).send({ error: "Not found" });
} else {
Object.keys(data).forEach((key) => {
if(key.includes("encrypted")){
accountData[key.substring(0,key.length-10)] = decrypt(data[key])
} else {
accountData[key] = data[key]
}
})
// -----------------------------
// UPDATE EXISTING
// -----------------------------
if (id) {
const saveData = {
emailEncrypted: body.email ? encrypt(body.email) : undefined,
passwordEncrypted: body.password ? encrypt(body.password) : undefined,
smtpHostEncrypted: body.smtp_host ? encrypt(body.smtp_host) : undefined,
smtpPort: body.smtp_port,
smtpSsl: body.smtp_ssl,
imapHostEncrypted: body.imap_host ? encrypt(body.imap_host) : undefined,
imapPort: body.imap_port,
imapSsl: body.imap_ssl,
}
await server.db
.update(userCredentials)
.set(saveData)
.where(eq(userCredentials.id, id))
return reply.send({ success: true })
}
// -----------------------------
// CREATE NEW
// -----------------------------
const insertData = {
userId: req.user.user_id,
tenantId: req.user.tenant_id,
type: "mail",
emailEncrypted: encrypt(body.email),
passwordEncrypted: encrypt(body.password),
smtpHostEncrypted: encrypt(body.smtp_host),
smtpPort: body.smtp_port,
smtpSsl: body.smtp_ssl,
imapHostEncrypted: encrypt(body.imap_host),
imapPort: body.imap_port,
imapSsl: body.imap_ssl,
}
await server.db.insert(userCredentials).values(insertData)
return reply.send({ success: true })
} catch (err) {
console.error("POST /email/accounts error:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// ======================================================================
// GET SINGLE OR ALL ACCOUNTS
// ======================================================================
server.get("/email/accounts/:id?", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id?: string }
// ============================================================
// LOAD SINGLE ACCOUNT
// ============================================================
if (id) {
const rows = await server.db
.select()
.from(userCredentials)
.where(eq(userCredentials.id, id))
const row = rows[0]
if (!row) return reply.code(404).send({ error: "Not found" })
const returnData: any = {}
Object.entries(row).forEach(([key, val]) => {
if (key.endsWith("Encrypted")) {
const cleanKey = key.replace("Encrypted", "")
// @ts-ignore
returnData[cleanKey] = decrypt(val as string)
} else {
returnData[key] = val
}
})
return reply.send(returnData)
}
// ============================================================
// LOAD ALL ACCOUNTS FOR TENANT
// ============================================================
const rows = await server.db
.select()
.from(userCredentials)
.where(eq(userCredentials.tenantId, req.user.tenant_id))
const accounts = rows.map(row => {
const temp: any = {}
Object.entries(row).forEach(([key, val]) => {
if (key.endsWith("Encrypted")) {
// @ts-ignore
temp[key.replace("Encrypted", "")] = decrypt(val as string)
} else {
temp[key] = val
}
})
return temp
})
return reply.send(accounts)
} catch (err) {
console.error("GET /email/accounts error:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// ======================================================================
// SEND EMAIL + SAVE IN IMAP SENT FOLDER
// ======================================================================
server.post("/email/send", async (req, reply) => {
try {
const body = req.body as {
to: string
cc?: string
bcc?: string
subject?: string
text?: string
html?: string
attachments?: any
account: string
}
// Fetch email credentials
const rows = await server.db
.select()
.from(userCredentials)
.where(eq(userCredentials.id, body.account))
const row = rows[0]
if (!row) return reply.code(404).send({ error: "Account not found" })
const accountData: any = {}
Object.entries(row).forEach(([key, val]) => {
if (key.endsWith("Encrypted")) {
// @ts-ignore
accountData[key.replace("Encrypted", "")] = decrypt(val as string)
} else {
accountData[key] = val
}
})
// -------------------------
// SEND EMAIL VIA SMTP
// -------------------------
const transporter = nodemailer.createTransport({
host: accountData.smtp_host,
port: accountData.smtp_port,
secure: accountData.smtp_ssl,
host: accountData.smtpHost,
port: accountData.smtpPort,
secure: accountData.smtpSsl,
auth: {
user: accountData.email,
pass: accountData.password,
@@ -208,62 +211,48 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const message = {
from: accountData.email,
to: body.to,
cc: body.cc ? body.cc : undefined,
bcc: body.bcc ? body.bcc : undefined,
cc: body.cc,
bcc: body.bcc,
subject: body.subject,
html: body.html ? body.html : undefined,
html: body.html,
text: body.text,
attachments: body.attachments ? body.attachments : undefined,
attachments: body.attachments,
}
const info = await transporter.sendMail(message)
const imapClient = new ImapFlow({
host: accountData.imap_host,
port: accountData.imap_port,
secure: accountData.imap_ssl,
// -------------------------
// SAVE TO IMAP SENT FOLDER
// -------------------------
const imap = new ImapFlow({
host: accountData.imapHost,
port: accountData.imapPort,
secure: accountData.imapSsl,
auth: {
user: accountData.email,
pass: accountData.password,
},
logger: false
})
await imapClient.connect()
await imap.connect()
const mail = new MailComposer(message)
const raw = await mail.compile().build()
const raw = await mail.compile().build() // → Buffer mit kompletter MIME
for await (const mailbox of await imapClient.list()) {
// mailbox.flags enthält z. B. ['\\Sent', '\\HasChildren']
console.log(mailbox.specialUse)
if (mailbox.specialUse == '\\Sent') {
console.log('📨 Sent folder gefunden:', mailbox.path)
await imapClient.mailboxOpen(mailbox.path)
await imapClient.append(mailbox.path, raw, ['\\Seen'])
await imapClient.logout()
break
for await (const mailbox of await imap.list()) {
if (mailbox.specialUse === "\\Sent") {
await imap.mailboxOpen(mailbox.path)
await imap.append(mailbox.path, raw, ["\\Seen"])
await imap.logout()
}
}
if(info.response.includes("OK")){
reply.send({success: true})
}{
reply.status(500)
}
return reply.send({ success: true })
} catch (err) {
console.log(err)
reply.code(500).send({ error: "Failed to send E-Mail as User" })
console.error("POST /email/send error:", err)
return reply.code(500).send({ error: "Failed to send email" })
}
})
}
}

View File

@@ -1,184 +1,200 @@
import { FastifyInstance } from "fastify"
import multipart from "@fastify/multipart"
import { s3 } from "../utils/s3"
import {GetObjectCommand, PutObjectCommand} from "@aws-sdk/client-s3"
import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
import {
GetObjectCommand,
PutObjectCommand
} from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import archiver from "archiver"
import {secrets} from "../utils/secrets"
import { secrets } from "../utils/secrets"
import { eq, inArray } from "drizzle-orm"
import {
files,
createddocuments,
customers
} from "../../db/schema"
export default async function fileRoutes(server: FastifyInstance) {
await server.register(multipart,{
limits: {
fileSize: 20 * 1024 * 1024, // 20 MB
}
// -------------------------------------------------------------
// MULTIPART INIT
// -------------------------------------------------------------
await server.register(multipart, {
limits: { fileSize: 20 * 1024 * 1024 } // 20 MB
})
// -------------------------------------------------------------
// UPLOAD FILE
// -------------------------------------------------------------
server.post("/files/upload", async (req, reply) => {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
try {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const data:any = await req.file()
const fileBuffer = await data.toBuffer()
const data: any = await req.file()
if (!data?.file) return reply.code(400).send({ error: "No file uploaded" })
const fileBuffer = await data.toBuffer()
console.log(data)
const meta = data.fields?.meta?.value ? JSON.parse(data.fields.meta.value) : {}
// 1⃣ DB-Eintrag erzeugen
const inserted = await server.db
.insert(files)
.values({ tenant: tenantId })
.returning()
let meta = JSON.parse(data.fields?.meta?.value)
const created = inserted[0]
if (!created) throw new Error("Could not create DB entry")
if (!data.file) return reply.code(400).send({ error: "No file uploaded" })
const {data:createdFileData,error:createdFileError} = await server.supabase
.from("files")
.insert({
tenant: tenantId,
})
.select()
.single()
if(createdFileError) {
console.log(createdFileError)
return reply.code(500).send({ error: "Internal Server Error" })
} else if(createdFileData && data.file) {
const fileKey = `${tenantId}/filesbyid/${createdFileData.id}/${data.filename}`
// 2⃣ Datei in S3 speichern
const fileKey = `${tenantId}/filesbyid/${created.id}/${data.filename}`
await s3.send(new PutObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: fileKey,
Body: fileBuffer,
ContentType: data.mimetype,
ContentType: data.mimetype
}))
//Update File with Corresponding Path
const {data:updateFileData, error:updateFileError} = await server.supabase
.from("files")
.update({
// 3⃣ DB updaten: meta + path
await server.db
.update(files)
.set({
...meta,
path: fileKey,
path: fileKey
})
.eq("id", createdFileData.id)
if(updateFileError) {
console.log(updateFileError)
return reply.code(500).send({ error: "Internal Server Error" })
} else {
/*const {data:tagData, error:tagError} = await server.supabase
.from("filetagmembers")
.insert(tags.map(tag => {
return {
file_id: createdFileData.id,
tag_id: tag
}
}))*/
return { id: createdFileData.id, filename: data.filename, path: fileKey }
.where(eq(files.id, created.id))
return {
id: created.id,
filename: data.filename,
path: fileKey
}
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Upload failed" })
}
})
// -------------------------------------------------------------
// GET FILE OR LIST FILES
// -------------------------------------------------------------
server.get("/files/:id?", async (req, reply) => {
const { id } = req.params as { id?: string }
try {
const { id } = req.params as { id?: string }
if(id) {
try {
const {data,error} = await server.supabase.from("files").select("*").eq("id", id).single()
// 🔹 EINZELNE DATEI
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
return {...data}
} catch (err) {
req.log.error(err);
reply.code(500).send({ error: "Could not generate presigned URL" });
const file = rows[0]
if (!file) return reply.code(404).send({ error: "Not found" })
return file
}
} else {
try {
const {data:supabaseFileEntries,error} = await server.supabase.from("files").select("*, createddocument(*, customer(*))").eq("tenant",req.user.tenant_id)
// 🔹 ALLE DATEIEN DES TENANTS (mit createddocument + customer)
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const list = await server.db
.select({
...files,
createddocument: createddocuments,
customer: customers
})
.from(files)
.leftJoin(
createddocuments,
eq(files.createddocument, createddocuments.id)
)
.leftJoin(
customers,
eq(createddocuments.customer, customers.id)
)
.where(eq(files.tenant, tenantId))
return { files: supabaseFileEntries }
} catch (err) {
req.log.error(err)
reply.code(500).send({ error: "Could not generate presigned URLs" })
}
return { files: list }
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Could not load files" })
}
})
// -------------------------------------------------------------
// DOWNLOAD (SINGLE OR MULTI ZIP)
// -------------------------------------------------------------
server.post("/files/download/:id?", async (req, reply) => {
const { id } = req.params as { id?: string }
// @ts-ignore
const ids = req.body?.ids || []
try {
if (id) {
// 🔹 Einzeldownload
const { data, error } = await server.supabase
.from("files")
.select("*")
.eq("id", id)
.single()
const { id } = req.params as { id?: string }
const ids = req.body?.ids || []
if (error || !data) {
return reply.code(404).send({ error: "File not found" })
}
// -------------------------------------------------
// 1⃣ SINGLE DOWNLOAD
// -------------------------------------------------
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
if (!file) return reply.code(404).send({ error: "File not found" })
const command = new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: data.path,
Key: file.path!
})
const { Body, ContentType } = await s3.send(command)
const chunks: any[] = []
// @ts-ignore
for await (const chunk of Body) {
chunks.push(chunk)
}
for await (const chunk of Body as any) chunks.push(chunk)
const buffer = Buffer.concat(chunks)
reply.header("Content-Type", ContentType || "application/octet-stream")
reply.header(
"Content-Disposition",
`attachment; filename="${data.path.split("/").pop()}"`
)
reply.header("Content-Disposition", `attachment; filename="${file.path?.split("/").pop()}"`)
return reply.send(buffer)
}
console.log(ids)
// -------------------------------------------------
// 2⃣ MULTI DOWNLOAD → ZIP
// -------------------------------------------------
if (Array.isArray(ids) && ids.length > 0) {
// 🔹 Multi-Download → ZIP zurückgeben
const { data: supabaseFiles, error } = await server.supabase
.from("files")
.select("*")
.in("id", ids)
const rows = await server.db
.select()
.from(files)
.where(inArray(files.id, ids))
if (error || !supabaseFiles?.length) {
return reply.code(404).send({ error: "Files not found" })
}
console.log(supabaseFiles)
if (!rows.length) return reply.code(404).send({ error: "Files not found" })
reply.header("Content-Type", "application/zip")
reply.header("Content-Disposition", "attachment; filename=dateien.zip")
reply.header("Content-Disposition", `attachment; filename="dateien.zip"`)
const archive = archiver("zip", { zlib: { level: 9 } })
archive.on("warning", console.warn)
for (const entry of supabaseFiles) {
const command = new GetObjectCommand({
for (const entry of rows) {
const cmd = new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: entry.path,
Key: entry.path!
})
const { Body } = await s3.send(cmd)
const { Body } = await s3.send(command)
const filename = entry.path.split("/").pop() || entry.id
console.log(filename)
archive.append(Body as any, { name: filename })
archive.append(Body as any, {
name: entry.path?.split("/").pop() || entry.id
})
}
await archive.finalize()
@@ -186,80 +202,80 @@ export default async function fileRoutes(server: FastifyInstance) {
}
return reply.code(400).send({ error: "No id or ids provided" })
} catch (err) {
console.log(err)
reply.code(500).send({ error: "Download failed" })
console.error(err)
return reply.code(500).send({ error: "Download failed" })
}
})
// -------------------------------------------------------------
// GENERATE PRESIGNED URL(S)
// -------------------------------------------------------------
server.post("/files/presigned/:id?", async (req, reply) => {
const { id } = req.params as { id: string };
const { ids } = req.body as { ids: string[] }
try {
const { id } = req.params as { id?: string }
const { ids } = req.body as { ids?: string[] }
const tenantId = req.user?.tenant_id
if(id) {
try {
const {data,error} = await server.supabase.from("files").select("*").eq("id", id).single()
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const command = new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: data.path,
});
// -------------------------------------------------
// SINGLE FILE PRESIGNED URL
// -------------------------------------------------
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
// URL für 15 Minuten gültig
const url = await getSignedUrl(s3, command, { expiresIn: 900 });
const file = rows[0]
if (!file) return reply.code(404).send({ error: "Not found" })
return { ...data, url };
} catch (err) {
req.log.error(err);
reply.code(500).send({ error: "Could not generate presigned URL" });
}
} else {
if (!Array.isArray(ids) || ids.length === 0) {
return reply.code(400).send({ error: "No file keys provided" })
}
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path! }),
{ expiresIn: 900 }
)
try {
const {data:supabaseFileEntries,error} = await server.supabase.from("files").select("*, createddocument(*, customer(*))").eq("tenant",req.user.tenant_id).is("archived",false)
return { ...file, url }
} else {
// -------------------------------------------------
// MULTIPLE PRESIGNED URLs
// -------------------------------------------------
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return reply.code(400).send({ error: "No ids provided" })
}
console.log(error)
const rows = await server.db
.select()
.from(files)
.where(eq(files.tenant, tenantId))
let filteredFiles = supabaseFileEntries.filter(i => ids.includes(i.id))
filteredFiles = filteredFiles.filter(i => i.path)
const selected = rows.filter(f => ids.includes(f.id))
console.log(filteredFiles.filter(i => !i.path))
let urls = await Promise.all(
ids.map(async (id) => {
let file = filteredFiles.find(i => i.id === id)
if(!file) return
let key = file.path
if(!key) console.log(file)
const command = new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: key,
})
const url = await getSignedUrl(s3, command, { expiresIn: 900 }) // 15 min gültig
return {...filteredFiles.find(i => i.id === id), url}
const output = await Promise.all(
selected.map(async (file) => {
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path! }),
{ expiresIn: 900 }
)
return { ...file, url }
})
)
urls = urls.filter(i => i)
return { files: urls }
} catch (err) {
console.log(err)
reply.code(500).send({ error: "Could not generate presigned URLs" })
return { files: output }
}
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Could not create presigned URLs" })
}
})
}
}

View File

@@ -13,6 +13,8 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
import duration from "dayjs/plugin/duration.js";
import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys} from "../../db/schema";
import {eq} from "drizzle-orm";
dayjs.extend(customParseFormat)
dayjs.extend(isoWeek)
dayjs.extend(isBetween)
@@ -102,7 +104,11 @@ export default async function functionRoutes(server: FastifyInstance) {
}
try {
const { data, error } = await server.supabase
//@ts-ignore
const data = await server.db.select().from(citys).where(eq(citys.zip,zip))
/*const { data, error } = await server.supabase
.from('citys')
.select()
.eq('zip', zip)
@@ -111,7 +117,7 @@ export default async function functionRoutes(server: FastifyInstance) {
if (error) {
console.log(error)
return reply.code(500).send({ error: 'Database error' })
}
}*/
if (!data) {
return reply.code(404).send({ error: 'ZIP not found' })

View File

@@ -1,54 +1,120 @@
import { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import {
authProfiles,
} from "../../db/schema";
export default async function authProfilesRoutes(server: FastifyInstance) {
// Ein einzelnes Profil laden (nur im aktuellen Tenant)
// -------------------------------------------------------------
// GET SINGLE PROFILE
// -------------------------------------------------------------
server.get("/profiles/:id", async (req, reply) => {
const { id } = req.params as {id:string};
const tenantId = (req.user as any)?.tenant_id;
try {
const { id } = req.params as { id: string };
const tenantId = (req.user as any)?.tenant_id;
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" });
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" });
}
const rows = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.id, id),
eq(authProfiles.tenant_id, tenantId)
)
)
.limit(1);
if (!rows.length) {
return reply.code(404).send({ error: "User not found or not in tenant" });
}
return rows[0];
} catch (error) {
console.error("GET /profiles/:id ERROR:", error);
return reply.code(500).send({ error: "Internal Server Error" });
}
const { data, error } = await server.supabase
.from("auth_profiles")
.select()
.eq("id", id)
.eq("tenant_id", tenantId)
.single();
if (error || !data) {
console.log(error)
return reply.code(404).send({ error: "User not found or not in tenant" });
}
console.log(data);
reply.send(data)
});
server.put("/profiles/:id", async (req, reply) => {
if (!req.user.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" });
function sanitizeProfileUpdate(body: any) {
const cleaned: any = { ...body }
// ❌ Systemfelder entfernen
const forbidden = [
"id", "user_id", "tenant_id", "created_at", "updated_at",
"updatedAt", "updatedBy", "old_profile_id", "full_name"
]
forbidden.forEach(f => delete cleaned[f])
// ❌ Falls NULL Strings vorkommen → in null umwandeln
for (const key of Object.keys(cleaned)) {
if (cleaned[key] === "") cleaned[key] = null
}
const { id } = req.params as { id: string };
const body = req.body as any
// ✅ Date-Felder sauber konvertieren, falls vorhanden
const dateFields = ["birthday", "entry_date"]
delete body.full_name
for (const field of dateFields) {
if (cleaned[field]) {
const d = new Date(cleaned[field])
if (!isNaN(d.getTime())) cleaned[field] = d
else delete cleaned[field] // invalid → entfernen
}
}
return cleaned
}
const { data, error } = await server.supabase
.from("auth_profiles")
.update(body)
.eq("id", id)
.eq("tenant_id", req.user.tenant_id)
.select("*")
.single();
// -------------------------------------------------------------
// UPDATE PROFILE
// -------------------------------------------------------------
server.put("/profiles/:id", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (error || !data) {
console.log(error)
return reply.code(404).send({ error: "User not found or not in tenant" });
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
let body = req.body as any
// Clean + Normalize
body = sanitizeProfileUpdate(body)
const updateData = {
...body,
updatedAt: new Date(),
updatedBy: userId
}
const updated = await server.db
.update(authProfiles)
.set(updateData)
.where(
and(
eq(authProfiles.id, id),
eq(authProfiles.tenant_id, tenantId)
)
)
.returning()
if (!updated.length) {
return reply.code(404).send({ error: "User not found or not in tenant" })
}
return updated[0]
} catch (err) {
console.error("PUT /profiles/:id ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
}
}

View File

@@ -11,18 +11,8 @@ import {
} from "drizzle-orm"
import {
projects,
customers,
plants,
contracts,
projecttypes,
createddocuments,
files,
events,
tasks, contacts, vendors
} from "../../../db/schema"
import * as sea from "node:sea";
import {resourceConfig} from "../../resource.config";
// -------------------------------------------------------------
// SQL Volltextsuche auf mehreren Feldern
@@ -50,7 +40,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// LIST
// -------------------------------------------------------------
/*server.get("/resource/:resource", async (req, reply) => {
server.get("/resource/:resource", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId)
@@ -62,34 +52,85 @@ export default async function resourceRoutes(server: FastifyInstance) {
asc?: string
}
const {resource} = req.params as {resource: string}
const table = resourceConfig[resource].table
// WHERE-Basis
let whereCond: any = eq(projects.tenant, tenantId)
let whereCond: any = eq(table.tenant, tenantId)
// 🔍 SQL Search
const searchCond = buildProjectSearch(search)
if (searchCond) whereCond = and(whereCond, searchCond)
if(search) {
const searchCond = buildSearchCondition(
table,
resourceConfig[resource].searchColumns,
search.trim()
)
if (searchCond) {
whereCond = and(whereCond, searchCond)
}
}
// Base Query
let q = server.db.select().from(projects).where(whereCond)
let q = server.db.select().from(table).where(whereCond)
// Sortierung
if (sort) {
const col = (projects as any)[sort]
const col = (table as any)[sort]
if (col) {
//@ts-ignore
q = ascQuery === "true"
? q.orderBy(asc(col))
: q.orderBy(desc(col))
}
}
const data = await q
const queryData = await q
// RELATION LOADING (MANY-TO-ONE)
let ids = {}
let lists = {}
let maps = {}
let data = []
if(resourceConfig[resource].mtoLoad) {
resourceConfig[resource].mtoLoad.forEach(relation => {
ids[relation] = [...new Set(queryData.map(r => r[relation]).filter(Boolean))];
})
for await (const relation of resourceConfig[resource].mtoLoad ) {
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
}
resourceConfig[resource].mtoLoad.forEach(relation => {
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
})
data = queryData.map(row => {
let toReturn = {
...row
}
resourceConfig[resource].mtoLoad.forEach(relation => {
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
})
return toReturn
});
} else {
data = queryData
}
return data
} catch (err) {
console.error("ERROR /resource/projects", err)
console.error("ERROR /resource/:resource", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})*/
})
// -------------------------------------------------------------
@@ -118,41 +159,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
};
const config = {
projects: {
searchColumns: ["name"],
mtoLoad: ["customer","plant","contract","projecttype"],
table: projects
},
customers: {
searchColumns: ["name", "customerNumber", "firstname", "lastname", "notes"],
table: customers,
},
contacts: {
searchColumns: ["firstName", "lastName", "email", "phone", "notes"],
table: contacts,
mtoLoad: ["customer","vendor"]
},
contracts: {
table: contracts,
searchColumns: ["name", "notes", "contractNumber", "paymentType", "sepaRef", "bankingName"]
},
plants: {
table: plants
},
projecttypes: {
table: projecttypes
},
vendors: {
table: vendors,
searchColumns: ["name","vendorNumber","notes","defaultPaymentType"],
},
files: {
table: files
}
}
let table = config[resource].table
let table = resourceConfig[resource].table
let whereCond: any = eq(table.tenant, tenantId);
@@ -160,7 +170,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
if(search) {
const searchCond = buildSearchCondition(
table,
config[resource].searchColumns,
resourceConfig[resource].searchColumns,
search.trim()
)
@@ -225,7 +235,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (sort?.length > 0) {
const s = sort[0];
const col = (projects as any)[s.field];
const col = (table as any)[s.field];
if (col) {
orderField = col;
direction = s.direction === "asc" ? "asc" : "desc";
@@ -268,17 +278,17 @@ export default async function resourceRoutes(server: FastifyInstance) {
let maps = {}
let data = []
if(config[resource].mtoLoad) {
config[resource].mtoLoad.forEach(relation => {
if(resourceConfig[resource].mtoLoad) {
resourceConfig[resource].mtoLoad.forEach(relation => {
ids[relation] = [...new Set(rows.map(r => r[relation]).filter(Boolean))];
})
for await (const relation of config[resource].mtoLoad ) {
lists[relation] = ids[relation].length ? await server.db.select().from(config[relation + "s"].table).where(inArray(config[relation + "s"].table.id, ids[relation])) : []
for await (const relation of resourceConfig[resource].mtoLoad ) {
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
}
config[resource].mtoLoad.forEach(relation => {
resourceConfig[resource].mtoLoad.forEach(relation => {
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
})
@@ -287,12 +297,14 @@ export default async function resourceRoutes(server: FastifyInstance) {
...row
}
config[resource].mtoLoad.forEach(relation => {
resourceConfig[resource].mtoLoad.forEach(relation => {
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
})
return toReturn
});
} else {
data = rows
}
// -----------------------------------------------
@@ -318,96 +330,55 @@ export default async function resourceRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// DETAIL (mit JOINS)
// -------------------------------------------------------------
/*server.get("/resource/projects/:id", async (req, reply) => {
server.get("/resource/:resource/:id", async (req, reply) => {
try {
const { id } = req.params as { id: string }
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const pid = Number(id)
const {resource} = req.params as { resource: string }
const table = resourceConfig[resource].table
const projRows = await server.db
.select()
.from(projects)
.where(and(eq(projects.id, pid), eq(projects.tenant, tenantId)))
.from(table)
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
.limit(1)
if (!projRows.length)
return reply.code(404).send({ error: "Project not found" })
const project = projRows[0]
return reply.code(404).send({ error: "Resource not found" })
// ------------------------------------
// LOAD RELATIONS
// ------------------------------------
const [
customerRecord,
plantRecord,
contractRecord,
projectTypeRecord,
projectTasks,
projectFiles,
projectDocuments,
projectEvents,
] = await Promise.all([
project.customer
? server.db.select().from(customers).where(eq(customers.id, project.customer))
: [],
project.plant
? server.db.select().from(plants).where(eq(plants.id, project.plant))
: [],
project.contract
? server.db.select().from(contracts).where(eq(contracts.id, project.contract))
: [],
project.projecttype
? server.db.select().from(projecttypes).where(eq(projecttypes.id, project.projecttype))
: [],
// Tasks
server.db
.select()
.from(tasks)
.where(eq(tasks.project, pid)),
// Files
server.db
.select()
.from(files)
.where(eq(files.project, pid)),
// Documents
server.db
.select()
.from(createddocuments)
.where(eq(createddocuments.project, pid)),
// Events
server.db
.select()
.from(events)
.where(eq(events.project, pid)),
])
return {
...project,
customer: customerRecord[0] ?? null,
plant: plantRecord[0] ?? null,
contract: contractRecord[0] ?? null,
projecttype: projectTypeRecord[0] ?? null,
tasks: projectTasks,
files: projectFiles,
createddocuments: projectDocuments,
events: projectEvents,
let ids = {}
let lists = {}
let maps = {}
let data = {
...projRows[0]
}
if(resourceConfig[resource].mtoLoad) {
for await (const relation of resourceConfig[resource].mtoLoad ) {
if(data[relation]) {
data[relation] = await server.db.select().from(resourceConfig[relation + "s"].table).where(eq(resourceConfig[relation + "s"].table.id, data[relation]))
}
}
for await (const relation of resourceConfig[resource].mtmLoad ) {
console.log(relation)
data[relation] = await server.db.select().from(resourceConfig[relation].table).where(eq(resourceConfig[relation].table[resource.substring(0,resource.length - 1)],id))
}
}
return data
} catch (err) {
console.error("ERROR /resource/projects/:id", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})*/
})
}

View File

@@ -1,858 +0,0 @@
import { FastifyInstance } from "fastify"
import {
eq,
ilike,
asc,
desc,
and,
count,
inArray,
} from "drizzle-orm"
import {
products,
productcategories,
services,
servicecategories,
} from "../../../db/schema"
// -----------------------------------------------------------------------------
// PRODUCTS
// -----------------------------------------------------------------------------
export default async function productsAndServicesRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// LIST: /resource/products
// -------------------------------------------------------------
server.get("/resource/products", async (req, reply) => {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const { search, sort, asc: ascQuery } = req.query as {
search?: string
sort?: string
asc?: string
}
let baseQuery = server.db
.select()
.from(products)
.where(eq(products.tenant, tenantId))
if (search) {
baseQuery = server.db
.select()
.from(products)
.where(
and(
eq(products.tenant, tenantId),
ilike(products.name, `%${search}%`)
)
)
}
if (sort) {
const field = (products as any)[sort]
if (field) {
// @ts-ignore
baseQuery = baseQuery.orderBy(
ascQuery === "true" ? asc(field) : desc(field)
)
}
}
const list = await baseQuery
return list
})
// -------------------------------------------------------------
// PAGINATED: /resource/products/paginated
// -------------------------------------------------------------
server.get("/resource/products/paginated", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" })
}
const queryConfig = req.queryConfig
const {
pagination,
sort,
filters,
paginationDisabled,
} = queryConfig
const {
select, // aktuell ignoriert, wie bei customers
search,
searchColumns,
distinctColumns,
} = req.query as {
select?: string
search?: string
searchColumns?: string
distinctColumns?: string
}
let whereCond: any = eq(products.tenant, tenantId)
if (filters) {
for (const [key, val] of Object.entries(filters)) {
const col = (products as any)[key]
if (!col) continue
if (Array.isArray(val)) {
whereCond = and(whereCond, inArray(col, val))
} else if (val === true || val === false || val === null) {
whereCond = and(whereCond, eq(col, val as any))
} else {
whereCond = and(whereCond, eq(col, val as any))
}
}
}
if (search && search.trim().length > 0) {
const searchTerm = `%${search.trim().toLowerCase()}%`
whereCond = and(
whereCond,
ilike(products.name, searchTerm)
)
}
const totalRes = await server.db
.select({ value: count(products.id) })
.from(products)
.where(whereCond)
const total = Number(totalRes[0]?.value ?? 0)
const distinctValues: Record<string, any[]> = {}
if (distinctColumns) {
for (const colName of distinctColumns.split(",").map(v => v.trim())) {
const col = (products as any)[colName]
if (!col) continue
const rows = await server.db
.select({ v: col })
.from(products)
.where(eq(products.tenant, tenantId))
const values = rows
.map(r => r.v)
.filter(v => v != null && v !== "")
distinctValues[colName] = [...new Set(values)].sort()
}
}
let offset = 0
let limit = 999999
if (!paginationDisabled && pagination) {
offset = pagination.offset
limit = pagination.limit
}
let orderField: any = null
let orderDirection: "asc" | "desc" = "asc"
if (sort?.length > 0) {
const s = sort[0]
const col = (products as any)[s.field]
if (col) {
orderField = col
orderDirection = s.direction === "asc" ? "asc" : "desc"
}
}
let dataQuery = server.db
.select()
.from(products)
.where(whereCond)
.offset(offset)
.limit(limit)
if (orderField) {
dataQuery =
orderDirection === "asc"
? dataQuery.orderBy(asc(orderField))
: dataQuery.orderBy(desc(orderField))
}
const data = await dataQuery
const totalPages = pagination?.limit
? Math.ceil(total / pagination.limit)
: 1
const enrichedConfig = {
...queryConfig,
total,
totalPages,
distinctValues,
search: search || null,
}
return {
data,
queryConfig: enrichedConfig,
}
} catch (e) {
server.log.error(e)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// -------------------------------------------------------------
// DETAIL: /resource/products/:id
// (aktuell ohne weitere Joins)
// -------------------------------------------------------------
server.get("/resource/products/:id", async (req, reply) => {
const { id } = req.params as { id: string }
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const rows = await server.db
.select()
.from(products)
.where(
and(
eq(products.id, Number(id)),
eq(products.tenant, tenantId)
)
)
.limit(1)
if (!rows.length) {
return reply.code(404).send({ error: "Product not found" })
}
return rows[0]
})
// ---------------------------------------------------------------------------
// PRODUCTCATEGORIES
// ---------------------------------------------------------------------------
server.get("/resource/productcategories", async (req, reply) => {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const { search, sort, asc: ascQuery } = req.query as {
search?: string
sort?: string
asc?: string
}
let baseQuery = server.db
.select()
.from(productcategories)
.where(eq(productcategories.tenant, tenantId))
if (search) {
const searchTerm = `%${search}%`
baseQuery = server.db
.select()
.from(productcategories)
.where(
and(
eq(productcategories.tenant, tenantId),
ilike(productcategories.name, searchTerm)
)
)
}
if (sort) {
const field = (productcategories as any)[sort]
if (field) {
// @ts-ignore
baseQuery = baseQuery.orderBy(
ascQuery === "true" ? asc(field) : desc(field)
)
}
}
const list = await baseQuery
return list
})
server.get("/resource/productcategories/paginated", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" })
}
const queryConfig = req.queryConfig
const {
pagination,
sort,
filters,
paginationDisabled,
} = queryConfig
const {
select,
search,
searchColumns,
distinctColumns,
} = req.query as {
select?: string
search?: string
searchColumns?: string
distinctColumns?: string
}
let whereCond: any = eq(productcategories.tenant, tenantId)
if (filters) {
for (const [key, val] of Object.entries(filters)) {
const col = (productcategories as any)[key]
if (!col) continue
if (Array.isArray(val)) {
whereCond = and(whereCond, inArray(col, val))
} else if (val === true || val === false || val === null) {
whereCond = and(whereCond, eq(col, val as any))
} else {
whereCond = and(whereCond, eq(col, val as any))
}
}
}
if (search && search.trim().length > 0) {
const searchTerm = `%${search.trim().toLowerCase()}%`
whereCond = and(
whereCond,
ilike(productcategories.name, searchTerm)
)
}
const totalRes = await server.db
.select({ value: count(productcategories.id) })
.from(productcategories)
.where(whereCond)
const total = Number(totalRes[0]?.value ?? 0)
const distinctValues: Record<string, any[]> = {}
if (distinctColumns) {
for (const colName of distinctColumns.split(",").map(v => v.trim())) {
const col = (productcategories as any)[colName]
if (!col) continue
const rows = await server.db
.select({ v: col })
.from(productcategories)
.where(eq(productcategories.tenant, tenantId))
const values = rows
.map(r => r.v)
.filter(v => v != null && v !== "")
distinctValues[colName] = [...new Set(values)].sort()
}
}
let offset = 0
let limit = 999999
if (!paginationDisabled && pagination) {
offset = pagination.offset
limit = pagination.limit
}
let orderField: any = null
let orderDirection: "asc" | "desc" = "asc"
if (sort?.length > 0) {
const s = sort[0]
const col = (productcategories as any)[s.field]
if (col) {
orderField = col
orderDirection = s.direction === "asc" ? "asc" : "desc"
}
}
let dataQuery = server.db
.select()
.from(productcategories)
.where(whereCond)
.offset(offset)
.limit(limit)
if (orderField) {
dataQuery =
orderDirection === "asc"
? dataQuery.orderBy(asc(orderField))
: dataQuery.orderBy(desc(orderField))
}
const data = await dataQuery
const totalPages = pagination?.limit
? Math.ceil(total / pagination.limit)
: 1
const enrichedConfig = {
...queryConfig,
total,
totalPages,
distinctValues,
search: search || null,
}
return {
data,
queryConfig: enrichedConfig,
}
} catch (e) {
server.log.error(e)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
server.get("/resource/productcategories/:id", async (req, reply) => {
const { id } = req.params as { id: string }
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const rows = await server.db
.select()
.from(productcategories)
.where(
and(
eq(productcategories.id, Number(id)),
eq(productcategories.tenant, tenantId)
)
)
.limit(1)
if (!rows.length) {
return reply.code(404).send({ error: "Product category not found" })
}
// Später hier: products mit Join-Tabelle
return rows[0]
})
// ---------------------------------------------------------------------------
// SERVICES
// ---------------------------------------------------------------------------
server.get("/resource/services", async (req, reply) => {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const { search, sort, asc: ascQuery } = req.query as {
search?: string
sort?: string
asc?: string
}
let baseQuery = server.db
.select()
.from(services)
.where(eq(services.tenant, tenantId))
if (search) {
const searchTerm = `%${search}%`
baseQuery = server.db
.select()
.from(services)
.where(
and(
eq(services.tenant, tenantId),
ilike(services.name, searchTerm)
)
)
}
if (sort) {
const field = (services as any)[sort]
if (field) {
// @ts-ignore
baseQuery = baseQuery.orderBy(
ascQuery === "true" ? asc(field) : desc(field)
)
}
}
const list = await baseQuery
return list
})
server.get("/resource/services/paginated", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" })
}
const queryConfig = req.queryConfig
const {
pagination,
sort,
filters,
paginationDisabled,
} = queryConfig
const {
select,
search,
searchColumns,
distinctColumns,
} = req.query as {
select?: string
search?: string
searchColumns?: string
distinctColumns?: string
}
let whereCond: any = eq(services.tenant, tenantId)
if (filters) {
for (const [key, val] of Object.entries(filters)) {
const col = (services as any)[key]
if (!col) continue
if (Array.isArray(val)) {
whereCond = and(whereCond, inArray(col, val))
} else if (val === true || val === false || val === null) {
whereCond = and(whereCond, eq(col, val as any))
} else {
whereCond = and(whereCond, eq(col, val as any))
}
}
}
if (search && search.trim().length > 0) {
const searchTerm = `%${search.trim().toLowerCase()}%`
whereCond = and(
whereCond,
ilike(services.name, searchTerm)
)
}
const totalRes = await server.db
.select({ value: count(services.id) })
.from(services)
.where(whereCond)
const total = Number(totalRes[0]?.value ?? 0)
const distinctValues: Record<string, any[]> = {}
if (distinctColumns) {
for (const colName of distinctColumns.split(",").map(v => v.trim())) {
const col = (services as any)[colName]
if (!col) continue
const rows = await server.db
.select({ v: col })
.from(services)
.where(eq(services.tenant, tenantId))
const values = rows
.map(r => r.v)
.filter(v => v != null && v !== "")
distinctValues[colName] = [...new Set(values)].sort()
}
}
let offset = 0
let limit = 999999
if (!paginationDisabled && pagination) {
offset = pagination.offset
limit = pagination.limit
}
let orderField: any = null
let orderDirection: "asc" | "desc" = "asc"
if (sort?.length > 0) {
const s = sort[0]
const col = (services as any)[s.field]
if (col) {
orderField = col
orderDirection = s.direction === "asc" ? "asc" : "desc"
}
}
let dataQuery = server.db
.select()
.from(services)
.where(whereCond)
.offset(offset)
.limit(limit)
if (orderField) {
dataQuery =
orderDirection === "asc"
? dataQuery.orderBy(asc(orderField))
: dataQuery.orderBy(desc(orderField))
}
const data = await dataQuery
const totalPages = pagination?.limit
? Math.ceil(total / pagination.limit)
: 1
const enrichedConfig = {
...queryConfig,
total,
totalPages,
distinctValues,
search: search || null,
}
return {
data,
queryConfig: enrichedConfig,
}
} catch (e) {
server.log.error(e)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
server.get("/resource/services/:id", async (req, reply) => {
const { id } = req.params as { id: string }
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const rows = await server.db
.select()
.from(services)
.where(
and(
eq(services.id, Number(id)),
eq(services.tenant, tenantId)
)
)
.limit(1)
if (!rows.length) {
return reply.code(404).send({ error: "Service not found" })
}
// Später: Unit, Kategorien, etc. als Joins
return rows[0]
})
// ---------------------------------------------------------------------------
// SERVICECATEGORIES
// ---------------------------------------------------------------------------
server.get("/resource/servicecategories", async (req, reply) => {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const { search, sort, asc: ascQuery } = req.query as {
search?: string
sort?: string
asc?: string
}
let baseQuery = server.db
.select()
.from(servicecategories)
.where(eq(servicecategories.tenant, tenantId))
if (search) {
const searchTerm = `%${search}%`
baseQuery = server.db
.select()
.from(servicecategories)
.where(
and(
eq(servicecategories.tenant, tenantId),
ilike(servicecategories.name, searchTerm)
)
)
}
if (sort) {
const field = (servicecategories as any)[sort]
if (field) {
// @ts-ignore
baseQuery = baseQuery.orderBy(
ascQuery === "true" ? asc(field) : desc(field)
)
}
}
const list = await baseQuery
return list
})
server.get("/resource/servicecategories/paginated", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" })
}
const queryConfig = req.queryConfig
const {
pagination,
sort,
filters,
paginationDisabled,
} = queryConfig
const {
select,
search,
searchColumns,
distinctColumns,
} = req.query as {
select?: string
search?: string
searchColumns?: string
distinctColumns?: string
}
let whereCond: any = eq(servicecategories.tenant, tenantId)
if (filters) {
for (const [key, val] of Object.entries(filters)) {
const col = (servicecategories as any)[key]
if (!col) continue
if (Array.isArray(val)) {
whereCond = and(whereCond, inArray(col, val))
} else if (val === true || val === false || val === null) {
whereCond = and(whereCond, eq(col, val as any))
} else {
whereCond = and(whereCond, eq(col, val as any))
}
}
}
if (search && search.trim().length > 0) {
const searchTerm = `%${search.trim().toLowerCase()}%`
whereCond = and(
whereCond,
ilike(servicecategories.name, searchTerm)
)
}
const totalRes = await server.db
.select({ value: count(servicecategories.id) })
.from(servicecategories)
.where(whereCond)
const total = Number(totalRes[0]?.value ?? 0)
const distinctValues: Record<string, any[]> = {}
if (distinctColumns) {
for (const colName of distinctColumns.split(",").map(v => v.trim())) {
const col = (servicecategories as any)[colName]
if (!col) continue
const rows = await server.db
.select({ v: col })
.from(servicecategories)
.where(eq(servicecategories.tenant, tenantId))
const values = rows
.map(r => r.v)
.filter(v => v != null && v !== "")
distinctValues[colName] = [...new Set(values)].sort()
}
}
let offset = 0
let limit = 999999
if (!paginationDisabled && pagination) {
offset = pagination.offset
limit = pagination.limit
}
let orderField: any = null
let orderDirection: "asc" | "desc" = "asc"
if (sort?.length > 0) {
const s = sort[0]
const col = (servicecategories as any)[s.field]
if (col) {
orderField = col
orderDirection = s.direction === "asc" ? "asc" : "desc"
}
}
let dataQuery = server.db
.select()
.from(servicecategories)
.where(whereCond)
.offset(offset)
.limit(limit)
if (orderField) {
dataQuery =
orderDirection === "asc"
? dataQuery.orderBy(asc(orderField))
: dataQuery.orderBy(desc(orderField))
}
const data = await dataQuery
const totalPages = pagination?.limit
? Math.ceil(total / pagination.limit)
: 1
const enrichedConfig = {
...queryConfig,
total,
totalPages,
distinctValues,
search: search || null,
}
return {
data,
queryConfig: enrichedConfig,
}
} catch (e) {
server.log.error(e)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
server.get("/resource/servicecategories/:id", async (req, reply) => {
const { id } = req.params as { id: string }
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const rows = await server.db
.select()
.from(servicecategories)
.where(
and(
eq(servicecategories.id, Number(id)),
eq(servicecategories.tenant, tenantId)
)
)
.limit(1)
if (!rows.length) {
return reply.code(404).send({ error: "Service category not found" })
}
// Später: zugehörige Services über Join-Tabelle
return rows[0]
})
}