Merge branch 'orm' into 'main'

Orm

See merge request fedeo/backend!2
This commit is contained in:
2025-12-07 21:49:31 +00:00
98 changed files with 5427 additions and 11243 deletions

10
db/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { drizzle } from "drizzle-orm/node-postgres"
import { Pool } from "pg"
import {secrets} from "../src/utils/secrets";
const pool = new Pool({
connectionString: secrets.DATABASE_URL,
max: 10, // je nach Last
})
export const db = drizzle(pool)

24
db/schema/accounts.ts Normal file
View File

@@ -0,0 +1,24 @@
import {
pgTable,
bigint,
timestamp,
text,
} from "drizzle-orm/pg-core"
export const accounts = pgTable("accounts", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
number: text("number").notNull(),
label: text("label").notNull(),
description: text("description"),
})
export type Account = typeof accounts.$inferSelect
export type NewAccount = typeof accounts.$inferInsert

View File

@@ -0,0 +1,83 @@
import {
pgTable,
uuid,
text,
timestamp,
date,
boolean,
bigint,
doublePrecision,
jsonb,
} from "drizzle-orm/pg-core"
import { authUsers } from "./auth_users"
export const authProfiles = pgTable("auth_profiles", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: uuid("user_id").references(() => authUsers.id),
tenant_id: bigint("tenant_id", { mode: "number" }).notNull(),
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
first_name: text("first_name").notNull(),
last_name: text("last_name").notNull(),
full_name: text("full_name").generatedAlwaysAs(
`((first_name || ' ') || last_name)`
),
mobile_tel: text("mobile_tel"),
fixed_tel: text("fixed_tel"),
salutation: text("salutation"),
employee_number: text("employee_number"),
weekly_working_hours: doublePrecision("weekly_working_hours").default(0),
annual_paid_leave_days: bigint("annual_paid_leave_days", { mode: "number" }),
weekly_regular_working_hours: jsonb("weekly_regular_working_hours").default("{}"),
clothing_size_top: text("clothing_size_top"),
clothing_size_bottom: text("clothing_size_bottom"),
clothing_size_shoe: text("clothing_size_shoe"),
email_signature: text("email_signature").default("<p>Mit freundlichen Grüßen</p>"),
birthday: date("birthday"),
entry_date: date("entry_date").defaultNow(),
automatic_hour_corrections: jsonb("automatic_hour_corrections").default("[]"),
recreation_days_compensation: boolean("recreation_days_compensation")
.notNull()
.default(true),
customer_for_portal: bigint("customer_for_portal", { mode: "number" }),
pinned_on_navigation: jsonb("pinned_on_navigation").notNull().default("[]"),
email: text("email"),
token_id: text("token_id"),
weekly_working_days: doublePrecision("weekly_working_days"),
old_profile_id: uuid("old_profile_id"),
temp_config: jsonb("temp_config"),
state_code: text("state_code").default("DE-NI"),
contract_type: text("contract_type"),
position: text("position"),
qualification: text("qualification"),
address_street: text("address_street"),
address_zip: text("address_zip"),
address_city: text("address_city"),
active: boolean("active").notNull().default(true),
})
export type AuthProfile = typeof authProfiles.$inferSelect
export type NewAuthProfile = typeof authProfiles.$inferInsert

View File

@@ -0,0 +1,23 @@
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"
import { authRoles } from "./auth_roles"
export const authRolePermissions = pgTable(
"auth_role_permissions",
{
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
role_id: uuid("role_id")
.notNull()
.references(() => authRoles.id),
permission: text("permission").notNull(),
},
(table) => ({
primaryKey: [table.role_id, table.permission],
})
)
export type AuthRolePermission = typeof authRolePermissions.$inferSelect
export type NewAuthRolePermission = typeof authRolePermissions.$inferInsert

19
db/schema/auth_roles.ts Normal file
View File

@@ -0,0 +1,19 @@
import { pgTable, uuid, text, timestamp, bigint } from "drizzle-orm/pg-core"
import { authUsers } from "./auth_users"
export const authRoles = pgTable("auth_roles", {
id: uuid("id").primaryKey().defaultRandom(),
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
description: text("description"),
created_by: uuid("created_by").references(() => authUsers.id),
tenant_id: bigint("tenant_id", {mode: "number"}),
})
export type AuthRole = typeof authRoles.$inferSelect
export type NewAuthRole = typeof authRoles.$inferInsert

View File

@@ -0,0 +1,22 @@
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
import { authUsers } from "./auth_users"
export const authTenantUsers = pgTable(
"auth_tenant_users",
{
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant_id: bigint("tenant_id", { mode: "number" }).notNull(),
user_id: uuid("user_id").notNull(),
created_by: uuid("created_by").references(() => authUsers.id),
},
(table) => ({
primaryKey: [table.tenant_id, table.user_id],
})
)
export type AuthTenantUser = typeof authTenantUsers.$inferSelect
export type NewAuthTenantUser = typeof authTenantUsers.$inferInsert

View File

@@ -0,0 +1,30 @@
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
import { authUsers } from "./auth_users"
import { authRoles } from "./auth_roles"
export const authUserRoles = pgTable(
"auth_user_roles",
{
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
user_id: uuid("user_id")
.notNull()
.references(() => authUsers.id),
role_id: uuid("role_id")
.notNull()
.references(() => authRoles.id),
tenant_id: bigint("tenant_id", { mode: "number" }).notNull(),
created_by: uuid("created_by").references(() => authUsers.id),
},
(table) => ({
primaryKey: [table.user_id, table.role_id, table.tenant_id],
})
)
export type AuthUserRole = typeof authUserRoles.$inferSelect
export type NewAuthUserRole = typeof authUserRoles.$inferInsert

22
db/schema/auth_users.ts Normal file
View File

@@ -0,0 +1,22 @@
import { pgTable, uuid, text, boolean, timestamp } from "drizzle-orm/pg-core"
export const authUsers = pgTable("auth_users", {
id: uuid("id").primaryKey().defaultRandom(),
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
email: text("email").notNull(),
passwordHash: text("password_hash").notNull(),
multiTenant: boolean("multi_tenant").notNull().default(true),
must_change_password: boolean("must_change_password").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
ported: boolean("ported").notNull().default(true),
})
export type AuthUser = typeof authUsers.$inferSelect
export type NewAuthUser = typeof authUsers.$inferInsert

52
db/schema/bankaccounts.ts Normal file
View File

@@ -0,0 +1,52 @@
import {
pgTable,
bigint,
timestamp,
text,
doublePrecision,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const bankaccounts = pgTable("bankaccounts", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name"),
iban: text("iban").notNull(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
bankId: text("bankId").notNull(),
ownerName: text("ownerName"),
accountId: text("accountId").notNull(),
balance: doublePrecision("balance"),
expired: boolean("expired").notNull().default(false),
datevNumber: text("datevNumber"),
syncedAt: timestamp("synced_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
archived: boolean("archived").notNull().default(false),
})
export type BankAccount = typeof bankaccounts.$inferSelect
export type NewBankAccount = typeof bankaccounts.$inferInsert

View File

@@ -0,0 +1,30 @@
import {
pgTable,
uuid,
timestamp,
text,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const bankrequisitions = pgTable("bankrequisitions", {
id: uuid("id").primaryKey(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
institutionId: text("institutionId"),
tenant: bigint("tenant", { mode: "number" }).references(() => tenants.id),
status: text("status"),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type BankRequisition = typeof bankrequisitions.$inferSelect
export type NewBankRequisition = typeof bankrequisitions.$inferInsert

View File

@@ -0,0 +1,70 @@
import {
pgTable,
bigint,
timestamp,
text,
doublePrecision,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { bankaccounts } from "./bankaccounts"
import { createddocuments } from "./createddocuments"
import { tenants } from "./tenants"
import { incominginvoices } from "./incominginvoices"
import { contracts } from "./contracts"
import { authUsers } from "./auth_users"
export const bankstatements = pgTable("bankstatements", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
account: bigint("account", { mode: "number" })
.notNull()
.references(() => bankaccounts.id),
date: text("date").notNull(),
credIban: text("credIban"),
credName: text("credName"),
text: text("text"),
amount: doublePrecision("amount").notNull(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
debIban: text("debIban"),
debName: text("debName"),
gocardlessId: text("gocardlessId"),
currency: text("currency"),
valueDate: text("valueDate"),
incomingInvoice: bigint("incomingInvoice", { mode: "number" }).references(
() => incominginvoices.id
),
mandateId: text("mandateId"),
contract: bigint("contract", { mode: "number" }).references(
() => contracts.id
),
createdDocument: bigint("createdDocument", { mode: "number" }).references(
() => createddocuments.id
),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type BankStatement = typeof bankstatements.$inferSelect
export type NewBankStatement = typeof bankstatements.$inferInsert

View File

@@ -0,0 +1,27 @@
import {
pgTable,
uuid,
timestamp,
text,
} from "drizzle-orm/pg-core"
import { checks } from "./checks"
export const checkexecutions = pgTable("checkexecutions", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
check: uuid("check").references(() => checks.id),
executedAt: timestamp("executed_at"),
// ❌ executed_by removed (was 0_profiles)
description: text("description"),
})
export type CheckExecution = typeof checkexecutions.$inferSelect
export type NewCheckExecution = typeof checkexecutions.$inferInsert

52
db/schema/checks.ts Normal file
View File

@@ -0,0 +1,52 @@
import {
pgTable,
uuid,
timestamp,
text,
bigint,
boolean,
jsonb,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { vehicles } from "./vehicles"
import { inventoryitems } from "./inventoryitems"
import { authUsers } from "./auth_users"
export const checks = pgTable("checks", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
vehicle: bigint("vehicle", { mode: "number" })
.references(() => vehicles.id),
// ❌ profile removed (old 0_profiles reference)
inventoryItem: bigint("inventoryitem", { mode: "number" })
.references(() => inventoryitems.id),
tenant: bigint("tenant", { mode: "number" })
.references(() => tenants.id),
name: text("name"),
type: text("type"),
distance: bigint("distance", { mode: "number" }).default(1),
distanceUnit: text("distanceUnit").default("days"),
description: text("description"),
profiles: jsonb("profiles").notNull().default([]),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Check = typeof checks.$inferSelect
export type NewCheck = typeof checks.$inferInsert

32
db/schema/citys.ts Normal file
View File

@@ -0,0 +1,32 @@
import {
pgTable,
bigint,
text,
jsonb,
} from "drizzle-orm/pg-core"
export const citys = pgTable("citys", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
name: text("name"),
short: text("short"),
long: text("long"),
geometry: jsonb("geometry"),
zip: bigint("zip", { mode: "number" }),
districtCode: bigint("districtCode", { mode: "number" }),
countryName: text("countryName"),
countryCode: bigint("countryCode", { mode: "number" }),
districtName: text("districtName"),
geopoint: text("geopoint"),
})
export type City = typeof citys.$inferSelect
export type NewCity = typeof citys.$inferInsert

66
db/schema/contacts.ts Normal file
View File

@@ -0,0 +1,66 @@
import {
pgTable,
bigint,
text,
timestamp,
boolean,
jsonb,
date,
uuid,
} from "drizzle-orm/pg-core"
import { customers } from "./customers"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const contacts = pgTable(
"contacts",
{
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
firstName: text("firstName"),
lastName: text("lastName"),
email: text("email"),
customer: bigint("customer", { mode: "number" }).references(
() => customers.id
),
tenant: bigint("tenant", { mode: "number" }).notNull(),
phoneMobile: text("phoneMobile"),
phoneHome: text("phoneHome"),
heroId: text("heroId"),
role: text("role"),
fullName: text("fullName"),
salutation: text("salutation"),
vendor: bigint("vendor", { mode: "number" }), // vendors folgt separat
active: boolean("active").notNull().default(true),
birthday: date("birthday"),
notes: text("notes"),
profiles: jsonb("profiles").notNull().default([]),
archived: boolean("archived").notNull().default(false),
title: text("title"),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
}
)
export type Contact = typeof contacts.$inferSelect
export type NewContact = typeof contacts.$inferInsert

76
db/schema/contracts.ts Normal file
View File

@@ -0,0 +1,76 @@
import {
pgTable,
bigint,
text,
timestamp,
boolean,
jsonb,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { customers } from "./customers"
import { contacts } from "./contacts"
import { authUsers } from "./auth_users"
export const contracts = pgTable(
"contracts",
{
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" }).notNull(),
name: text("name").notNull(),
customer: bigint("customer", { mode: "number" })
.notNull()
.references(() => customers.id),
notes: text("notes"),
active: boolean("active").notNull().default(true),
recurring: boolean("recurring").notNull().default(false),
rhythm: jsonb("rhythm"),
startDate: timestamp("startDate", { withTimezone: true }),
endDate: timestamp("endDate", { withTimezone: true }),
signDate: timestamp("signDate", { withTimezone: true }),
duration: text("duration"),
contact: bigint("contact", { mode: "number" }).references(
() => contacts.id
),
bankingIban: text("bankingIban"),
bankingBIC: text("bankingBIC"),
bankingName: text("bankingName"),
bankingOwner: text("bankingOwner"),
sepaRef: text("sepaRef"),
sepaDate: timestamp("sepaDate", { withTimezone: true }),
paymentType: text("paymentType"),
invoiceDispatch: text("invoiceDispatch"),
ownFields: jsonb("ownFields").notNull().default({}),
profiles: jsonb("profiles").notNull().default([]),
archived: boolean("archived").notNull().default(false),
contractNumber: text("contractNumber"),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
}
)
export type Contract = typeof contracts.$inferSelect
export type NewContract = typeof contracts.$inferInsert

50
db/schema/costcentres.ts Normal file
View File

@@ -0,0 +1,50 @@
import {
pgTable,
uuid,
timestamp,
text,
boolean,
jsonb,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { inventoryitems } from "./inventoryitems"
import { projects } from "./projects"
import { vehicles } from "./vehicles"
import { authUsers } from "./auth_users"
export const costcentres = pgTable("costcentres", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
number: text("number").notNull(),
name: text("name").notNull(),
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
project: bigint("project", { mode: "number" }).references(() => projects.id),
inventoryitem: bigint("inventoryitem", { mode: "number" }).references(
() => inventoryitems.id
),
description: text("description"),
archived: boolean("archived").notNull().default(false),
profiles: jsonb("profiles").notNull().default([]),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type CostCentre = typeof costcentres.$inferSelect
export type NewCostCentre = typeof costcentres.$inferInsert

21
db/schema/countrys.ts Normal file
View File

@@ -0,0 +1,21 @@
import {
pgTable,
bigint,
timestamp,
text,
} from "drizzle-orm/pg-core"
export const countrys = pgTable("countrys", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
})
export type Country = typeof countrys.$inferSelect
export type NewCountry = typeof countrys.$inferInsert

View File

@@ -0,0 +1,121 @@
import {
pgTable,
bigint,
timestamp,
text,
jsonb,
boolean,
smallint,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { customers } from "./customers"
import { contacts } from "./contacts"
import { contracts } from "./contracts"
import { letterheads } from "./letterheads"
import { projects } from "./projects"
import { plants } from "./plants"
import { authUsers } from "./auth_users"
export const createddocuments = pgTable("createddocuments", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
type: text("type").notNull().default("INVOICE"),
customer: bigint("customer", { mode: "number" }).references(
() => customers.id
),
contact: bigint("contact", { mode: "number" }).references(
() => contacts.id
),
address: jsonb("address"),
project: bigint("project", { mode: "number" }).references(
() => projects.id
),
documentNumber: text("documentNumber"),
documentDate: text("documentDate"),
state: text("state").notNull().default("Entwurf"),
info: jsonb("info"),
createdBy: uuid("createdBy").references(() => authUsers.id),
title: text("title"),
description: text("description"),
startText: text("startText"),
endText: text("endText"),
rows: jsonb("rows").default([]),
deliveryDateType: text("deliveryDateType"),
paymentDays: smallint("paymentDays"),
deliveryDate: text("deliveryDate"),
contactPerson: uuid("contactPerson"),
serialConfig: jsonb("serialConfig").default({}),
linkedDocument: bigint("linkedDocument", { mode: "number" }).references(
() => createddocuments.id
),
agriculture: jsonb("agriculture"),
letterhead: bigint("letterhead", { mode: "number" }).references(
() => letterheads.id
),
advanceInvoiceResolved: boolean("advanceInvoiceResolved")
.notNull()
.default(false),
usedAdvanceInvoices: jsonb("usedAdvanceInvoices").notNull().default([]),
archived: boolean("archived").notNull().default(false),
deliveryDateEnd: text("deliveryDateEnd"),
plant: bigint("plant", { mode: "number" }).references(() => plants.id),
taxType: text("taxType"),
customSurchargePercentage: smallint("customSurchargePercentage")
.notNull()
.default(0),
report: jsonb("report").notNull().default({}),
availableInPortal: boolean("availableInPortal")
.notNull()
.default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
created_by: uuid("created_by").references(() => authUsers.id),
payment_type: text("payment_type").default("transfer"),
contract: bigint("contract", { mode: "number" }).references(
() => contracts.id
),
})
export type CreatedDocument = typeof createddocuments.$inferSelect
export type NewCreatedDocument = typeof createddocuments.$inferInsert

View File

@@ -0,0 +1,43 @@
import {
pgTable,
uuid,
timestamp,
bigint,
text,
jsonb,
boolean,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { customers } from "./customers"
import { vendors } from "./vendors"
import { authUsers } from "./auth_users"
export const createdletters = pgTable("createdletters", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" }).references(() => tenants.id),
customer: bigint("customer", { mode: "number" }).references(
() => customers.id
),
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
contentJson: jsonb("content_json").default([]),
contentText: text("content_text"),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
archived: boolean("archived").notNull().default(false),
})
export type CreatedLetter = typeof createdletters.$inferSelect
export type NewCreatedLetter = typeof createdletters.$inferInsert

69
db/schema/customers.ts Normal file
View File

@@ -0,0 +1,69 @@
import {
pgTable,
bigint,
text,
timestamp,
boolean,
jsonb,
smallint,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const customers = pgTable(
"customers",
{
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
customerNumber: text("customerNumber").notNull(),
name: text("name").notNull(),
tenant: bigint("tenant", { mode: "number" }).notNull(),
infoData: jsonb("infoData").default({}),
active: boolean("active").notNull().default(true),
notes: text("notes"),
type: text("type").default("Privat"),
heroId: text("heroId"),
isCompany: boolean("isCompany").notNull().default(false),
profiles: jsonb("profiles").notNull().default([]),
customPaymentDays: smallint("customPaymentDays"),
firstname: text("firstname"),
lastname: text("lastname"),
archived: boolean("archived").notNull().default(false),
customSurchargePercentage: smallint("customSurchargePercentage")
.notNull()
.default(0),
salutation: text("salutation"),
title: text("title"),
nameAddition: text("nameAddition"),
availableInPortal: boolean("availableInPortal")
.notNull()
.default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
customPaymentType: text("custom_payment_type"), // ENUM payment_types separat?
}
)
export type Customer = typeof customers.$inferSelect
export type NewCustomer = typeof customers.$inferInsert

29
db/schema/devices.ts Normal file
View File

@@ -0,0 +1,29 @@
import {
pgTable,
uuid,
timestamp,
text,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
export const devices = pgTable("devices", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
type: text("type").notNull(),
tenant: bigint("tenant", { mode: "number" }).references(() => tenants.id),
password: text("password"),
externalId: text("externalId"),
})
export type Device = typeof devices.$inferSelect
export type NewDevice = typeof devices.$inferInsert

View File

@@ -0,0 +1,28 @@
import { pgTable, uuid, timestamp, text, boolean, bigint } from "drizzle-orm/pg-core"
import { spaces } from "./spaces"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const documentboxes = pgTable("documentboxes", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
space: bigint("space", { mode: "number" }).references(() => spaces.id),
key: text("key").notNull(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type DocumentBox = typeof documentboxes.$inferSelect
export type NewDocumentBox = typeof documentboxes.$inferInsert

97
db/schema/enums.ts Normal file
View File

@@ -0,0 +1,97 @@
import { pgEnum } from "drizzle-orm/pg-core"
// public.textTemplatePositions
export const textTemplatePositionsEnum = pgEnum("texttemplatepositions", [
"startText",
"endText",
])
// public.folderFunctions
export const folderFunctionsEnum = pgEnum("folderfunctions", [
"none",
"yearSubCategory",
"incomingInvoices",
"invoices",
"quotes",
"confirmationOrders",
"deliveryNotes",
"vehicleData",
"reminders",
"taxData",
"deposit",
"timeEvaluations",
])
// public.locked_tenant
export const lockedTenantEnum = pgEnum("locked_tenant", [
"maintenance_tenant",
"maintenance",
"general",
"no_subscription",
])
// public.credential_types
export const credentialTypesEnum = pgEnum("credential_types", [
"mail",
"m365",
])
// public.payment_types
export const paymentTypesEnum = pgEnum("payment_types", [
"transfer",
"direct_debit",
])
// public.notification_status
export const notificationStatusEnum = pgEnum("notification_status", [
"queued",
"sent",
"failed",
"read",
])
// public.notification_channel
export const notificationChannelEnum = pgEnum("notification_channel", [
"email",
"inapp",
"sms",
"push",
"webhook",
])
// public.notification_severity
export const notificationSeverityEnum = pgEnum("notification_severity", [
"info",
"success",
"warning",
"error",
])
// public.times_state
export const timesStateEnum = pgEnum("times_state", [
"submitted",
"approved",
"draft",
])
export const helpdeskStatusEnum = [
"open",
"in_progress",
"waiting_for_customer",
"answered",
"closed",
] as const
export const helpdeskPriorityEnum = [
"low",
"normal",
"high",
] as const
export const helpdeskDirectionEnum = [
"incoming",
"outgoing",
"internal",
"system",
] as const

60
db/schema/events.ts Normal file
View File

@@ -0,0 +1,60 @@
import {
pgTable,
bigint,
text,
timestamp,
boolean,
jsonb,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { customers } from "./customers"
import { authUsers } from "./auth_users"
export const events = pgTable(
"events",
{
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" }).notNull(),
name: text("name").notNull(),
startDate: timestamp("startDate", { withTimezone: true }).notNull(),
endDate: timestamp("endDate", { withTimezone: true }),
eventtype: text("eventtype").default("Umsetzung"),
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists
resources: jsonb("resources").default([]),
notes: text("notes"),
link: text("link"),
profiles: jsonb("profiles").notNull().default([]),
archived: boolean("archived").notNull().default(false),
vehicles: jsonb("vehicles").notNull().default([]),
inventoryitems: jsonb("inventoryitems").notNull().default([]),
inventoryitemgroups: jsonb("inventoryitemgroups").notNull().default([]),
customer: bigint("customer", { mode: "number" }).references(
() => customers.id
),
vendor: bigint("vendor", { mode: "number" }), // will link once vendors.ts is created
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
}
)
export type Event = typeof events.$inferSelect
export type NewEvent = typeof events.$inferInsert

79
db/schema/files.ts Normal file
View File

@@ -0,0 +1,79 @@
import {
pgTable,
uuid,
timestamp,
text,
boolean,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { projects } from "./projects"
import { customers } from "./customers"
import { contracts } from "./contracts"
import { vendors } from "./vendors"
import { incominginvoices } from "./incominginvoices"
import { plants } from "./plants"
import { createddocuments } from "./createddocuments"
import { vehicles } from "./vehicles"
import { products } from "./products"
import { inventoryitems } from "./inventoryitems"
import { folders } from "./folders"
import { filetags } from "./filetags"
import { authUsers } from "./auth_users"
import { authProfiles } from "./auth_profiles"
import { spaces } from "./spaces"
import { documentboxes } from "./documentboxes"
import { checks } from "./checks"
export const files = pgTable("files", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
path: text("path"),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
project: bigint("project", { mode: "number" }).references(() => projects.id),
customer: bigint("customer", { mode: "number" }).references(() => customers.id),
contract: bigint("contract", { mode: "number" }).references(() => contracts.id),
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
incominginvoice: bigint("incominginvoice", { mode: "number" }).references(() => incominginvoices.id),
plant: bigint("plant", { mode: "number" }).references(() => plants.id),
createddocument: bigint("createddocument", { mode: "number" }).references(() => createddocuments.id),
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
product: bigint("product", { mode: "number" }).references(() => products.id),
check: uuid("check").references(() => checks.id),
inventoryitem: bigint("inventoryitem", { mode: "number" }).references(() => inventoryitems.id),
folder: uuid("folder").references(() => folders.id),
mimeType: text("mimeType"),
archived: boolean("archived").notNull().default(false),
space: bigint("space", { mode: "number" }).references(() => spaces.id),
type: uuid("type").references(() => filetags.id),
documentbox: uuid("documentbox").references(() => documentboxes.id),
name: text("name"),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
createdBy: uuid("created_by").references(() => authUsers.id),
authProfile: uuid("auth_profile").references(() => authProfiles.id),
})
export type File = typeof files.$inferSelect
export type NewFile = typeof files.$inferInsert

33
db/schema/filetags.ts Normal file
View File

@@ -0,0 +1,33 @@
import {
pgTable,
uuid,
timestamp,
text,
boolean,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
export const filetags = pgTable("filetags", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
color: text("color"),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
createdDocumentType: text("createddocumenttype").default(""),
incomingDocumentType: text("incomingDocumentType"),
archived: boolean("archived").notNull().default(false),
})
export type FileTag = typeof filetags.$inferSelect
export type NewFileTag = typeof filetags.$inferInsert

51
db/schema/folders.ts Normal file
View File

@@ -0,0 +1,51 @@
import {
pgTable,
uuid,
timestamp,
text,
boolean,
integer,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { filetags } from "./filetags"
import { folderFunctionsEnum } from "./enums"
export const folders = pgTable("folders", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
icon: text("icon"),
parent: uuid("parent").references(() => folders.id),
isSystemUsed: boolean("isSystemUsed").notNull().default(false),
function: folderFunctionsEnum("function"),
year: integer("year"),
standardFiletype: uuid("standardFiletype").references(() => filetags.id),
standardFiletypeIsOptional: boolean("standardFiletypeIsOptional")
.notNull()
.default(true),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
archived: boolean("archived").notNull().default(false),
})
export type Folder = typeof folders.$inferSelect
export type NewFolder = typeof folders.$inferInsert

View File

@@ -0,0 +1,35 @@
import {
pgTable,
bigint,
timestamp,
text,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
export const generatedexports = pgTable("exports", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id),
startDate: timestamp("start_date", { withTimezone: true }).notNull(),
endDate: timestamp("end_date", { withTimezone: true }).notNull(),
validUntil: timestamp("valid_until", { withTimezone: true }),
type: text("type").notNull().default("datev"),
url: text("url").notNull(),
filePath: text("file_path"),
})
export type Export = typeof generatedexports.$inferSelect
export type NewExport = typeof generatedexports.$inferInsert

View File

@@ -0,0 +1,22 @@
import {
pgTable,
bigint,
timestamp,
text,
} from "drizzle-orm/pg-core"
export const globalmessages = pgTable("globalmessages", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
title: text("title"),
description: text("description"),
})
export type GlobalMessage = typeof globalmessages.$inferSelect
export type NewGlobalMessage = typeof globalmessages.$inferInsert

View File

@@ -0,0 +1,17 @@
import {
pgTable,
timestamp,
bigint,
} from "drizzle-orm/pg-core"
import { globalmessages } from "./globalmessages"
export const globalmessagesseen = pgTable("globalmessagesseen", {
message: bigint("message", { mode: "number" })
.notNull()
.references(() => globalmessages.id),
seenAt: timestamp("seen_at", { withTimezone: true })
.notNull()
.defaultNow(),
})

View File

@@ -0,0 +1,44 @@
import {
pgTable,
uuid,
timestamp,
text,
boolean,
jsonb,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { helpdesk_channel_types } from "./helpdesk_channel_types"
export const helpdesk_channel_instances = pgTable("helpdesk_channel_instances", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
typeId: text("type_id")
.notNull()
.references(() => helpdesk_channel_types.id),
name: text("name").notNull(),
isActive: boolean("is_active").notNull().default(true),
config: jsonb("config").notNull(),
publicConfig: jsonb("public_config").notNull().default({}),
publicToken: text("public_token").unique(),
secretToken: text("secret_token"),
createdBy: uuid("created_by").references(() => authUsers.id),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
})
export type HelpdeskChannelInstance =
typeof helpdesk_channel_instances.$inferSelect
export type NewHelpdeskChannelInstance =
typeof helpdesk_channel_instances.$inferInsert

View File

@@ -0,0 +1,9 @@
import { pgTable, text } from "drizzle-orm/pg-core"
export const helpdesk_channel_types = pgTable("helpdesk_channel_types", {
id: text("id").primaryKey(),
description: text("description").notNull(),
})
export type HelpdeskChannelType = typeof helpdesk_channel_types.$inferSelect
export type NewHelpdeskChannelType = typeof helpdesk_channel_types.$inferInsert

View File

@@ -0,0 +1,45 @@
import {
pgTable,
uuid,
timestamp,
text,
jsonb,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { customers } from "./customers"
import { contacts } from "./contacts"
import { helpdesk_channel_instances } from "./helpdesk_channel_instances" // placeholder
export const helpdesk_contacts = pgTable("helpdesk_contacts", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
customerId: bigint("customer_id", { mode: "number" })
.references(() => customers.id, { onDelete: "set null" }),
email: text("email"),
phone: text("phone"),
externalRef: jsonb("external_ref"),
displayName: text("display_name"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
sourceChannelId: uuid("source_channel_id").references(
() => helpdesk_channel_instances.id,
{ onDelete: "set null" }
),
contactId: bigint("contact_id", { mode: "number" }).references(
() => contacts.id,
{ onDelete: "set null" }
),
})
export type HelpdeskContact = typeof helpdesk_contacts.$inferSelect
export type NewHelpdeskContact = typeof helpdesk_contacts.$inferInsert

View File

@@ -0,0 +1,34 @@
import {
pgTable,
uuid,
text,
} from "drizzle-orm/pg-core"
import { helpdesk_conversations } from "./helpdesk_conversations"
import { authUsers } from "./auth_users"
export const helpdesk_conversation_participants = pgTable(
"helpdesk_conversation_participants",
{
conversationId: uuid("conversation_id")
.notNull()
.references(() => helpdesk_conversations.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => authUsers.id, { onDelete: "cascade" }),
role: text("role"),
},
(table) => ({
pk: {
name: "helpdesk_conversation_participants_pkey",
columns: [table.conversationId, table.userId],
},
})
)
export type HelpdeskConversationParticipant =
typeof helpdesk_conversation_participants.$inferSelect
export type NewHelpdeskConversationParticipant =
typeof helpdesk_conversation_participants.$inferInsert

View File

@@ -0,0 +1,59 @@
import {
pgTable,
uuid,
timestamp,
text,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { helpdesk_contacts } from "./helpdesk_contacts"
import { contacts } from "./contacts"
import { customers } from "./customers"
import { authUsers } from "./auth_users"
import { helpdesk_channel_instances } from "./helpdesk_channel_instances"
export const helpdesk_conversations = pgTable("helpdesk_conversations", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
channelInstanceId: uuid("channel_instance_id")
.notNull()
.references(() => helpdesk_channel_instances.id, { onDelete: "cascade" }),
contactId: uuid("contact_id").references(() => helpdesk_contacts.id, {
onDelete: "set null",
}),
subject: text("subject"),
status: text("status").notNull().default("open"),
priority: text("priority").default("normal"),
assigneeUserId: uuid("assignee_user_id").references(() => authUsers.id),
lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
customerId: bigint("customer_id", { mode: "number" }).references(
() => customers.id,
{ onDelete: "set null" }
),
contactPersonId: bigint("contact_person_id", { mode: "number" }).references(
() => contacts.id,
{ onDelete: "set null" }
),
ticketNumber: text("ticket_number"),
})
export type HelpdeskConversation =
typeof helpdesk_conversations.$inferSelect
export type NewHelpdeskConversation =
typeof helpdesk_conversations.$inferInsert

View File

@@ -0,0 +1,46 @@
import {
pgTable,
uuid,
timestamp,
text,
jsonb,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { helpdesk_contacts } from "./helpdesk_contacts"
import { helpdesk_conversations } from "./helpdesk_conversations"
import { authUsers } from "./auth_users"
export const helpdesk_messages = pgTable("helpdesk_messages", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
conversationId: uuid("conversation_id")
.notNull()
.references(() => helpdesk_conversations.id, { onDelete: "cascade" }),
direction: text("direction").notNull(),
authorUserId: uuid("author_user_id").references(() => authUsers.id),
payload: jsonb("payload").notNull(),
rawMeta: jsonb("raw_meta"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
contactId: uuid("contact_id").references(() => helpdesk_contacts.id, {
onDelete: "set null",
}),
externalMessageId: text("external_message_id").unique(),
receivedAt: timestamp("received_at", { withTimezone: true }).defaultNow(),
})
export type HelpdeskMessage = typeof helpdesk_messages.$inferSelect
export type NewHelpdeskMessage = typeof helpdesk_messages.$inferInsert

View File

@@ -0,0 +1,33 @@
import {
pgTable,
uuid,
timestamp,
text,
jsonb,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const helpdesk_routing_rules = pgTable("helpdesk_routing_rules", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
condition: jsonb("condition").notNull(),
action: jsonb("action").notNull(),
createdBy: uuid("created_by").references(() => authUsers.id),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
})
export type HelpdeskRoutingRule =
typeof helpdesk_routing_rules.$inferSelect
export type NewHelpdeskRoutingRule =
typeof helpdesk_routing_rules.$inferInsert

140
db/schema/historyitems.ts Normal file
View File

@@ -0,0 +1,140 @@
import {
pgTable,
bigint,
uuid,
timestamp,
text,
jsonb,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { customers } from "./customers"
import { vendors } from "./vendors"
import { projects } from "./projects"
import { plants } from "./plants"
import { incominginvoices } from "./incominginvoices"
import { contacts } from "./contacts"
import { inventoryitems } from "./inventoryitems"
import { products } from "./products"
import { tasks } from "./tasks"
import { vehicles } from "./vehicles"
import { bankstatements } from "./bankstatements"
import { spaces } from "./spaces"
import { costcentres } from "./costcentres"
import { ownaccounts } from "./ownaccounts"
import { createddocuments } from "./createddocuments"
import { documentboxes } from "./documentboxes"
import { hourrates } from "./hourrates"
import { projecttypes } from "./projecttypes"
import { checks } from "./checks"
import { services } from "./services"
import { events } from "./events"
import { inventoryitemgroups } from "./inventoryitemgroups"
import { authUsers } from "./auth_users"
import {files} from "./files";
export const historyitems = pgTable("historyitems", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
text: text("text").notNull(),
customer: bigint("customer", { mode: "number" }).references(
() => customers.id,
{ onDelete: "cascade" }
),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
project: bigint("project", { mode: "number" }).references(
() => projects.id,
{ onDelete: "cascade" }
),
plant: bigint("plant", { mode: "number" }).references(
() => plants.id,
{ onDelete: "cascade" }
),
incomingInvoice: bigint("incomingInvoice", { mode: "number" }).references(
() => incominginvoices.id,
{ onDelete: "cascade" }
),
contact: bigint("contact", { mode: "number" }).references(() => contacts.id, {
onDelete: "cascade",
}),
inventoryitem: bigint("inventoryitem", { mode: "number" }).references(
() => inventoryitems.id,
{ onDelete: "cascade" }
),
product: bigint("product", { mode: "number" }).references(
() => products.id,
{ onDelete: "cascade" }
),
event: bigint("event", { mode: "number" }).references(() => events.id),
newVal: text("newVal"),
oldVal: text("oldVal"),
task: bigint("task", { mode: "number" }).references(() => tasks.id),
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
bankstatement: bigint("bankstatement", { mode: "number" }).references(
() => bankstatements.id
),
space: bigint("space", { mode: "number" }).references(() => spaces.id),
config: jsonb("config"),
projecttype: bigint("projecttype", { mode: "number" }).references(
() => projecttypes.id
),
check: uuid("check").references(() => checks.id),
service: bigint("service", { mode: "number" }).references(
() => services.id
),
createddocument: bigint("createddocument", { mode: "number" }).references(
() => createddocuments.id
),
file: uuid("file").references(() => files.id),
inventoryitemgroup: uuid("inventoryitemgroup").references(
() => inventoryitemgroups.id
),
source: text("source").default("Software"),
costcentre: uuid("costcentre").references(() => costcentres.id),
ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
documentbox: uuid("documentbox").references(() => documentboxes.id),
hourrate: uuid("hourrate").references(() => hourrates.id),
createdBy: uuid("created_by").references(() => authUsers.id),
action: text("action"),
})
export type HistoryItem = typeof historyitems.$inferSelect
export type NewHistoryItem = typeof historyitems.$inferInsert

18
db/schema/holidays.ts Normal file
View File

@@ -0,0 +1,18 @@
import { pgTable, bigint, date, text, timestamp } from "drizzle-orm/pg-core"
export const holidays = pgTable("holidays", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedAlwaysAsIdentity(),
date: date("date").notNull(),
name: text("name").notNull(),
stateCode: text("state_code").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
})
export type Holiday = typeof holidays.$inferSelect
export type NewHoliday = typeof holidays.$inferInsert

27
db/schema/hourrates.ts Normal file
View File

@@ -0,0 +1,27 @@
import { pgTable, uuid, timestamp, text, boolean, bigint, doublePrecision } from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const hourrates = pgTable("hourrates", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
purchasePrice: doublePrecision("purchasePrice").notNull(),
sellingPrice: doublePrecision("sellingPrice").notNull(),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type HourRate = typeof hourrates.$inferSelect
export type NewHourRate = typeof hourrates.$inferInsert

View File

@@ -0,0 +1,63 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
jsonb,
uuid,
} from "drizzle-orm/pg-core"
import { vendors } from "./vendors"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const incominginvoices = pgTable("incominginvoices", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
state: text("state").notNull().default("Entwurf"),
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
reference: text("reference"),
date: text("date"),
document: bigint("document", { mode: "number" }),
dueDate: text("dueDate"),
description: text("description"),
paymentType: text("paymentType"),
accounts: jsonb("accounts").notNull().default([
{
account: null,
taxType: null,
amountNet: null,
amountTax: 19,
costCentre: null,
},
]),
paid: boolean("paid").notNull().default(false),
expense: boolean("expense").notNull().default(true),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
archived: boolean("archived").notNull().default(false),
})
export type IncomingInvoice = typeof incominginvoices.$inferSelect
export type NewIncomingInvoice = typeof incominginvoices.$inferInsert

70
db/schema/index.ts Normal file
View File

@@ -0,0 +1,70 @@
export * from "./accounts"
export * from "./auth_profiles"
export * from "./auth_role_permisssions"
export * from "./auth_roles"
export * from "./auth_tenant_users"
export * from "./auth_user_roles"
export * from "./auth_users"
export * from "./bankaccounts"
export * from "./bankrequisitions"
export * from "./bankstatements"
export * from "./checkexecutions"
export * from "./checks"
export * from "./citys"
export * from "./contacts"
export * from "./contracts"
export * from "./costcentres"
export * from "./countrys"
export * from "./createddocuments"
export * from "./createdletters"
export * from "./customers"
export * from "./devices"
export * from "./documentboxes"
export * from "./enums"
export * from "./events"
export * from "./files"
export * from "./filetags"
export * from "./folders"
export * from "./generatedexports"
export * from "./globalmessages"
export * from "./globalmessagesseen"
export * from "./helpdesk_channel_instances"
export * from "./helpdesk_channel_types"
export * from "./helpdesk_contacts"
export * from "./helpdesk_conversation_participants"
export * from "./helpdesk_conversations"
export * from "./helpdesk_messages"
export * from "./helpdesk_routing_rules"
export * from "./historyitems"
export * from "./holidays"
export * from "./hourrates"
export * from "./incominginvoices"
export * from "./inventoryitemgroups"
export * from "./inventoryitems"
export * from "./letterheads"
export * from "./movements"
export * from "./notifications_event_types"
export * from "./notifications_items"
export * from "./notifications_preferences"
export * from "./notifications_preferences_defaults"
export * from "./ownaccounts"
export * from "./plants"
export * from "./productcategories"
export * from "./products"
export * from "./projects"
export * from "./projecttypes"
export * from "./servicecategories"
export * from "./services"
export * from "./spaces"
export * from "./staff_time_entries"
export * from "./staff_time_entry_connects"
export * from "./staff_zeitstromtimestamps"
export * from "./statementallocations"
export * from "./tasks"
export * from "./taxtypes"
export * from "./tenants"
export * from "./texttemplates"
export * from "./units"
export * from "./user_credentials"
export * from "./vehicles"
export * from "./vendors"

View File

@@ -0,0 +1,39 @@
import {
pgTable,
uuid,
timestamp,
text,
boolean,
jsonb, bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const inventoryitemgroups = pgTable("inventoryitemgroups", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" }).notNull().references(() => tenants.id),
name: text("name").notNull(),
inventoryitems: jsonb("inventoryitems").notNull().default([]),
description: text("description"),
archived: boolean("archived").notNull().default(false),
profiles: jsonb("profiles").notNull().default([]),
usePlanning: boolean("usePlanning").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type InventoryItemGroup = typeof inventoryitemgroups.$inferSelect
export type NewInventoryItemGroup = typeof inventoryitemgroups.$inferInsert

View File

@@ -0,0 +1,68 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
doublePrecision,
uuid,
jsonb,
date,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { vendors } from "./vendors"
import { spaces } from "./spaces"
import { authUsers } from "./auth_users"
export const inventoryitems = pgTable("inventoryitems", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
usePlanning: boolean("usePlanning").notNull().default(false),
description: text("description"),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
currentSpace: bigint("currentSpace", { mode: "number" }).references(
() => spaces.id
),
articleNumber: text("articleNumber"),
serialNumber: text("serialNumber"),
purchaseDate: date("purchaseDate"),
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
quantity: bigint("quantity", { mode: "number" }).notNull().default(0),
purchasePrice: doublePrecision("purchasePrice").default(0),
manufacturer: text("manufacturer"),
manufacturerNumber: text("manufacturerNumber"),
currentValue: doublePrecision("currentValue"),
archived: boolean("archived").notNull().default(false),
profiles: jsonb("profiles").notNull().default([]),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() =>
authUsers.id
),
})
export type InventoryItem = typeof inventoryitems.$inferSelect
export type NewInventoryItem = typeof inventoryitems.$inferInsert

39
db/schema/letterheads.ts Normal file
View File

@@ -0,0 +1,39 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const letterheads = pgTable("letterheads", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").default("Standard"),
path: text("path").notNull(),
documentTypes: text("documentTypes").array().notNull().default([]),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
archived: boolean("archived").notNull().default(false),
})
export type Letterhead = typeof letterheads.$inferSelect
export type NewLetterhead = typeof letterheads.$inferInsert

49
db/schema/movements.ts Normal file
View File

@@ -0,0 +1,49 @@
import {
pgTable,
bigint,
timestamp,
text,
uuid,
} from "drizzle-orm/pg-core"
import { products } from "./products"
import { spaces } from "./spaces"
import { tenants } from "./tenants"
import { projects } from "./projects"
import { authUsers } from "./auth_users"
export const movements = pgTable("movements", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
quantity: bigint("quantity", { mode: "number" }).notNull(),
productId: bigint("productId", { mode: "number" })
.notNull()
.references(() => products.id),
spaceId: bigint("spaceId", { mode: "number" }).references(() => spaces.id),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
projectId: bigint("projectId", { mode: "number" }).references(
() => projects.id
),
notes: text("notes"),
serials: text("serials").array(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Movement = typeof movements.$inferSelect
export type NewMovement = typeof movements.$inferInsert

View File

@@ -0,0 +1,34 @@
import {
pgTable,
text,
jsonb,
boolean,
timestamp,
} from "drizzle-orm/pg-core"
import {notificationSeverityEnum} from "./enums";
export const notificationsEventTypes = pgTable("notifications_event_types", {
eventKey: text("event_key").primaryKey(),
displayName: text("display_name").notNull(),
description: text("description"),
category: text("category"),
severity: notificationSeverityEnum("severity").notNull().default("info"),
allowedChannels: jsonb("allowed_channels").notNull().default(["inapp", "email"]),
payloadSchema: jsonb("payload_schema"),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
})
export type NotificationsEventType =
typeof notificationsEventTypes.$inferSelect
export type NewNotificationsEventType =
typeof notificationsEventTypes.$inferInsert

View File

@@ -0,0 +1,54 @@
import {
pgTable,
uuid,
bigint,
text,
jsonb,
timestamp,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { notificationsEventTypes } from "./notifications_event_types"
import {notificationChannelEnum, notificationStatusEnum} from "./enums";
export const notificationsItems = pgTable("notifications_items", {
id: uuid("id").primaryKey().defaultRandom(),
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" }),
eventType: text("event_type")
.notNull()
.references(() => notificationsEventTypes.eventKey, {
onUpdate: "cascade",
onDelete: "restrict",
}),
title: text("title").notNull(),
message: text("message").notNull(),
payload: jsonb("payload"),
channel: notificationChannelEnum("channel").notNull(),
status: notificationStatusEnum("status").notNull().default("queued"),
error: text("error"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
sentAt: timestamp("sent_at", { withTimezone: true }),
readAt: timestamp("read_at", { withTimezone: true }),
})
export type NotificationItem = typeof notificationsItems.$inferSelect
export type NewNotificationItem = typeof notificationsItems.$inferInsert

View File

@@ -0,0 +1,60 @@
import {
pgTable,
uuid,
bigint,
text,
boolean,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { notificationsEventTypes } from "./notifications_event_types"
import {notificationChannelEnum} from "./enums";
export const notificationsPreferences = pgTable(
"notifications_preferences",
{
id: uuid("id").primaryKey().defaultRandom(),
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",
}),
eventType: text("event_type")
.notNull()
.references(() => notificationsEventTypes.eventKey, {
onDelete: "restrict",
onUpdate: "cascade",
}),
channel: notificationChannelEnum("channel").notNull(),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
uniquePrefs: uniqueIndex(
"notifications_preferences_tenant_id_user_id_event_type_chan_key",
).on(table.tenantId, table.userId, table.eventType, table.channel),
}),
)
export type NotificationPreference =
typeof notificationsPreferences.$inferSelect
export type NewNotificationPreference =
typeof notificationsPreferences.$inferInsert

View File

@@ -0,0 +1,52 @@
import {
pgTable,
uuid,
bigint,
text,
boolean,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { notificationsEventTypes } from "./notifications_event_types"
import {notificationChannelEnum} from "./enums";
export const notificationsPreferencesDefaults = pgTable(
"notifications_preferences_defaults",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
eventKey: text("event_key")
.notNull()
.references(() => notificationsEventTypes.eventKey, {
onDelete: "restrict",
onUpdate: "cascade",
}),
channel: notificationChannelEnum("channel").notNull(),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
uniqueDefaults: uniqueIndex(
"notifications_preferences_defau_tenant_id_event_key_channel_key",
).on(table.tenantId, table.eventKey, table.channel),
}),
)
export type NotificationPreferenceDefault =
typeof notificationsPreferencesDefaults.$inferSelect
export type NewNotificationPreferenceDefault =
typeof notificationsPreferencesDefaults.$inferInsert

39
db/schema/ownaccounts.ts Normal file
View File

@@ -0,0 +1,39 @@
import {
pgTable,
uuid,
timestamp,
text,
boolean,
jsonb,
bigint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const ownaccounts = pgTable("ownaccounts", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
number: text("number").notNull(),
name: text("name").notNull(),
description: text("description"),
archived: boolean("archived").notNull().default(false),
profiles: jsonb("profiles").notNull().default([]),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type OwnAccount = typeof ownaccounts.$inferSelect
export type NewOwnAccount = typeof ownaccounts.$inferInsert

56
db/schema/plants.ts Normal file
View File

@@ -0,0 +1,56 @@
import {
pgTable,
bigint,
timestamp,
text,
jsonb,
boolean,
uuid,
date,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { customers } from "./customers"
import { contracts } from "./contracts"
import { authUsers } from "./auth_users"
export const plants = pgTable("plants", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
customer: bigint("customer", { mode: "number" }).references(
() => customers.id
),
infoData: jsonb("infoData"),
contract: bigint("contract", { mode: "number" }).references(
() => contracts.id
),
description: jsonb("description").default({
html: "",
json: [],
text: "",
}),
archived: boolean("archived").notNull().default(false),
profiles: jsonb("profiles").notNull().default([]),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Plant = typeof plants.$inferSelect
export type NewPlant = typeof plants.$inferInsert

View File

@@ -0,0 +1,37 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const productcategories = pgTable("productcategories", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
description: text("description"),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type ProductCategory = typeof productcategories.$inferSelect
export type NewProductCategory = typeof productcategories.$inferInsert

69
db/schema/products.ts Normal file
View File

@@ -0,0 +1,69 @@
import {
pgTable,
bigint,
timestamp,
text,
doublePrecision,
boolean,
smallint,
uuid,
jsonb,
json,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { units } from "./units"
import { authUsers } from "./auth_users"
export const products = pgTable("products", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
manufacturer: text("manufacturer"),
unit: bigint("unit", { mode: "number" })
.notNull()
.references(() => units.id),
tags: json("tags").notNull().default([]),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
ean: text("ean"),
barcode: text("barcode"),
purchase_price: doublePrecision("purchasePrice"),
selling_price: doublePrecision("sellingPrice"),
description: text("description"),
manufacturer_number: text("manufacturerNumber"),
vendor_allocation: jsonb("vendorAllocation").default([]),
article_number: text("articleNumber"),
barcodes: text("barcodes").array().notNull().default([]),
productcategories: jsonb("productcategories").default([]),
archived: boolean("archived").notNull().default(false),
tax_percentage: smallint("taxPercentage").notNull().default(19),
markup_percentage: doublePrecision("markupPercentage"),
updated_at: timestamp("updated_at", { withTimezone: true }),
updated_by: uuid("updated_by").references(() => authUsers.id),
})
export type Product = typeof products.$inferSelect
export type NewProduct = typeof products.$inferInsert

78
db/schema/projects.ts Normal file
View File

@@ -0,0 +1,78 @@
import {
pgTable,
bigint,
timestamp,
text,
jsonb,
json,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { customers } from "./customers"
import { contracts } from "./contracts"
import { projecttypes } from "./projecttypes"
import { authUsers } from "./auth_users"
export const projects = pgTable("projects", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
notes: text("notes"),
customer: bigint("customer", { mode: "number" }).references(
() => customers.id
),
phases: jsonb("phases").default([]),
description: json("description"),
forms: jsonb("forms").default([]),
heroId: text("heroId"),
measure: text("measure"),
material: jsonb("material"),
plant: bigint("plant", { mode: "number" }),
profiles: uuid("profiles").array().notNull().default([]),
projectNumber: text("projectNumber"),
contract: bigint("contract", { mode: "number" }).references(
() => contracts.id
),
projectType: text("projectType").default("Projekt"),
projecttype: bigint("projecttype", { mode: "number" }).references(
() => projecttypes.id
),
archived: boolean("archived").notNull().default(false),
customerRef: text("customerRef"),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
active_phase: text("active_phase"),
})
export type Project = typeof projects.$inferSelect
export type NewProject = typeof projects.$inferInsert

41
db/schema/projecttypes.ts Normal file
View File

@@ -0,0 +1,41 @@
import {
pgTable,
bigint,
timestamp,
text,
jsonb,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const projecttypes = pgTable("projecttypes", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
initialPhases: jsonb("initialPhases"),
addablePhases: jsonb("addablePhases"),
icon: text("icon"),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type ProjectType = typeof projecttypes.$inferSelect
export type NewProjectType = typeof projecttypes.$inferInsert

View File

@@ -0,0 +1,39 @@
import {
pgTable,
bigint,
timestamp,
text,
doublePrecision,
boolean,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const servicecategories = pgTable("servicecategories", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
description: text("description"),
discount: doublePrecision("discount").default(0),
archived: boolean("archived").notNull().default(false),
updated_at: timestamp("updated_at", { withTimezone: true }),
updated_by: uuid("updated_by").references(() => authUsers.id),
})
export type ServiceCategory = typeof servicecategories.$inferSelect
export type NewServiceCategory = typeof servicecategories.$inferInsert

63
db/schema/services.ts Normal file
View File

@@ -0,0 +1,63 @@
import {
pgTable,
bigint,
timestamp,
text,
doublePrecision,
jsonb,
boolean,
smallint,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { units } from "./units"
import { authUsers } from "./auth_users"
export const services = pgTable("services", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
sellingPrice: doublePrecision("sellingPrice"),
description: text("description"),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
unit: bigint("unit", { mode: "number" }).references(() => units.id),
serviceNumber: bigint("serviceNumber", { mode: "number" }),
tags: jsonb("tags").default([]),
servicecategories: jsonb("servicecategories").notNull().default([]),
archived: boolean("archived").notNull().default(false),
purchasePriceComposed: jsonb("purchasePriceComposed")
.notNull()
.default({ total: 0 }),
sellingPriceComposed: jsonb("sellingPriceComposed")
.notNull()
.default({ total: 0 }),
taxPercentage: smallint("taxPercentage").notNull().default(19),
materialComposition: jsonb("materialComposition").notNull().default([]),
personalComposition: jsonb("personalComposition").notNull().default([]),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Service = typeof services.$inferSelect
export type NewService = typeof services.$inferInsert

49
db/schema/spaces.ts Normal file
View File

@@ -0,0 +1,49 @@
import {
pgTable,
bigint,
timestamp,
text,
boolean,
jsonb,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const spaces = pgTable("spaces", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name"),
type: text("type").notNull(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
space_number: text("spaceNumber").notNull(),
parentSpace: bigint("parentSpace", { mode: "number" }).references(
() => spaces.id
),
info_data: jsonb("infoData")
.notNull()
.default({ zip: "", city: "", streetNumber: "" }),
description: text("description"),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Space = typeof spaces.$inferSelect
export type NewSpace = typeof spaces.$inferInsert

View File

@@ -0,0 +1,68 @@
import {
pgTable,
uuid,
bigint,
timestamp,
integer,
text,
boolean,
numeric,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { timesStateEnum } from "./enums"
import {sql} from "drizzle-orm";
export const stafftimeentries = pgTable("staff_time_entries", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id),
userId: uuid("user_id")
.notNull()
.references(() => authUsers.id, { onDelete: "cascade" }),
startedAt: timestamp("started_at", { withTimezone: true }).notNull(),
stoppedAt: timestamp("stopped_at", { withTimezone: true }),
durationMinutes: integer("duration_minutes").generatedAlwaysAs(
sql`CASE
WHEN stopped_at IS NOT NULL
THEN (EXTRACT(epoch FROM (stopped_at - started_at)) / 60)
ELSE NULL
END`
),
type: text("type").default("work"),
description: text("description"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
archived: boolean("archived").notNull().default(false),
updatedBy: uuid("updated_by").references(() => authUsers.id),
source: text("source"),
state: timesStateEnum("state").notNull().default("draft"),
device: uuid("device"),
internalNote: text("internal_note"),
vacationReason: text("vacation_reason"),
vacationDays: numeric("vacation_days", { precision: 5, scale: 2 }),
approvedBy: uuid("approved_by").references(() => authUsers.id),
approvedAt: timestamp("approved_at", { withTimezone: true }),
sickReason: text("sick_reason"),
})
export type StaffTimeEntry = typeof stafftimeentries.$inferSelect
export type NewStaffTimeEntry = typeof stafftimeentries.$inferInsert

View File

@@ -0,0 +1,38 @@
import {
pgTable,
uuid,
bigint,
timestamp,
integer,
text,
} from "drizzle-orm/pg-core"
import { stafftimeentries } from "./staff_time_entries"
import {sql} from "drizzle-orm";
export const staffTimeEntryConnects = pgTable("staff_time_entry_connects", {
id: uuid("id").primaryKey().defaultRandom(),
timeEntryId: uuid("time_entry_id")
.notNull()
.references(() => stafftimeentries.id, { onDelete: "cascade" }),
projectId: bigint("project_id", { mode: "number" }), // referenziert später projects.id
startedAt: timestamp("started_at", { withTimezone: true }).notNull(),
stoppedAt: timestamp("stopped_at", { withTimezone: true }).notNull(),
durationMinutes: integer("duration_minutes").generatedAlwaysAs(
sql`(EXTRACT(epoch FROM (stopped_at - started_at)) / 60)`
),
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
})
export type StaffTimeEntryConnect =
typeof staffTimeEntryConnects.$inferSelect
export type NewStaffTimeEntryConnect =
typeof staffTimeEntryConnects.$inferInsert

View File

@@ -0,0 +1,44 @@
import {
pgTable,
uuid,
timestamp,
bigint,
text,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authProfiles } from "./auth_profiles"
import { stafftimeentries } from "./staff_time_entries"
export const staffZeitstromTimestamps = pgTable("staff_zeitstromtimestamps", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
profile: uuid("profile")
.notNull()
.references(() => authProfiles.id),
key: text("key").notNull(),
intent: text("intent").notNull(),
time: timestamp("time", { withTimezone: true }).notNull(),
staffTimeEntry: uuid("staff_time_entry").references(
() => stafftimeentries.id
),
internalNote: text("internal_note"),
})
export type StaffZeitstromTimestamp =
typeof staffZeitstromTimestamps.$inferSelect
export type NewStaffZeitstromTimestamp =
typeof staffZeitstromTimestamps.$inferInsert

View File

@@ -0,0 +1,69 @@
import {
pgTable,
uuid,
bigint,
integer,
text,
timestamp,
boolean,
doublePrecision,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { customers } from "./customers"
import { vendors } from "./vendors"
import { ownaccounts } from "./ownaccounts"
import { incominginvoices } from "./incominginvoices"
import { createddocuments } from "./createddocuments"
import { bankstatements } from "./bankstatements"
import { accounts } from "./accounts" // Falls noch nicht erstellt → bitte melden!
export const statementallocations = pgTable("statementallocations", {
id: uuid("id").primaryKey().defaultRandom(),
// foreign keys
bs_id: integer("bs_id")
.notNull()
.references(() => bankstatements.id),
cd_id: integer("cd_id").references(() => createddocuments.id),
amount: doublePrecision("amount").notNull().default(0),
ii_id: bigint("ii_id", { mode: "number" }).references(
() => incominginvoices.id
),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
account: bigint("account", { mode: "number" }).references(
() => accounts.id
),
createdAt: timestamp("created_at", {
withTimezone: false,
}).defaultNow(),
ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
description: text("description"),
customer: bigint("customer", { mode: "number" }).references(
() => customers.id
),
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
archived: boolean("archived").notNull().default(false),
})
export type StatementAllocation = typeof statementallocations.$inferSelect
export type NewStatementAllocation =
typeof statementallocations.$inferInsert

51
db/schema/tasks.ts Normal file
View File

@@ -0,0 +1,51 @@
import {
pgTable,
bigint,
text,
timestamp,
boolean,
jsonb,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { customers } from "./customers"
export const tasks = pgTable("tasks", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
description: text("description"),
categorie: text("categorie"),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
// FIXED: user_id statt profile, verweist auf auth_users.id
userId: uuid("user_id").references(() => authUsers.id),
project: bigint("project", { mode: "number" }),
plant: bigint("plant", { mode: "number" }),
customer: bigint("customer", { mode: "number" }).references(
() => customers.id
),
profiles: jsonb("profiles").notNull().default([]),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Task = typeof tasks.$inferSelect
export type NewTask = typeof tasks.$inferInsert

28
db/schema/taxtypes.ts Normal file
View File

@@ -0,0 +1,28 @@
import {
pgTable,
bigint,
timestamp,
text,
uuid,
} from "drizzle-orm/pg-core"
import { authUsers } from "./auth_users"
export const taxTypes = pgTable("taxtypes", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
label: text("label").notNull(),
percentage: bigint("percentage", { mode: "number" }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type TaxType = typeof taxTypes.$inferSelect
export type NewTaxType = typeof taxTypes.$inferInsert

140
db/schema/tenants.ts Normal file
View File

@@ -0,0 +1,140 @@
import {
pgTable,
bigint,
text,
timestamp,
boolean,
jsonb,
integer,
smallint,
date,
uuid,
pgEnum,
} from "drizzle-orm/pg-core"
import { authUsers } from "./auth_users"
import {lockedTenantEnum} from "./enums";
export const tenants = pgTable(
"tenants",
{
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
short: text("short").notNull(),
calendarConfig: jsonb("calendarConfig").default({
eventTypes: [
{ color: "blue", label: "Büro" },
{ color: "yellow", label: "Besprechung" },
{ color: "green", label: "Umsetzung" },
{ color: "red", label: "Vor Ort Termin" },
],
}),
timeConfig: jsonb("timeConfig").notNull().default({}),
tags: jsonb("tags").notNull().default({
products: [],
documents: [],
}),
measures: jsonb("measures")
.notNull()
.default([
{ name: "Netzwerktechnik", short: "NWT" },
{ name: "Elektrotechnik", short: "ELT" },
{ name: "Photovoltaik", short: "PV" },
{ name: "Videüberwachung", short: "VÜA" },
{ name: "Projekt", short: "PRJ" },
{ name: "Smart Home", short: "SHO" },
]),
businessInfo: jsonb("businessInfo").default({
zip: "",
city: "",
name: "",
street: "",
}),
features: jsonb("features").default({
objects: true,
calendar: true,
contacts: true,
projects: true,
vehicles: true,
contracts: true,
inventory: true,
accounting: true,
timeTracking: true,
planningBoard: true,
workingTimeTracking: true,
}),
ownFields: jsonb("ownFields"),
numberRanges: jsonb("numberRanges")
.notNull()
.default({
vendors: { prefix: "", suffix: "", nextNumber: 10000 },
customers: { prefix: "", suffix: "", nextNumber: 10000 },
products: { prefix: "AT-", suffix: "", nextNumber: 1000 },
quotes: { prefix: "AN-", suffix: "", nextNumber: 1000 },
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 },
spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 },
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },
projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 },
costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 },
}),
standardEmailForInvoices: text("standardEmailForInvoices"),
extraModules: jsonb("extraModules").notNull().default([]),
isInTrial: boolean("isInTrial").default(false),
trialEndDate: date("trialEndDate"),
stripeCustomerId: text("stripeCustomerId"),
hasActiveLicense: boolean("hasActiveLicense").notNull().default(false),
userLicenseCount: integer("userLicenseCount")
.notNull()
.default(0),
workstationLicenseCount: integer("workstationLicenseCount")
.notNull()
.default(0),
standardPaymentDays: smallint("standardPaymentDays")
.notNull()
.default(14),
dokuboxEmailAddresses: jsonb("dokuboxEmailAddresses").default([]),
dokuboxkey: uuid("dokuboxkey").notNull().defaultRandom(),
autoPrepareIncomingInvoices: boolean("autoPrepareIncomingInvoices")
.default(true),
portalDomain: text("portalDomain"),
portalConfig: jsonb("portalConfig")
.notNull()
.default({ primayColor: "#69c350" }),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
locked: lockedTenantEnum("locked"),
}
)
export type Tenant = typeof tenants.$inferSelect
export type NewTenant = typeof tenants.$inferInsert

View File

@@ -0,0 +1,44 @@
import {
pgTable,
bigint,
text,
timestamp,
boolean,
jsonb,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { textTemplatePositionsEnum } from "./enums"
export const texttemplates = pgTable("texttemplates", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
name: text("name").notNull(),
text: text("text").notNull(),
documentType: text("documentType").default(""),
default: boolean("default").notNull().default(false),
pos: textTemplatePositionsEnum("pos").notNull(),
archived: boolean("archived").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type TextTemplate = typeof texttemplates.$inferSelect
export type NewTextTemplate = typeof texttemplates.$inferInsert

27
db/schema/units.ts Normal file
View File

@@ -0,0 +1,27 @@
import {
pgTable,
bigint,
timestamp,
text,
} from "drizzle-orm/pg-core"
export const units = pgTable("units", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
single: text("single").notNull(),
multiple: text("multiple"),
short: text("short"),
step: text("step").notNull().default("1"),
})
export type Unit = typeof units.$inferSelect
export type NewUnit = typeof units.$inferInsert

View File

@@ -0,0 +1,53 @@
import {
pgTable,
uuid,
timestamp,
bigint,
boolean,
jsonb,
numeric, pgEnum,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import {credentialTypesEnum} from "./enums";
export const userCredentials = pgTable("user_credentials", {
id: uuid("id").primaryKey().defaultRandom(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
userId: uuid("user_id")
.notNull()
.references(() => authUsers.id),
updatedAt: timestamp("updated_at", { withTimezone: true }),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id),
smtpPort: numeric("smtp_port"),
smtpSsl: boolean("smtp_ssl"),
type: credentialTypesEnum("type").notNull(),
imapPort: numeric("imap_port"),
imapSsl: boolean("imap_ssl"),
emailEncrypted: jsonb("email_encrypted"),
passwordEncrypted: jsonb("password_encrypted"),
smtpHostEncrypted: jsonb("smtp_host_encrypted"),
imapHostEncrypted: jsonb("imap_host_encrypted"),
accessTokenEncrypted: jsonb("access_token_encrypted"),
refreshTokenEncrypted: jsonb("refresh_token_encrypted"),
})
export type UserCredential = typeof userCredentials.$inferSelect
export type NewUserCredential = typeof userCredentials.$inferInsert

57
db/schema/vehicles.ts Normal file
View File

@@ -0,0 +1,57 @@
import {
pgTable,
bigint,
text,
timestamp,
boolean,
jsonb,
uuid,
doublePrecision,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const vehicles = pgTable("vehicles", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
license_plate: text("licensePlate"),
name: text("name"),
type: text("type"),
active: boolean("active").default(true),
// FIXED: driver references auth_users.id
driver: uuid("driver").references(() => authUsers.id),
vin: text("vin"),
tank_size: doublePrecision("tankSize").notNull().default(0),
archived: boolean("archived").notNull().default(false),
build_year: text("buildYear"),
towing_capacity: bigint("towingCapacity", { mode: "number" }),
power_in_kw: bigint("powerInKW", { mode: "number" }),
color: text("color"),
profiles: jsonb("profiles").notNull().default([]),
updated_at: timestamp("updated_at", { withTimezone: true }),
updated_by: uuid("updated_by").references(() => authUsers.id),
})
export type Vehicle = typeof vehicles.$inferSelect
export type NewVehicle = typeof vehicles.$inferInsert

45
db/schema/vendors.ts Normal file
View File

@@ -0,0 +1,45 @@
import {
pgTable,
bigint,
text,
timestamp,
boolean,
jsonb,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
export const vendors = pgTable("vendors", {
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedByDefaultAsIdentity(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
name: text("name").notNull(),
vendorNumber: text("vendorNumber").notNull(),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),
infoData: jsonb("infoData").notNull().default({}),
notes: text("notes"),
hasSEPA: boolean("hasSEPA").notNull().default(false),
profiles: jsonb("profiles").notNull().default([]),
archived: boolean("archived").notNull().default(false),
defaultPaymentMethod: text("defaultPaymentMethod"),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
})
export type Vendor = typeof vendors.$inferSelect
export type NewVendor = typeof vendors.$inferInsert

11
drizzle.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "drizzle-kit"
import {secrets} from "./src/utils/secrets";
export default defineConfig({
dialect: "postgresql",
schema: "./db/schema",
out: "./db/migrations",
dbCredentials: {
url: secrets.DATABASE_URL || process.env.DATABASE_URL,
},
})

9635
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts"
},
"repository": {
"type": "git",
@@ -24,26 +25,35 @@
"@fastify/swagger": "^9.5.1",
"@fastify/swagger-ui": "^5.2.3",
"@infisical/sdk": "^4.0.6",
"@mmote/niimbluelib": "^0.0.1-alpha.29",
"@prisma/client": "^6.15.0",
"@supabase/supabase-js": "^2.56.1",
"@zip.js/zip.js": "^2.7.73",
"archiver": "^7.0.1",
"axios": "^1.12.1",
"bcrypt": "^6.0.0",
"bwip-js": "^4.8.0",
"crypto": "^1.0.1",
"dayjs": "^1.11.18",
"drizzle-orm": "^0.45.0",
"fastify": "^5.5.0",
"fastify-plugin": "^5.0.1",
"imapflow": "^1.1.1",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.6",
"pdf-lib": "^1.17.1",
"xmlbuilder": "^15.1.1"
"pg": "^8.16.3",
"pngjs": "^7.0.0",
"sharp": "^0.34.5",
"xmlbuilder": "^15.1.1",
"zpl-image": "^0.2.0",
"zpl-renderer-js": "^2.0.2"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.3.0",
"drizzle-kit": "^0.31.8",
"prisma": "^6.15.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2"

View File

@@ -0,0 +1,16 @@
import fs from "node:fs"
import path from "node:path"
const schemaDir = path.resolve("db/schema")
const indexFile = path.join(schemaDir, "index.ts")
const files = fs
.readdirSync(schemaDir)
.filter((f) => f.endsWith(".ts") && f !== "index.ts")
const exportsToWrite = files
.map((f) => `export * from "./${f.replace(".ts", "")}"`)
.join("\n")
fs.writeFileSync(indexFile, exportsToWrite)
console.log("✓ schema/index.ts generated")

View File

@@ -12,12 +12,11 @@ import authPlugin from "./plugins/auth";
import adminRoutes from "./routes/admin";
import corsPlugin from "./plugins/cors";
import queryConfigPlugin from "./plugins/queryconfig";
import resourceRoutes from "./routes/resources";
import dbPlugin from "./plugins/db";
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"
@@ -29,6 +28,9 @@ import notificationsRoutes from "./routes/notifications";
import staffTimeRoutes from "./routes/staff/time";
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
//Resources
import resourceRoutes from "./routes/resources/main";
//M2M
import authM2m from "./plugins/auth.m2m";
import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email";
@@ -57,6 +59,7 @@ async function main() {
await app.register(supabasePlugin);
await app.register(tenantPlugin);
await app.register(dayjsPlugin);
await app.register(dbPlugin);
app.addHook('preHandler', (req, reply, done) => {
console.log(req.method)
@@ -97,11 +100,9 @@ async function main() {
await subApp.register(meRoutes);
await subApp.register(tenantRoutes);
await subApp.register(adminRoutes);
await subApp.register(resourceRoutes);
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);
@@ -112,8 +113,19 @@ async function main() {
await subApp.register(staffTimeRoutes);
await subApp.register(staffTimeConnectRoutes);
await subApp.register(resourceRoutes);
},{prefix: "/api"})
app.ready(async () => {
try {
const result = await app.db.execute("SELECT NOW()");
console.log("✓ DB connection OK: " + JSON.stringify(result.rows[0]));
} catch (err) {
console.log("❌ DB connection failed:", err);
}
});
// Start
try {

View File

@@ -72,8 +72,8 @@ export async function generateTimesEvaluation(
for (const t of times) {
const minutes = calcMinutes(t.started_at, t.stopped_at)
if(["submitted","approved"].includes(t.state))sumWorkingMinutesEingereicht += minutes
if (t.state === "approved") sumWorkingMinutesApproved += minutes
if(["submitted","approved"].includes(t.state) && t.type === "work")sumWorkingMinutesEingereicht += minutes
if (t.state === "approved" && t.type === "work") sumWorkingMinutesApproved += minutes
}
// 🎉 Feiertagsausgleich
@@ -90,16 +90,37 @@ export async function generateTimesEvaluation(
}
// 🏖️ Urlaub & Krankheit (über Typ)
const sumWorkingMinutesVacationDays = times
.filter((t) => t.type === "vacation")
.reduce((sum, t) => sum + calcMinutes(t.started_at, t.stopped_at), 0)
let sumWorkingMinutesVacationDays = 0
let sumVacationDays = 0
times
.filter((t) => t.type === "vacation" && t.state === "approved")
.forEach((time) => {
const days = server.dayjs(time.stopped_at).diff(server.dayjs(time.startet_at), "day") + 1;
const sumWorkingMinutesSickDays = times
.filter((t) => t.type === "sick")
.reduce((sum, t) => sum + calcMinutes(t.started_at, t.stopped_at), 0)
for(let i = 0; i < days; i++) {
const weekday = server.dayjs(time.started_at).add(i,"day").day()
const hours = profile.weekly_regular_working_hours?.[weekday] || 0
sumWorkingMinutesVacationDays += hours * 60
}
sumVacationDays += days
})
const sumVacationDays = times.filter((t) => t.type === "vacation").length
const sumSickDays = times.filter((t) => t.type === "sick").length
let sumWorkingMinutesSickDays = 0
let sumSickDays = 0
times
.filter((t) => t.type === "sick" && t.state === "approved")
.forEach((time) => {
const days = server.dayjs(time.stopped_at).diff(server.dayjs(time.startet_at), "day") + 1;
for(let i = 0; i < days; i++) {
const weekday = server.dayjs(time.started_at).add(i,"day").day()
const hours = profile.weekly_regular_working_hours?.[weekday] || 0
sumWorkingMinutesSickDays += hours * 60
}
sumSickDays += days
})
// 💰 Salden
const saldo =

View File

@@ -6,7 +6,10 @@ export default fp(async (server: FastifyInstance) => {
await server.register(cors, {
origin: [
"http://localhost:3000", // dein Nuxt-Frontend
"http://localhost:3001", // dein Nuxt-Frontend
"http://127.0.0.1:3000", // dein Nuxt-Frontend
"http://192.168.1.227:3001", // dein Nuxt-Frontend
"http://192.168.1.227:3000", // dein Nuxt-Frontend
"https://beta.fedeo.de", // dein Nuxt-Frontend
"https://app.fedeo.de", // dein Nuxt-Frontend
"capacitor://localhost", // dein Nuxt-Frontend

34
src/plugins/db.ts Normal file
View File

@@ -0,0 +1,34 @@
import fp from "fastify-plugin"
import {drizzle, NodePgDatabase} from "drizzle-orm/node-postgres"
import { Pool } from "pg"
import * as schema from "../../db/schema"
export default fp(async (server, opts) => {
const pool = new Pool({
host: "db-001.netbird.cloud",
port: Number(process.env.DB_PORT || 5432),
user: "postgres",
password: "wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu",
database: "fedeo",
ssl: process.env.DB_DISABLE_SSL === "true" ? false : undefined,
})
// Drizzle instance
const db = drizzle(pool, { schema })
// Dekorieren -> überall server.db
server.decorate("db", db)
// Graceful Shutdown
server.addHook("onClose", async () => {
await pool.end()
})
server.log.info("Drizzle database connected")
})
declare module "fastify" {
interface FastifyInstance {
db:NodePgDatabase<typeof schema>
}
}

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,13 +1,21 @@
import { FastifyInstance } from "fastify";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { generateRandomPassword, hashPassword } from "../../utils/password"
import { sendMail } from "../../utils/mailer"
import {secrets} from "../../utils/secrets";
import { generateRandomPassword, hashPassword } from "../../utils/password";
import { sendMail } from "../../utils/mailer";
import { secrets } from "../../utils/secrets";
import { authUsers } from "../../../db/schema";
import { authTenantUsers } from "../../../db/schema";
import { tenants } from "../../../db/schema";
import { eq, and } from "drizzle-orm";
export default async function authRoutes(server: FastifyInstance) {
// Registrierung
server.post("/auth/register",{
// -----------------------------------------------------
// REGISTER
// -----------------------------------------------------
server.post("/auth/register", {
schema: {
tags: ["Auth"],
summary: "Register User",
@@ -19,43 +27,31 @@ export default async function authRoutes(server: FastifyInstance) {
password: { type: "string" },
},
},
response: {
200: {
type: "object",
properties: {
user: { type: "object" },
},
},
},
},
}, async (req, reply) => {
const body = req.body as { email: string; password: string };
if (!body.email || !body.password) {
// @ts-ignore
return reply.code(400).send({ error: "Email and password required" });
}
// Passwort hashen
const passwordHash = await bcrypt.hash(body.password, 10);
// User speichern
const { data, error } = await server.supabase
.from("auth_users")
.insert({ email: body.email, password_hash: passwordHash })
.select("id, email")
.single();
const [user] = await server.db
.insert(authUsers)
.values({
email: body.email.toLowerCase(),
passwordHash,
})
.returning({
id: authUsers.id,
email: authUsers.email,
});
if (error) {
// @ts-ignore
return reply.code(400).send({ error: error.message });
}
return { user: data };
return { user };
});
// Login
server.post("/auth/login",{
// -----------------------------------------------------
// LOGIN
// -----------------------------------------------------
server.post("/auth/login", {
schema: {
tags: ["Auth"],
summary: "Login User",
@@ -67,103 +63,110 @@ export default async function authRoutes(server: FastifyInstance) {
password: { type: "string" },
},
},
response: {
200: {
type: "object",
properties: {
token: { type: "string" },
},
},
},
},
}, async (req, reply) => {
const body = req.body as { email: string; password: string };
if (!body.email || !body.password) {
// @ts-ignore
return reply.code(400).send({ error: "Email and password required" });
}
let user: any = null;
/**
* Wenn das Tenant Objekt verfügbar ist, befindet sich das Backend im Single Tenant Modus.
* Es werden nur Benutzer zugelassen, welche auschließlich diesem Tenant angehören.
* Das zeigt sich über das im User gesetzte Tenant Feld
*
* */
let user = null
let error = null
if(req.tenant) {
// User finden
const { data, error } = await server.supabase
.from("auth_users")
.select("*, tenants!auth_tenant_users(*)")
.eq("email", body.email)
// -------------------------------
// SINGLE TENANT MODE
// -------------------------------
/* if (req.tenant) {
const tenantId = req.tenant.id;
// @ts-ignore
user = (data || []).find(i => i.tenants.find(x => x.id === req.tenant.id))
if(error) {
// @ts-ignore
return reply.code(500).send({ error: "Internal Server Error" });
}
} else {
// User finden
const { data, error } = await server.supabase
.from("auth_users")
.select("*")
.eq("email", body.email)
.single();
user = data
if(error) {
// @ts-ignore
return reply.code(500).send({ error: "Internal Server Error" });
}
}
if(!user) {
// @ts-ignore
return reply.code(401).send({ error: "Invalid credentials" });
} else {
const valid = await bcrypt.compare(body.password, user.password_hash);
if (!valid) {
// @ts-ignore
return reply.code(401).send({ error: "Invalid credentials" });
} else {
const token = jwt.sign(
{ user_id: user.id, email: user.email, tenant_id: req.tenant?.id ? req.tenant.id : null },
secrets.JWT_SECRET!,
{ expiresIn: "6h" }
);
reply.setCookie("token", token, {
path: "/",
httpOnly: true,
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
secure: process.env.NODE_ENV === "production", // lokal: false, prod: true
maxAge: 60 * 60 * 3, // 3 Stunden
const result = await server.db
.select({
user: authUsers,
})
.from(authUsers)
.innerJoin(
authTenantUsers,
eq(authTenantUsers.userId, authUsers.id)
)
.innerJoin(
tenants,
eq(authTenantUsers.tenantId, tenants.id)
)
.where(and(
eq(authUsers.email, body.email.toLowerCase()),
eq(authTenantUsers.tenantId, tenantId)
));
return { token };
if (result.length === 0) {
return reply.code(401).send({ error: "Invalid credentials" });
}
user = result[0].user;
// -------------------------------
// MULTI TENANT MODE
// -------------------------------
} else {*/
const [found] = await server.db
.select()
.from(authUsers)
.where(eq(authUsers.email, body.email.toLowerCase()))
.limit(1);
if (!found) {
return reply.code(401).send({ error: "Invalid credentials" });
}
user = found;
/*}*/
// Passwort prüfen
const valid = await bcrypt.compare(body.password, user.passwordHash);
if (!valid) {
return reply.code(401).send({ error: "Invalid credentials" });
}
const token = jwt.sign(
{
user_id: user.id,
email: user.email,
tenant_id: req.tenant?.id ?? null,
},
secrets.JWT_SECRET!,
{ expiresIn: "6h" }
);
reply.setCookie("token", token, {
path: "/",
httpOnly: true,
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 3,
});
return { token };
});
// -----------------------------------------------------
// LOGOUT
// -----------------------------------------------------
server.post("/auth/logout", {
schema: {
tags: ["Auth"],
summary: "Logout User (löscht Cookie)"
},
summary: "Logout User"
}
}, async (req, reply) => {
reply.clearCookie("token", {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
})
});
return { success: true }
})
return { success: true };
});
// -----------------------------------------------------
// PASSWORD RESET
// -----------------------------------------------------
server.post("/auth/password/reset", {
schema: {
tags: ["Auth"],
@@ -177,43 +180,45 @@ export default async function authRoutes(server: FastifyInstance) {
}
}
}, async (req, reply) => {
const { email } = req.body as { email: string }
const { email } = req.body as { email: string };
// User finden
const { data: user, error } = await server.supabase
.from("auth_users")
.select("id, email")
.eq("email", email)
.single()
const [user] = await server.db
.select({
id: authUsers.id,
email: authUsers.email,
})
.from(authUsers)
.where(eq(authUsers.email, email.toLowerCase()))
.limit(1);
if (error || !user) {
return reply.code(404).send({ error: "User not found" })
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
// Neues Passwort generieren
const plainPassword = generateRandomPassword()
const passwordHash = await hashPassword(plainPassword)
const plainPassword = generateRandomPassword();
const passwordHash = await hashPassword(plainPassword);
// In DB updaten
const { error: updateError } = await server.supabase
.from("auth_users")
.update({ password_hash: passwordHash, must_change_password: true })
.eq("id", user.id)
if (updateError) {
return reply.code(500).send({ error: "Could not update password" })
}
await server.db
.update(authUsers)
.set({
passwordHash,
// @ts-ignore
mustChangePassword: true,
})
.where(eq(authUsers.id, user.id));
// Mail verschicken
await sendMail(
user.email,
"FEDEO | Dein neues Passwort",
`<p>Hallo,</p>
<p>dein Passwort wurde zurückgesetzt.</p>
<p><strong>Neues Passwort:</strong> ${plainPassword}</p>
<p>Bitte ändere es nach dem Login umgehend.</p>`
)
`
<p>Hallo,</p>
<p>Dein Passwort wurde zurückgesetzt.</p>
<p><strong>Neues Passwort:</strong> ${plainPassword}</p>
<p>Bitte ändere es nach dem Login umgehend.</p>
`
);
return { success: true }
})
}
return { success: true };
});
}

View File

@@ -1,79 +1,140 @@
import { FastifyInstance } from "fastify";
import { FastifyInstance } from "fastify"
import {
authUsers,
authTenantUsers,
tenants,
authProfiles,
authUserRoles,
authRoles,
authRolePermissions,
} from "../../../db/schema"
import { eq, and, or, isNull } from "drizzle-orm"
export default async function meRoutes(server: FastifyInstance) {
server.get("/me", async (req, reply) => {
const authUser = req.user // kommt aus JWT (user_id + tenant_id)
try {
const authUser = req.user
if (!authUser) {
return reply.code(401).send({ error: "Unauthorized" })
}
if (!authUser) {
return reply.code(401).send({ error: "Unauthorized" })
}
const user_id = req.user.user_id
const tenant_id = req.user.tenant_id
const userId = authUser.user_id
const activeTenantId = authUser.tenant_id
// 1. User laden
const { data: user, error: userError } = await server.supabase
.from("auth_users")
.select("id, email, created_at, must_change_password")
.eq("id", authUser.user_id)
.single()
// ----------------------------------------------------
// 1) USER LADEN
// ----------------------------------------------------
const userResult = await server.db
.select({
id: authUsers.id,
email: authUsers.email,
created_at: authUsers.created_at,
must_change_password: authUsers.must_change_password,
})
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
if (userError || !user) {
return reply.code(401).send({ error: "User not found" })
}
const user = userResult[0]
// 2. Tenants laden (alle Tenants des Users)
const { data: tenantLinks, error: tenantLinksError } = await server.supabase
.from("auth_users")
.select(`*, tenants!auth_tenant_users ( id, name,short, locked, extraModules, businessInfo, numberRanges, dokuboxkey, standardEmailForInvoices, standardPaymentDays )`)
.eq("id", authUser.user_id)
.single();
if (!user) {
return reply.code(401).send({ error: "User not found" })
}
if (tenantLinksError) {
// ----------------------------------------------------
// 2) TENANTS LADEN
// ----------------------------------------------------
const tenantRows = await server.db
.select({
id: tenants.id,
name: tenants.name,
short: tenants.short,
locked: tenants.locked,
extraModules: tenants.extraModules,
businessInfo: tenants.businessInfo,
numberRanges: tenants.numberRanges,
dokuboxkey: tenants.dokuboxkey,
standardEmailForInvoices: tenants.standardEmailForInvoices,
standardPaymentDays: tenants.standardPaymentDays,
})
.from(authTenantUsers)
.innerJoin(tenants, eq(authTenantUsers.tenant_id, tenants.id))
.where(eq(authTenantUsers.user_id, userId))
console.log(tenantLinksError)
const tenantList = tenantRows ?? []
return reply.code(401).send({ error: "Tenant Error" })
}
// ----------------------------------------------------
// 3) ACTIVE TENANT
// ----------------------------------------------------
const activeTenant = activeTenantId
const tenants = tenantLinks?.tenants
// ----------------------------------------------------
// 4) PROFIL LADEN
// ----------------------------------------------------
let profile = null
if (activeTenantId) {
const profileResult = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.user_id, userId),
eq(authProfiles.tenant_id, activeTenantId)
)
)
.limit(1)
// 3. Aktiven Tenant bestimmen
const activeTenant = authUser.tenant_id /*|| tenants[0].id*/
profile = profileResult?.[0] ?? null
}
// 4. Profil für den aktiven Tenant laden
let profile = null
if (activeTenant) {
const { data: profileData } = await server.supabase
.from("auth_profiles")
.select("*")
.eq("user_id", user.id)
.eq("tenant_id", activeTenant)
.single()
// ----------------------------------------------------
// 5) PERMISSIONS — RPC ERSETZT
// ----------------------------------------------------
const permissionRows =
(await server.db
.select({
permission: authRolePermissions.permission,
})
.from(authUserRoles)
.innerJoin(
authRoles,
and(
eq(authRoles.id, authUserRoles.role_id),
or(
isNull(authRoles.tenant_id), // globale Rolle
eq(authRoles.tenant_id, activeTenantId) // tenant-spezifische Rolle
)
)
)
.innerJoin(
authRolePermissions,
eq(authRolePermissions.role_id, authRoles.id)
)
.where(
and(
eq(authUserRoles.user_id, userId),
eq(authUserRoles.tenant_id, activeTenantId)
)
)) ?? []
profile = profileData
}
const permissions = Array.from(
new Set(permissionRows.map((p) => p.permission))
)
// 5. Permissions laden (über Funktion)
const { data: permissionsData, error: permissionsError } = await server.supabase
.rpc("auth_get_user_permissions", {
uid: user.id,
tid: activeTenant || null
})
if(permissionsError) {
console.log(permissionsError)
}
const permissions = permissionsData.map(i => i.permission) || []
// 6. Response zurückgeben
return {
user,
tenants,
activeTenant,
profile,
permissions
// ----------------------------------------------------
// RESPONSE
// ----------------------------------------------------
return {
user,
tenants: tenantList,
activeTenant,
profile,
permissions,
}
} catch (err: any) {
console.error("ERROR in /me route:", err)
return reply.code(500).send({ error: "Internal server error" })
}
})
}
}

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,175 @@ 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)
//@ts-ignore
.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,
}
//@ts-ignore
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 +213,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

@@ -8,13 +8,15 @@ import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
import dayjs from "dayjs";
import {randomUUID} from "node:crypto";
import {secrets} from "../utils/secrets";
import {createSEPAExport} from "../utils/export/sepa";
const createExport = async (server:FastifyInstance,req:any,startDate,endDate,beraternr,mandantennr) => {
const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDate,beraternr,mandantennr) => {
console.log(startDate,endDate,beraternr,mandantennr)
// 1) ZIP erzeugen
const buffer = await buildExportZip(server,req.user.tenant_id, startDate, endDate, beraternr, mandantennr)
console.log("ZIP created")
console.log(buffer)
// 2) Dateiname & Key festlegen
const fileKey = `${req.user.tenant_id}/exports/Export_${dayjs(startDate).format("YYYY-MM-DD")}_${dayjs(endDate).format("YYYY-MM-DD")}_${randomUUID()}.zip`
@@ -80,7 +82,27 @@ export default async function exportRoutes(server: FastifyInstance) {
setImmediate(async () => {
try {
await createExport(server,req,start_date,end_date,beraternr,mandantennr)
await createDatevExport(server,req,start_date,end_date,beraternr,mandantennr)
console.log("Job done ✅")
} catch (err) {
console.error("Job failed ❌", err)
}
})
})
server.post("/exports/sepa", async (req, reply) => {
const { idsToExport } = req.body as {
idsToExport: Array<number>
}
reply.send({success:true})
setImmediate(async () => {
try {
await createSEPAExport(server, idsToExport, req.user.tenant_id)
console.log("Job done ✅")
} catch (err) {
console.error("Job failed ❌", err)

View File

@@ -1,184 +1,202 @@
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
//@ts-ignore
.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 }
//@ts-ignore
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 +204,90 @@ 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) && f.path)
console.log(filteredFiles.filter(i => !i.path))
console.log(selected)
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: selected[0].path! }),
{ expiresIn: 900 }
)
console.log(url)
console.log(selected.filter(f => !f.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

@@ -1,7 +1,9 @@
import { FastifyInstance } from "fastify";
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
import {useNextNumberRangeNumber} from "../utils/functions";
//import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
import dayjs from "dayjs";
//import { ready as zplReady } from 'zpl-renderer-js'
//import { renderZPL } from "zpl-image";
import customParseFormat from "dayjs/plugin/customParseFormat.js";
import isoWeek from "dayjs/plugin/isoWeek.js";
@@ -11,6 +13,9 @@ 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";
import {useNextNumberRangeNumber} from "../utils/functions";
dayjs.extend(customParseFormat)
dayjs.extend(isoWeek)
dayjs.extend(isBetween)
@@ -48,8 +53,6 @@ export default async function functionRoutes(server: FastifyInstance) {
)
}
console.log(pdf)
return pdf // Fastify wandelt automatisch in JSON
} catch (err) {
console.log(err)
@@ -102,7 +105,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 +118,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' })
@@ -141,6 +148,7 @@ export default async function functionRoutes(server: FastifyInstance) {
return reply.send({
...data,
//@ts-ignore
state_code: bundeslaender.find(i => i.name === data.countryName)
})
} catch (err) {
@@ -149,4 +157,43 @@ export default async function functionRoutes(server: FastifyInstance) {
}
})
/*server.post('/print/zpl/preview', async (req, reply) => {
const { zpl, widthMm = 50, heightMm = 30, dpmm = 8, asBase64 = false } = req.body as {zpl:string,widthMm:number,heightMm:number,dpmm:number,asBase64:string}
console.log(widthMm,heightMm,dpmm)
if (!zpl) {
return reply.code(400).send({ error: 'Missing ZPL string' })
}
try {
// 1⃣ Renderer initialisieren
const { api } = await zplReady
// 2⃣ Rendern (liefert base64-encoded PNG)
const base64Png = await api.zplToBase64Async(zpl, widthMm, heightMm, dpmm)
return await encodeBase64ToNiimbot(base64Png, 'top')
} catch (err) {
console.error('[ZPL Preview Error]', err)
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
}
})
server.post('/print/label', async (req, reply) => {
const { context, width=584, heigth=354 } = req.body as {context:any,width:number,heigth:number}
try {
const base64 = await generateLabel(context,width,heigth)
return {
encoded: await encodeBase64ToNiimbot(base64, 'top'),
base64: base64
}
} catch (err) {
console.error('[ZPL Preview Error]', err)
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
}
})*/
}

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

@@ -1,829 +0,0 @@
import {FastifyInstance} from "fastify";
import {insertHistoryItem} from "../utils/history"
import {diffObjects} from "../utils/diff";
import {sortData} from "../utils/sort";
import {useNextNumberRangeNumber} from "../utils/functions";
import {compareValues, getNestedValue} from "../utils/helpers";
const dataTypes: any[] = {
// @ts-ignore
tasks: {
isArchivable: true,
label: "Aufgaben",
labelSingle: "Aufgabe",
isStandardEntity: true,
redirect: true,
historyItemHolder: "task",
supabaseSelectWithInformation: "*, plant(*), project(*), customer(*)",
inputColumns: [
"Allgemeines",
"Zuweisungen"
],
showTabs: [{label: 'Informationen'}]
},
customers: {
isArchivable: true,
label: "Kunden",
labelSingle: "Kunde",
isStandardEntity: true,
redirect: true,
numberRangeHolder: "customerNumber",
historyItemHolder: "customer",
supabaseSortColumn: "customerNumber",
supabaseSelectWithInformation: "*, projects(*), plants(*), contracts(*), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*)",
inputColumns: [
"Allgemeines",
"Kontaktdaten"
],
showTabs: [{label: 'Informationen'}, {label: 'Ansprechpartner'}, {label: 'Dateien'}, {label: 'Ausgangsbelege'}, {label: 'Projekte'}, {label: 'Objekte'}, {label: 'Termine'}, {label: 'Verträge'}]
},
contacts: {
isArchivable: true,
label: "Kontakte",
labelSingle: "Kontakt",
isStandardEntity: true,
redirect: true,
historyItemHolder: "contact",
supabaseSelectWithInformation: "*, customer(*), vendor(*)",
showTabs: [
{
label: 'Informationen',
}
]
},
contracts: {
isArchivable: true,
label: "Verträge",
labelSingle: "Vertrag",
isStandardEntity: true,
numberRangeHolder: "contractNumber",
redirect: true,
inputColumns: [
"Allgemeines",
"Abrechnung"
],
supabaseSelectWithInformation: "*, customer(*), files(*)",
showTabs: [{label: 'Informationen'}, {label: 'Dateien'}]
},
absencerequests: {
isArchivable: true,
label: "Abwesenheiten",
labelSingle: "Abwesenheit",
isStandardEntity: true,
supabaseSortColumn: "startDate",
supabaseSortAscending: false,
supabaseSelectWithInformation: "*",
historyItemHolder: "absencerequest",
redirect: true,
showTabs: [{label: 'Informationen'}]
},
plants: {
isArchivable: true,
label: "Objekte",
labelSingle: "Objekt",
isStandardEntity: true,
redirect: true,
historyItemHolder: "plant",
supabaseSelectWithInformation: "*, customer(id,name)",
showTabs: [
{
label: "Informationen"
}, {
label: "Projekte"
}, {
label: "Aufgaben"
}, {
label: "Dateien"
}]
},
products: {
isArchivable: true,
label: "Artikel",
labelSingle: "Artikel",
isStandardEntity: true,
redirect: true,
supabaseSelectWithInformation: "*, unit(name)",
historyItemHolder: "product",
showTabs: [
{
label: "Informationen"
}
]
},
projects: {
isArchivable: true,
label: "Projekte",
labelSingle: "Projekt",
isStandardEntity: true,
redirect: true,
historyItemHolder: "project",
numberRangeHolder: "projectNumber",
supabaseSelectWithInformation: "*, customer(id,name), plant(id,name), projecttype(name, id), tasks(*, project(id,name), customer(id,name), plant(id,name)), files(*), createddocuments(*, statementallocations(*)), events(*), times(*, profile(id, fullName))",
supabaseSortColumn: "projectNumber",
showTabs: [
{
key: "information",
label: "Informationen"
},
{
key: "phases",
label: "Phasen"
}, {
key: "tasks",
label: "Aufgaben"
}, {
key: "files",
label: "Dateien"
}, {
label: "Zeiten"
}, {
label: "Ausgangsbelege"
}, {
label: "Termine"
}/*,{
key: "timetracking",
label: "Zeiterfassung"
},{
key: "events",
label: "Termine"
},{
key: "material",
label: "Material"
}*/]
},
vehicles: {
isArchivable: true,
label: "Fahrzeuge",
labelSingle: "Fahrzeug",
isStandardEntity: true,
redirect: true,
historyItemHolder: "vehicle",
supabaseSelectWithInformation: "*, checks(*), files(*)",
showTabs: [
{
label: 'Informationen',
}, {
label: 'Dateien',
}, {
label: 'Überprüfungen',
}
]
},
vendors: {
isArchivable: true,
label: "Lieferanten",
labelSingle: "Lieferant",
isStandardEntity: true,
redirect: true,
numberRangeHolder: "vendorNumber",
historyItemHolder: "vendor",
supabaseSortColumn: "vendorNumber",
supabaseSelectWithInformation: "*, contacts(*)",
showTabs: [
{
label: 'Informationen',
}, {
label: 'Ansprechpartner',
}, {
label: 'Dateien',
}
]
},
messages: {
label: "Nachrichten",
labelSingle: "Nachricht"
},
spaces: {
isArchivable: true,
label: "Lagerplätze",
labelSingle: "Lagerplatz",
isStandardEntity: true,
supabaseSelectWithInformation: "*, files(*)",
supabaseSortColumn: "spaceNumber",
redirect: true,
numberRangeHolder: "spaceNumber",
historyItemHolder: "space",
inputColumns: [
"Allgemeines",
"Ort"
],
showTabs: [
{
label: 'Informationen',
}, {
label: 'Dateien',
}, {label: 'Inventarartikel'}
]
},
users: {
label: "Benutzer",
labelSingle: "Benutzer"
},
createddocuments: {
isArchivable: true,
label: "Dokumente",
labelSingle: "Dokument",
supabaseSelectWithInformation: "*, files(*), statementallocations(*)",
},
files: {
isArchivable: true,
label: "Dateien",
labelSingle: "Datei",
supabaseSelectWithInformation: "*",
},
folders: {
isArchivable: true,
label: "Ordner",
labelSingle: "Ordner",
supabaseSelectWithInformation: "*",
},
incominginvoices: {
label: "Eingangsrechnungen",
labelSingle: "Eingangsrechnung",
redirect: true
},
inventoryitems: {
isArchivable: true,
label: "Inventarartikel",
labelSingle: "Inventarartikel",
isStandardEntity: true,
supabaseSelectWithInformation: "*, files(*), vendor(id,name), currentSpace(id,name)",
redirect: true,
numberRangeHolder: "articleNumber",
historyItemHolder: "inventoryitem",
inputColumns: [
"Allgemeines",
"Anschaffung"
],
showTabs: [
{
label: 'Informationen',
}, {
label: 'Dateien',
}
]
},
inventoryitemgroups: {
isArchivable: true,
label: "Inventarartikelgruppen",
labelSingle: "Inventarartikelgruppe",
isStandardEntity: true,
historyItemHolder: "inventoryitemgroup",
supabaseSelectWithInformation: "*",
redirect: true,
showTabs: [
{
label: 'Informationen',
}
]
},
documentboxes: {
isArchivable: true,
label: "Dokumentenboxen",
labelSingle: "Dokumentenbox",
isStandardEntity: true,
supabaseSelectWithInformation: "*, space(*), files(*)",
redirect: true,
numberRangeHolder: "key",
historyItemHolder: "documentbox",
inputColumns: [
"Allgemeines",
],
showTabs: [
{
label: 'Informationen',
}, {
label: 'Dateien',
}
]
},
services: {
isArchivable: true,
label: "Leistungen",
labelSingle: "Leistung",
isStandardEntity: true,
redirect: true,
supabaseSelectWithInformation: "*, unit(*)",
historyItemHolder: "service",
showTabs: [
{
label: 'Informationen',
}
]
},
hourrates: {
isArchivable: true,
label: "Stundensätze",
labelSingle: "Stundensatz",
isStandardEntity: true,
redirect: true,
supabaseSelectWithInformation: "*",
historyItemHolder: "hourrate",
showTabs: [
{
label: 'Informationen',
}
]
},
events: {
isArchivable: true,
label: "Termine",
labelSingle: "Termin",
isStandardEntity: true,
historyItemHolder: "event",
supabaseSelectWithInformation: "*, project(id,name), customer(*)",
redirect: true,
showTabs: [
{
label: 'Informationen',
}
]
},
profiles: {
label: "Mitarbeiter",
labelSingle: "Mitarbeiter",
redirect: true,
historyItemHolder: "profile"
},
workingtimes: {
isArchivable: true,
label: "Anwesenheiten",
labelSingle: "Anwesenheit",
redirect: true,
redirectToList: true
},
texttemplates: {
isArchivable: true,
label: "Textvorlagen",
labelSingle: "Textvorlage"
},
bankstatements: {
isArchivable: true,
label: "Kontobewegungen",
labelSingle: "Kontobewegung",
historyItemHolder: "bankStatement",
},
statementallocations: {
label: "Bankzuweisungen",
labelSingle: "Bankzuweisung"
},
productcategories: {
isArchivable: true,
label: "Artikelkategorien",
labelSingle: "Artikelkategorie",
isStandardEntity: true,
redirect: true,
supabaseSelectWithInformation: "*",
showTabs: [
{
label: 'Informationen',
}
]
},
servicecategories: {
isArchivable: true,
label: "Leistungskategorien",
labelSingle: "Leistungskategorie",
isStandardEntity: true,
redirect: true,
supabaseSelectWithInformation: "*",
showTabs: [
{
label: 'Informationen',
}
]
},
trackingtrips: {
label: "Fahrten",
labelSingle: "Fahrt",
redirect: true,
historyItemHolder: "trackingtrip",
},
projecttypes: {
isArchivable: true,
label: "Projekttypen",
labelSingle: "Projekttyp",
redirect: true,
historyItemHolder: "projecttype"
},
checks: {
isArchivable: true,
label: "Überprüfungen",
labelSingle: "Überprüfung",
isStandardEntity: true,
supabaseSelectWithInformation: "*, vehicle(id,licensePlate), profile(id, fullName), inventoryitem(name), files(*)",
redirect: true,
historyItemHolder: "check",
showTabs: [
{
label: 'Informationen',
}, {label: 'Dateien'}, {label: 'Ausführungen'}]
},
roles: {
label: "Rollen",
labelSingle: "Rolle",
redirect: true,
historyItemHolder: "role",
filters: [],
templateColumns: [
{
key: "name",
label: "Name"
}, {
key: "description",
label: "Beschreibung"
}
]
},
costcentres: {
isArchivable: true,
label: "Kostenstellen",
labelSingle: "Kostenstelle",
isStandardEntity: true,
redirect: true,
numberRangeHolder: "number",
historyItemHolder: "costcentre",
supabaseSortColumn: "number",
supabaseSelectWithInformation: "*, project(*), vehicle(*), inventoryitem(*)",
showTabs: [{label: 'Informationen'}, {label: 'Auswertung Kostenstelle'}]
},
ownaccounts: {
isArchivable: true,
label: "zusätzliche Buchungskonten",
labelSingle: "zusätzliches Buchungskonto",
isStandardEntity: true,
redirect: true,
historyItemHolder: "ownaccount",
supabaseSortColumn: "number",
supabaseSelectWithInformation: "*, statementallocations(*, bs_id(*))",
showTabs: [{label: 'Informationen'}, {label: 'Buchungen'}]
},
tickets: {
isArchivable: true,
label: "Tickets",
labelSingle: "Ticket",
},
ticketmessages: {
isArchivable: true,
label: "Nachrichten",
labelSingle: "Nachricht",
},
}
export default async function resourceRoutes(server: FastifyInstance) {
//Liste
server.get("/resource/:resource", async (req, reply) => {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" });
}
const { resource } = req.params as { resource: string };
const {select, sort, asc } = req.query as { select?: string, sort?: string, asc?: string }
console.log(select, sort, asc)
const { data, error } = await server.supabase
.from(resource)
//@ts-ignore
.select(select || dataTypes[resource].supabaseSelectWithInformation)
.eq("tenant", req.user.tenant_id)
if (error) {
console.log(error)
return reply.code(400).send({ error: error.message });
}
const sorted =sortData(data,sort,asc === "true" ? true : false)
return sorted;
});
// Helper Funktionen
// Liste Paginated
server.get("/resource/:resource/paginated", async (req, reply) => {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" });
}
const { resource } = req.params as { resource: string };
const { queryConfig } = req;
const { pagination, sort, filters, paginationDisabled } = queryConfig;
const { select, search, searchColumns, distinctColumns } = req.query as {
select?: string;
search?: string;
searchColumns?: string;
distinctColumns?: string;
};
console.log(req.query);
console.log(select);
// --- 🔍 Suche (im Backend mit Joins) ---
if (search && search.trim().length > 0) {
// 1. Alle Daten mit Joins holen (OHNE Pagination, aber mit Filtern)
let searchQuery = server.supabase
.from(resource)
.select(select || dataTypes[resource].supabaseSelectWithInformation)
.eq("tenant", req.user.tenant_id);
// --- Filterung anwenden ---
for (const [key, val] of Object.entries(filters || {})) {
if (Array.isArray(val)) {
searchQuery = searchQuery.in(key, val);
} else { // @ts-ignore
if (val === true || val === false || val === null) {
searchQuery = searchQuery.is(key, val);
} else {
searchQuery = searchQuery.eq(key, val);
}
}
}
const { data: allData, error: searchError } = await searchQuery;
if (searchError) {
server.log.error(searchError);
return reply.code(400).send({ error: searchError.message });
}
// 2. Im Backend nach Suchbegriff filtern
const searchTerm = search.trim().toLowerCase();
const searchCols = searchColumns
? searchColumns.split(",").map(c => c.trim()).filter(Boolean)
: dataTypes[resource].searchableColumns || [];
const filteredData = (allData || []).filter(row => {
/*if (searchCols.length === 0) {
// Fallback: Durchsuche alle String-Felder der Hauptebene
return Object.values(row).some(val =>
JSON.stringify(val).toString().toLowerCase().includes(searchTerm)
);
}
return searchCols.some(col => {
const value = getNestedValue(row, col);
return JSON.stringify(value).toLowerCase().includes(searchTerm);
});*/
return JSON.stringify(row).toLowerCase().includes(searchTerm);
});
// 3. Im Backend sortieren
let sortedData = [...filteredData];
if (sort?.length > 0) {
sortedData.sort((a, b) => {
for (const s of sort) {
const aVal = getNestedValue(a, s.field);
const bVal = getNestedValue(b, s.field);
const comparison = compareValues(aVal, bVal);
if (comparison !== 0) {
return s.direction === "asc" ? comparison : -comparison;
}
}
return 0;
});
}
// 4. Im Backend paginieren
const total = sortedData.length;
const paginatedData = !paginationDisabled && pagination
? sortedData.slice(pagination.offset, pagination.offset + pagination.limit)
: sortedData;
// 5. Distinct Values berechnen
const distinctValues: Record<string, any[]> = {};
if (distinctColumns) {
const cols = distinctColumns.split(",").map(c => c.trim()).filter(Boolean);
for (const col of cols) {
// Distinct values aus den gefilterten Daten
const values = filteredData
.map(row => getNestedValue(row, col))
.filter(v => v !== null && v !== undefined && v !== "");
distinctValues[col] = [...new Set(values)].sort();
}
}
const totalPages = !paginationDisabled && pagination?.limit
? Math.ceil(total / pagination.limit)
: 1;
const enrichedConfig = {
...queryConfig,
total,
totalPages,
distinctValues,
search: search || null,
};
return { data: paginatedData, queryConfig: enrichedConfig };
}
// --- Standardabfrage (ohne Suche) ---
let baseQuery = server.supabase
.from(resource)
.select(select || dataTypes[resource].supabaseSelectWithInformation, { count: "exact" })
.eq("tenant", req.user.tenant_id);
// --- Filterung ---
for (const [key, val] of Object.entries(filters || {})) {
if (Array.isArray(val)) {
baseQuery = baseQuery.in(key, val);
} else { // @ts-ignore
if (val == true || val == false || val === null) {
baseQuery = baseQuery.is(key, val);
} else {
baseQuery = baseQuery.eq(key, val);
}
}
}
// --- Sortierung ---
if (sort?.length > 0) {
for (const s of sort) {
baseQuery = baseQuery.order(s.field, { ascending: s.direction === "asc" });
}
}
// --- Pagination ---
if (!paginationDisabled && pagination) {
const { offset, limit } = pagination;
baseQuery = baseQuery.range(offset, offset + limit - 1);
}
const { data, error, count } = await baseQuery;
if (error) {
server.log.error(error);
return reply.code(400).send({ error: error.message });
}
// --- Distinct-Werte (auch ohne Suche) ---
const distinctValues: Record<string, any[]> = {};
if (distinctColumns) {
const cols = distinctColumns.split(",").map(c => c.trim()).filter(Boolean);
for (const col of cols) {
const { data: allRows, error: distinctErr } = await server.supabase
.from(resource)
.select(col)
.eq("tenant", req.user.tenant_id);
if (distinctErr) continue;
const values = (allRows || [])
.map((row) => row?.[col] ?? null)
.filter((v) => v !== null && v !== undefined && v !== "");
distinctValues[col] = [...new Set(values)].sort();
}
}
const total = count || 0;
const totalPages = !paginationDisabled && pagination?.limit
? Math.ceil(total / pagination.limit)
: 1;
const enrichedConfig = {
...queryConfig,
total,
totalPages,
distinctValues,
search: search || null,
};
return { data, queryConfig: enrichedConfig };
});
// Detail
server.get("/resource/:resource/:id/:with_information?", async (req, reply) => {
if (!req.user?.tenant_id) {
return reply.code(400).send({error: "No tenant selected"});
}
const {resource, id, with_information} = req.params as {
resource: string;
id: string,
with_information: boolean
};
const {select} = req.query as { select?: string }
// @ts-ignore
const {
data,
error
} = await server.supabase.from(resource).select(with_information ? dataTypes[resource].supabaseSelectWithInformation : (select ? select : "*"))
.eq("id", id)
.eq("tenant", req.user.tenant_id)
.single();
if (error || !data) {
return reply.code(404).send({error: "Not found"});
}
return data;
});
// Create
server.post("/resource/:resource", async (req, reply) => {
if (!req.user?.tenant_id) {
return reply.code(400).send({error: "No tenant selected"});
}
const {resource} = req.params as { resource: string };
const body = req.body as Record<string, any>;
const dataType = dataTypes[resource];
let createData = {
...body,
tenant: req.user.tenant_id,
archived: false, // Standardwert
}
if (dataType.numberRangeHolder && !body[dataType.numberRangeHolder]) {
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, resource)
createData[dataType.numberRangeHolder] = result.usedNumber
}
const {data, error} = await server.supabase
.from(resource)
.insert(createData)
.select("*")
.single();
if (error) {
return reply.code(400).send({error: error.message});
}
await insertHistoryItem(server, {
entity: resource,
entityId: data.id,
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: data,
text: `${dataType.labelSingle} erstellt`,
});
return data;
});
// UPDATE (inkl. Soft-Delete/Archive)
server.put("/resource/:resource/:id", async (req, reply) => {
console.log("hi")
const {resource, id} = req.params as { resource: string; id: string }
const body = req.body as Record<string, any>
const tenantId = (req.user as any)?.tenant_id
const userId = (req.user as any)?.user_id
if (!tenantId || !userId) {
return reply.code(401).send({error: "Unauthorized"})
}
// vorherige Version für History laden
const {data: oldItem} = await server.supabase
.from(resource)
.select("*")
.eq("id", id)
.eq("tenant", tenantId)
.single()
const {data: newItem, error} = await server.supabase
.from(resource)
.update({...body, updated_at: new Date().toISOString(), updated_by: userId})
.eq("id", id)
.eq("tenant", tenantId)
.select()
.single()
if (error) return reply.code(500).send({error})
const diffs = diffObjects(oldItem, newItem);
for (const d of diffs) {
await insertHistoryItem(server, {
entity: resource,
entityId: id,
action: d.type,
created_by: userId,
tenant_id: tenantId,
oldVal: d.oldValue ? String(d.oldValue) : null,
newVal: d.newValue ? String(d.newValue) : null,
text: `Feld "${d.label}" ${d.typeLabel}: ${d.oldValue ?? ""}${d.newValue ?? ""}`,
});
}
return newItem
})
}

View File

@@ -0,0 +1,384 @@
import { FastifyInstance } from "fastify"
import {
eq,
ilike,
asc,
desc,
and,
count,
inArray,
or
} from "drizzle-orm"
import {resourceConfig} from "../../resource.config";
// -------------------------------------------------------------
// SQL Volltextsuche auf mehreren Feldern
// -------------------------------------------------------------
function buildSearchCondition(table: any, columns: string[], search: string) {
if (!search || !columns.length) return null
const term = `%${search.toLowerCase()}%`
const conditions = columns
.map((colName) => table[colName])
.filter(Boolean)
.map((col) => ilike(col, term))
if (conditions.length === 0) return null
// @ts-ignore
return or(...conditions)
}
export default async function resourceRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// LIST
// -------------------------------------------------------------
server.get("/resource/:resource", async (req, reply) => {
try {
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
}
const {resource} = req.params as {resource: string}
const table = resourceConfig[resource].table
// WHERE-Basis
let whereCond: any = eq(table.tenant, tenantId)
// 🔍 SQL Search
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(table).where(whereCond)
// Sortierung
if (sort) {
const col = (table as any)[sort]
if (col) {
//@ts-ignore
q = ascQuery === "true"
? q.orderBy(asc(col))
: q.orderBy(desc(col))
}
}
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/:resource", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// -------------------------------------------------------------
// PAGINATED LIST
// -------------------------------------------------------------
server.get("/resource/:resource/paginated", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id;
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" });
}
const {resource} = req.params as {resource: string};
const {queryConfig} = req;
const {
pagination,
sort,
filters,
paginationDisabled
} = queryConfig;
const { search, distinctColumns } = req.query as {
search?: string;
distinctColumns?: string;
};
let table = resourceConfig[resource].table
let whereCond: any = eq(table.tenant, tenantId);
if(search) {
const searchCond = buildSearchCondition(
table,
resourceConfig[resource].searchColumns,
search.trim()
)
if (searchCond) {
whereCond = and(whereCond, searchCond)
}
}
if (filters) {
for (const [key, val] of Object.entries(filters)) {
const col = (table as any)[key];
if (!col) continue;
if (Array.isArray(val)) {
whereCond = and(whereCond, inArray(col, val));
} else {
whereCond = and(whereCond, eq(col, val as any));
}
}
}
// -----------------------------------------------
// COUNT (for pagination)
// -----------------------------------------------
const totalRes = await server.db
.select({ value: count(table.id) })
.from(table)
.where(whereCond);
const total = Number(totalRes[0]?.value ?? 0);
// -----------------------------------------------
// DISTINCT VALUES (regardless of pagination)
// -----------------------------------------------
const distinctValues: Record<string, any[]> = {};
if (distinctColumns) {
for (const colName of distinctColumns.split(",").map(c => c.trim())) {
const col = (table as any)[colName];
if (!col) continue;
const rows = await server.db
.select({ v: col })
.from(table)
.where(eq(table.tenant, tenantId));
const values = rows
.map(r => r.v)
.filter(v => v != null && v !== "");
distinctValues[colName] = [...new Set(values)].sort();
}
}
// PAGINATION
const offset = pagination?.offset ?? 0;
const limit = pagination?.limit ?? 100;
// SORTING
let orderField: any = null;
let direction: "asc" | "desc" = "asc";
if (sort?.length > 0) {
const s = sort[0];
const col = (table as any)[s.field];
if (col) {
orderField = col;
direction = s.direction === "asc" ? "asc" : "desc";
}
}
// MAIN QUERY (Paginated)
let q = server.db
.select()
.from(table)
.where(whereCond)
.offset(offset)
.limit(limit);
if (orderField) {
//@ts-ignore
q = direction === "asc"
? q.orderBy(asc(orderField))
: q.orderBy(desc(orderField));
}
const rows = await q;
if (!rows.length) {
return {
data: [],
queryConfig: {
...queryConfig,
total,
totalPages: 0,
distinctValues
}
};
}
// 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(rows.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 = rows.map(row => {
let toReturn = {
...row
}
resourceConfig[resource].mtoLoad.forEach(relation => {
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
})
return toReturn
});
} else {
data = rows
}
// -----------------------------------------------
// RETURN DATA
// -----------------------------------------------
return {
data,
queryConfig: {
...queryConfig,
total,
totalPages: Math.ceil(total / limit),
distinctValues
}
};
} catch (err) {
console.error(`ERROR /resource/:resource/paginated:`, err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
// -------------------------------------------------------------
// DETAIL (mit JOINS)
// -------------------------------------------------------------
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 {resource} = req.params as { resource: string }
const table = resourceConfig[resource].table
const projRows = await server.db
.select()
.from(table)
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
.limit(1)
if (!projRows.length)
return reply.code(404).send({ error: "Resource not found" })
// ------------------------------------
// LOAD RELATIONS
// ------------------------------------
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

@@ -4,16 +4,24 @@ import { StaffTimeEntry } from '../../types/staff'
export default async function staffTimeRoutes(server: FastifyInstance) {
// ▶ Neue Zeit starten
server.post<{ Body: Pick<StaffTimeEntry, 'started_at' | 'stopped_at' | 'type' | 'description'> }>(
server.post(
'/staff/time',
async (req, reply) => {
const { started_at, stopped_at, type = 'work', description } = req.body
const { started_at, stopped_at, type = 'work', description, user_id } = req.body as any
const userId = req.user.user_id
const tenantId = req.user.tenant_id
let dataToInsert = {
tenant_id: tenantId,
user_id: user_id ? user_id : userId,
// @ts-ignore
...req.body
}
const { data, error } = await server.supabase
.from('staff_time_entries')
.insert([{ tenant_id: tenantId, user_id: userId, started_at, stopped_at, type, description }])
.insert([dataToInsert])
.select()
.maybeSingle()

27
src/utils/dbSearch.ts Normal file
View File

@@ -0,0 +1,27 @@
import { ilike, or } from "drizzle-orm"
/**
* Erzeugt eine OR-Suchbedingung über mehrere Spalten
*
* @param table - Drizzle Table Schema
* @param columns - Array der Spaltennamen (property names im schema)
* @param search - Suchbegriff
*/
export function buildSearchWhere(table: any, columns: string[], search: string) {
if (!search || !columns.length) return undefined
const term = `%${search.toLowerCase()}%`
const parts = columns
.map((colName) => {
const col = table[colName]
if (!col) return null
return ilike(col, term)
})
.filter(Boolean)
if (parts.length === 0) return undefined
// @ts-ignore
return or(...parts)
}

114
src/utils/export/sepa.ts Normal file
View File

@@ -0,0 +1,114 @@
import xmlbuilder from "xmlbuilder";
import {randomUUID} from "node:crypto";
import dayjs from "dayjs";
export const createSEPAExport = async (server,idsToExport, tenant_id) => {
const {data,error} = await server.supabase.from("createddocuments").select().eq("tenant", tenant_id).in("id", idsToExport)
const {data:tenantData,error:tenantError} = await server.supabase.from("tenants").select().eq("id", tenant_id).single()
console.log(tenantData)
console.log(tenantError)
console.log(data)
let transactions = []
let obj = {
Document: {
'@xmlns':"urn:iso:std:iso:20022:tech:xsd:pain.008.001.02",
'CstmrDrctDbtInitn': {
'GrpHdr': {
'MsgId': randomUUID(),
'CredDtTm': dayjs().format("YYYY-MM-DDTHH:mm:ss"),
'NbOfTxs': transactions.length,
'CtrlSum': 0, // TODO: Total Sum
'InitgPty': {
'Nm': tenantData.name
}
},
'PmtInf': {
'PmtInfId': "", // TODO: Mandatsreferenz,
'PmtMtd': "DD",
'BtchBookg': "true", // TODO: BatchBooking,
'NbOfTxs': transactions.length,
'CtrlSum': 0, //TODO: Total Sum
'PmtTpInf': {
'SvcLvl': {
'Cd': "SEPA"
},
'LclInstrm': {
'Cd': "CORE" // Core für BASIS / B2B für Firmen
},
'SeqTp': "RCUR" // TODO: Unterscheidung Einmalig / Wiederkehrend
},
'ReqdColltnDt': dayjs().add(3, "days").format("YYYY-MM-DDTHH:mm:ss"),
'Cdtr': {
'Nm': tenantData.name
},
'CdtrAcct': {
'Id': {
'IBAN': "DE" // TODO: IBAN Gläubiger EINSETZEN
}
},
'CdtrAgt': {
'FinInstnId': {
'BIC': "BIC" // TODO: BIC des Gläubigers einsetzen
}
},
'ChrgBr': "SLEV",
'CdtrSchmeId': {
'Id': {
'PrvtId': {
'Othr': {
'Id': tenantData.creditorId,
'SchmeNm': {
'Prty': "SEPA"
}
}
}
}
},
//TODO ITERATE ALL INVOICES HERE
'DrctDbtTxInf': {
'PmtId': {
'EndToEndId': "" // TODO: EINDEUTIGE ReFERENZ wahrscheinlich RE Nummer
},
'InstdAmt': {
'@Ccy':"EUR",
'#text':100 //TODO: Rechnungssumme zwei NK mit Punkt
},
'DrctDbtTx': {
'MndtRltdInf': {
'MndtId': "", // TODO: Mandatsref,
'DtOfSgntr': "", //TODO: Unterschrieben am,
'AmdmntInd': "" //TODO: Mandat geändert
}
},
'DbtrAgt': {
'FinInstnId': {
'BIC': "", //TODO: BIC Debtor
}
},
'Dbtr': {
'Nm': "" // TODO NAME Debtor
},
'DbtrAcct': {
'Id': {
'IBAN': "DE" // TODO IBAN Debtor
}
},
'RmtInf': {
'Ustrd': "" //TODO Verwendungszweck Rechnungsnummer
}
}
}
}
}
}
let doc = xmlbuilder.create(obj, {encoding: 'UTF-8', standalone: true})
console.log(doc.end({pretty:true}))
}

View File

@@ -1,4 +1,11 @@
import {FastifyInstance} from "fastify";
// import { PNG } from 'pngjs'
// import { ready as zplReady } from 'zpl-renderer-js'
// import { Utils } from '@mmote/niimbluelib'
// import { createCanvas } from 'canvas'
// import bwipjs from 'bwip-js'
// import Sharp from 'sharp'
// import fs from 'fs'
export const useNextNumberRangeNumber = async (server:FastifyInstance, tenantId:number,numberRange)=> {
const {data:tenant} = await server.supabase.from("tenants").select().eq("id",tenantId).single()
@@ -20,4 +27,118 @@ export const useNextNumberRangeNumber = async (server:FastifyInstance, tenantId:
usedNumber
}
}
}
}
/*
export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') {
// 1⃣ PNG dekodieren
const buffer = Buffer.from(base64Png, 'base64')
const png = PNG.sync.read(buffer) // liefert {width, height, data: Uint8Array(RGBA)}
const { width, height, data } = png
console.log(width, height, data)
const cols = printDirection === 'left' ? height : width
const rows = printDirection === 'left' ? width : height
const rowsData = []
console.log(cols)
if (cols % 8 !== 0) throw new Error('Column count must be multiple of 8')
// 2⃣ Zeilenweise durchgehen und Bits bilden
for (let row = 0; row < rows; row++) {
let isVoid = true
let blackPixelsCount = 0
const rowData = new Uint8Array(cols / 8)
for (let colOct = 0; colOct < cols / 8; colOct++) {
let pixelsOctet = 0
for (let colBit = 0; colBit < 8; colBit++) {
const x = printDirection === 'left' ? row : colOct * 8 + colBit
const y = printDirection === 'left' ? height - 1 - (colOct * 8 + colBit) : row
const idx = (y * width + x) * 4
const lum = 0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2]
const isBlack = lum < 128
if (isBlack) {
pixelsOctet |= 1 << (7 - colBit)
isVoid = false
blackPixelsCount++
}
}
rowData[colOct] = pixelsOctet
}
const newPart = {
dataType: isVoid ? 'void' : 'pixels',
rowNumber: row,
repeat: 1,
rowData: isVoid ? undefined : rowData,
blackPixelsCount,
}
if (rowsData.length === 0) {
rowsData.push(newPart)
} else {
const last = rowsData[rowsData.length - 1]
let same = newPart.dataType === last.dataType
if (same && newPart.dataType === 'pixels') {
same = Utils.u8ArraysEqual(newPart.rowData, last.rowData)
}
if (same) last.repeat++
else rowsData.push(newPart)
if (row % 200 === 199) {
rowsData.push({
dataType: 'check',
rowNumber: row,
repeat: 0,
rowData: undefined,
blackPixelsCount: 0,
})
}
}
}
return { cols, rows, rowsData }
}
export async function generateLabel(context,width,height) {
// Canvas für Hintergrund & Text
const canvas = createCanvas(width, height)
const ctx = canvas.getContext('2d')
// Hintergrund weiß
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(0, 0, width, height)
// Überschrift
ctx.fillStyle = '#000000'
ctx.font = '32px Arial'
ctx.fillText(context.text, 20, 40)
// 3) DataMatrix
const dataMatrixPng = await bwipjs.toBuffer({
bcid: 'datamatrix',
text: context.datamatrix,
scale: 6,
})
// Basisbild aus Canvas
const base = await Sharp(canvas.toBuffer())
.png()
.toBuffer()
// Alles zusammen compositen
const final = await Sharp(base)
.composite([
{ input: dataMatrixPng, top: 60, left: 20 },
])
.png()
.toBuffer()
fs.writeFileSync('label.png', final)
// Optional: Base64 zurückgeben (z.B. für API)
const base64 = final.toString('base64')
return base64
}*/

View File

@@ -13,6 +13,7 @@ export let secrets = {
JWT_SECRET: string
PORT: number
HOST: string
DATABASE_URL: string
SUPABASE_URL: string
SUPABASE_SERVICE_ROLE_KEY: string
S3_BUCKET: string