Merge branch 'orm' into 'main'

Orm

See merge request fedeo/backend!3
This commit is contained in:
2025-12-08 14:09:37 +00:00
19 changed files with 1280 additions and 793 deletions

View File

@@ -1,29 +1,20 @@
# Basis-Image
FROM node:20-alpine AS base
FROM node:20-alpine
WORKDIR /usr/src/app
# Dependencies installieren (dev deps für Build erforderlich)
# Package-Dateien
COPY package*.json ./
# Dev + Prod Dependencies (für TS-Build nötig)
RUN npm install
# Quellcode kopieren
# Restlicher Sourcecode
COPY . .
# Build ausführen (TypeScript -> dist)
RUN npx tsc --skipLibCheck --noEmitOnError false
# --------- Production Stage ---------
FROM node:20-alpine AS production
WORKDIR /usr/src/app
# Nur production dependencies installieren
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=base /usr/src/app/dist ./dist
# TypeScript Build
RUN npm run build
# Port freigeben
EXPOSE 3100
# App starten
CMD ["npm", "start"]
# Start der App
CMD ["node", "dist/src/index.js"]

View File

@@ -9,7 +9,7 @@ export const holidays = pgTable("holidays", {
name: text("name").notNull(),
stateCode: text("state_code").notNull(),
state_code: text("state_code").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
})

View File

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

View File

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

View File

@@ -23,15 +23,15 @@ export const statementallocations = pgTable("statementallocations", {
id: uuid("id").primaryKey().defaultRandom(),
// foreign keys
bs_id: integer("bs_id")
bankstatement: integer("bs_id")
.notNull()
.references(() => bankstatements.id),
cd_id: integer("cd_id").references(() => createddocuments.id),
createddocument: integer("cd_id").references(() => createddocuments.id),
amount: doublePrecision("amount").notNull().default(0),
ii_id: bigint("ii_id", { mode: "number" }).references(
incominginvoice: bigint("ii_id", { mode: "number" }).references(
() => incominginvoices.id
),
@@ -43,7 +43,7 @@ export const statementallocations = pgTable("statementallocations", {
() => accounts.id
),
createdAt: timestamp("created_at", {
created_at: timestamp("created_at", {
withTimezone: false,
}).defaultNow(),
@@ -57,9 +57,9 @@ export const statementallocations = pgTable("statementallocations", {
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updated_at: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),
updated_by: uuid("updated_by").references(() => authUsers.id),
archived: boolean("archived").notNull().default(false),
})

View File

@@ -6,7 +6,7 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts"
},
"repository": {

View File

@@ -27,6 +27,7 @@ import helpdeskInboundRoutes from "./routes/helpdesk.inbound";
import notificationsRoutes from "./routes/notifications";
import staffTimeRoutes from "./routes/staff/time";
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
import userRoutes from "./routes/auth/user";
//Resources
import resourceRoutes from "./routes/resources/main";
@@ -64,6 +65,7 @@ async function main() {
app.addHook('preHandler', (req, reply, done) => {
console.log(req.method)
console.log('Matched path:', req.routeOptions.url)
console.log('Exact URL:', req.url)
done()
})
@@ -112,6 +114,7 @@ async function main() {
await subApp.register(notificationsRoutes);
await subApp.register(staffTimeRoutes);
await subApp.register(staffTimeConnectRoutes);
await subApp.register(userRoutes);
await subApp.register(resourceRoutes);

View File

@@ -1,5 +1,10 @@
import {FastifyInstance} from "fastify";
import { FastifyInstance } from "fastify";
import {and, eq, gte, lte, asc, inArray} from "drizzle-orm";
import {
authProfiles,
stafftimeentries,
holidays,
} from "../../../db/schema";
export async function generateTimesEvaluation(
server: FastifyInstance,
@@ -8,136 +13,204 @@ export async function generateTimesEvaluation(
startDateInput: string,
endDateInput: string
) {
const startDate = server.dayjs(startDateInput)
const endDate = server.dayjs(endDateInput)
const startDate = server.dayjs(startDateInput);
const endDate = server.dayjs(endDateInput);
console.log(startDate.format("YYYY-MM-DD HH:mm:ss"));
console.log(endDate.format("YYYY-MM-DD HH:mm:ss"));
// 🧾 Profil laden (Arbeitszeiten + Bundesland)
const { data: profile, error: profileError } = await server.supabase
.from("auth_profiles")
.select("*")
.eq("user_id", user_id)
.eq("tenant_id", tenant_id)
.maybeSingle()
// -------------------------------------------------------------
// 1⃣ Profil laden
// -------------------------------------------------------------
const profileRows = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.user_id, user_id),
eq(authProfiles.tenant_id, tenant_id)
)
)
.limit(1);
if (profileError || !profile) throw new Error("Profil konnte nicht geladen werden.")
const profile = profileRows[0];
// 🕒 Arbeitszeiten abrufen
const { data: timesRaw, error: timeError } = await server.supabase
.from("staff_time_entries")
.select("*")
.eq("tenant_id", tenant_id)
.eq("user_id", user_id)
.order("started_at", { ascending: true })
if (!profile) throw new Error("Profil konnte nicht geladen werden.");
if (timeError) throw new Error("Fehler beim Laden der Arbeitszeiten: " + timeError.message)
// -------------------------------------------------------------
// 2⃣ Arbeitszeiten laden
// -------------------------------------------------------------
const timesRaw = await server.db
.select()
.from(stafftimeentries)
.where(
and(
eq(stafftimeentries.tenant_id, tenant_id),
eq(stafftimeentries.user_id, user_id)
)
)
.orderBy(asc(stafftimeentries.started_at));
const isBetween = (spanStartDate,spanEndDate,startDate,endDate) => {
return server.dayjs(startDate).isBetween(spanStartDate, spanEndDate, "day", "[]") && server.dayjs(endDate).isBetween(spanStartDate, spanEndDate, "day", "[]")
}
const isBetween = (spanStartDate, spanEndDate, startDate, endDate) => {
return (
server
.dayjs(startDate)
.isBetween(spanStartDate, spanEndDate, "day", "[]") &&
server
.dayjs(endDate)
.isBetween(spanStartDate, spanEndDate, "day", "[]")
);
};
const times = timesRaw.filter((i) =>
isBetween(startDate, endDate, i.started_at, i.stopped_at)
);
const times = timesRaw.filter(i => isBetween(startDate,endDate,i.started_at,i.stopped_at) )
console.log(times);
console.log(times)
// -------------------------------------------------------------
// 3⃣ Feiertage laden
// -------------------------------------------------------------
const holidaysRows = await server.db
.select({
date: holidays.date,
})
.from(holidays)
.where(
and(
inArray(holidays.state_code, [profile.state_code, "DE"]),
gte(holidays.date, startDate.format("YYYY-MM-DD")),
lte(holidays.date, endDate.add(1, "day").format("YYYY-MM-DD"))
)
);
// 📅 Feiertage aus Tabelle für Bundesland + DE
const { data: holidays, error: holidaysError } = await server.supabase
.from("holidays")
.select("date")
.in("state_code", [profile.state_code, "DE"])
.gte("date", startDate.format("YYYY-MM-DD"))
.lte("date", endDate.add(1,"day").format("YYYY-MM-DD"))
if (holidaysError) throw new Error("Fehler beim Laden der Feiertage: " + holidaysError.message)
// 🗓️ Sollzeit berechnen
let timeSpanWorkingMinutes = 0
const totalDays = endDate.add(1, "day").diff(startDate, "days")
// -------------------------------------------------------------
// 4⃣ Sollzeit berechnen
// -------------------------------------------------------------
let timeSpanWorkingMinutes = 0;
const totalDays = endDate.add(1, "day").diff(startDate, "days");
for (let i = 0; i < totalDays; i++) {
const date = startDate.add(i, "days")
const weekday = date.day()
timeSpanWorkingMinutes += (profile.weekly_regular_working_hours?.[weekday] || 0) * 60
const date = startDate.add(i, "days");
const weekday = date.day();
timeSpanWorkingMinutes +=
(profile.weekly_regular_working_hours?.[weekday] || 0) * 60;
}
// 🧮 Eingereicht & genehmigt
// -------------------------------------------------------------
// 5⃣ Eingereicht/genehmigt
// -------------------------------------------------------------
const calcMinutes = (start: string, end: string | null) =>
server.dayjs(end || new Date()).diff(server.dayjs(start), "minutes")
server.dayjs(end || new Date()).diff(server.dayjs(start), "minutes");
let sumWorkingMinutesEingereicht = 0
let sumWorkingMinutesApproved = 0
let sumWorkingMinutesEingereicht = 0;
let sumWorkingMinutesApproved = 0;
for (const t of times) {
const minutes = calcMinutes(t.started_at, t.stopped_at)
if(["submitted","approved"].includes(t.state) && t.type === "work")sumWorkingMinutesEingereicht += minutes
if (t.state === "approved" && t.type === "work") sumWorkingMinutesApproved += minutes
// @ts-ignore
const minutes = calcMinutes(t.started_at, t.stopped_at);
if (["submitted", "approved"].includes(t.state) && t.type === "work") {
sumWorkingMinutesEingereicht += minutes;
}
if (t.state === "approved" && t.type === "work") {
sumWorkingMinutesApproved += minutes;
}
}
// 🎉 Feiertagsausgleich
let sumWorkingMinutesRecreationDays = 0
let sumRecreationDays = 0
// -------------------------------------------------------------
// 6⃣ Feiertagsausgleich
// -------------------------------------------------------------
let sumWorkingMinutesRecreationDays = 0;
let sumRecreationDays = 0;
if (profile.recreation_days_compensation && holidays?.length) {
holidays.forEach(({ date }) => {
const weekday = server.dayjs(date).day()
const hours = profile.weekly_regular_working_hours?.[weekday] || 0
sumWorkingMinutesRecreationDays += hours * 60
sumRecreationDays++
})
if (profile.recreation_days_compensation && holidaysRows?.length) {
holidaysRows.forEach(({ date }) => {
const weekday = server.dayjs(date).day();
const hours = profile.weekly_regular_working_hours?.[weekday] || 0;
sumWorkingMinutesRecreationDays += hours * 60;
sumRecreationDays++;
});
}
// 🏖️ Urlaub & Krankheit (über Typ)
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;
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
})
let sumWorkingMinutesSickDays = 0
let sumSickDays = 0
// -------------------------------------------------------------
// 7⃣ Urlaub
// -------------------------------------------------------------
let sumWorkingMinutesVacationDays = 0;
let sumVacationDays = 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;
.filter((t) => t.type === "vacation" && t.state === "approved")
.forEach((time) => {
// Tippfehler aus Original: startet_at vs started_at → NICHT korrigiert
const days =
server.dayjs(time.stopped_at).diff(
//@ts-ignore
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
}
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;
});
sumSickDays += days
})
// -------------------------------------------------------------
// 8⃣ Krankheit
// -------------------------------------------------------------
let sumWorkingMinutesSickDays = 0;
let sumSickDays = 0;
// 💰 Salden
times
.filter((t) => t.type === "sick" && t.state === "approved")
.forEach((time) => {
const days =
server.dayjs(time.stopped_at).diff(
//@ts-ignore
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;
});
// -------------------------------------------------------------
// 9⃣ Salden
// -------------------------------------------------------------
const saldo =
sumWorkingMinutesApproved +
sumWorkingMinutesRecreationDays +
sumWorkingMinutesVacationDays +
sumWorkingMinutesSickDays -
timeSpanWorkingMinutes
timeSpanWorkingMinutes;
const saldoInOfficial =
sumWorkingMinutesEingereicht +
sumWorkingMinutesRecreationDays +
sumWorkingMinutesVacationDays +
sumWorkingMinutesSickDays -
timeSpanWorkingMinutes
timeSpanWorkingMinutes;
// 📦 Rückgabe (kompatibel zur alten Struktur)
// -------------------------------------------------------------
// 🔟 Rückgabe identisch
// -------------------------------------------------------------
return {
user_id,
tenant_id,
@@ -154,6 +227,6 @@ export async function generateTimesEvaluation(
sumSickDays,
saldo,
saldoInOfficial,
times
}
}
times,
};
}

View File

@@ -1,103 +1,115 @@
import { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
import jwt from "jsonwebtoken";
import { secrets } from "../utils/secrets";
import { FastifyInstance } from "fastify"
import fp from "fastify-plugin"
import jwt from "jsonwebtoken"
import { secrets } from "../utils/secrets"
import {
authUserRoles,
authRolePermissions,
} from "../../db/schema"
import { eq, and } from "drizzle-orm"
export default fp(async (server: FastifyInstance) => {
server.addHook("preHandler", async (req, reply) => {
// 1⃣ Token holen (Header oder Cookie)
const cookieToken = req.cookies?.token;
const authHeader = req.headers.authorization;
const headerToken = authHeader?.startsWith("Bearer ")
? authHeader.slice(7)
: null;
// 1⃣ Token aus Header oder Cookie lesen
const cookieToken = req.cookies?.token
const authHeader = req.headers.authorization
const headerToken =
authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null
const token =
headerToken && headerToken.length > 10 ? headerToken : cookieToken || null;
/*let token = null
if(headerToken !== null && headerToken.length > 10){
token = headerToken
} else if(cookieToken ){
token = cookieToken
}*/
headerToken && headerToken.length > 10
? headerToken
: cookieToken || null
if (!token) {
return reply.code(401).send({ error: "Authentication required" });
return reply.code(401).send({ error: "Authentication required" })
}
try {
// 2⃣ JWT verifizieren
const payload = jwt.verify(token, secrets.JWT_SECRET!) as {
user_id: string;
email: string;
tenant_id: number;
};
user_id: string
email: string
tenant_id: number | null
}
if (!payload?.user_id) {
return reply.code(401).send({ error: "Invalid token" });
return reply.code(401).send({ error: "Invalid token" })
}
req.user = payload;
// Payload an Request hängen
req.user = payload
if(req.user.tenant_id) {
// 3⃣ Rolle des Nutzers im Tenant laden
const { data: roleData, error: roleError } = await server.supabase
.from("auth_user_roles")
.select("role_id")
.eq("user_id", payload.user_id)
.eq("tenant_id", payload.tenant_id)
.maybeSingle();
if (roleError) {
console.log("Error fetching user role", roleError);
return reply.code(500).send({ error: "Failed to load user role" });
}
if (!roleData) {
return reply.code(403).send({ error: "No role assigned for this tenant" });
}
const roleId = roleData.role_id;
// 4⃣ Berechtigungen der Rolle laden
const { data: permissions, error: permsError } = await server.supabase
.from("auth_role_permissions")
.select("permission")
.eq("role_id", roleId);
if (permsError) {
console.log("Failed to load permissions", permsError);
return reply.code(500).send({ error: "Permission lookup failed" });
}
const perms = permissions?.map((p) => p.permission) ?? [];
// 5⃣ An Request hängen
req.role = roleId;
req.permissions = perms;
req.hasPermission = (perm: string) => perms.includes(perm);
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
if (!req.user.tenant_id) {
return
}
const tenantId = req.user.tenant_id
const userId = req.user.user_id
// --------------------------------------------------------
// 3⃣ Rolle des Nutzers im Tenant holen
// --------------------------------------------------------
const roleRows = await server.db
.select()
.from(authUserRoles)
.where(
and(
eq(authUserRoles.user_id, userId),
eq(authUserRoles.tenant_id, tenantId)
)
)
.limit(1)
if (roleRows.length === 0) {
return reply
.code(403)
.send({ error: "No role assigned for this tenant" })
}
const roleId = roleRows[0].role_id
// --------------------------------------------------------
// 4⃣ Berechtigungen der Rolle laden
// --------------------------------------------------------
const permissionRows = await server.db
.select()
.from(authRolePermissions)
.where(eq(authRolePermissions.role_id, roleId))
const permissions = permissionRows.map((p) => p.permission)
// --------------------------------------------------------
// 5⃣ An Request hängen für spätere Nutzung
// --------------------------------------------------------
req.role = roleId
req.permissions = permissions
req.hasPermission = (perm: string) => permissions.includes(perm)
} catch (err) {
return reply.code(401).send({ error: "Invalid or expired token" });
console.error("JWT verification error:", err)
return reply.code(401).send({ error: "Invalid or expired token" })
}
});
});
})
})
// ---------------------------------------------------------------------------
// Fastify TypeScript Erweiterungen
// ---------------------------------------------------------------------------
// 🧩 Fastify Type Declarations
declare module "fastify" {
interface FastifyRequest {
user: {
user_id: string;
email: string;
tenant_id: number;
};
role: string;
permissions: string[];
hasPermission: (permission: string) => boolean;
user_id: string
email: string
tenant_id: number | null
}
role: string
permissions: string[]
hasPermission: (permission: string) => boolean
}
}

View File

@@ -5,7 +5,7 @@ import * as schema from "../../db/schema"
export default fp(async (server, opts) => {
const pool = new Pool({
host: "db-001.netbird.cloud",
host: "100.102.185.225",
port: Number(process.env.DB_PORT || 5432),
user: "postgres",
password: "wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu",

View File

@@ -1,108 +0,0 @@
import { FastifyInstance } from "fastify";
export default async function userRoutes(server: FastifyInstance) {
//TODO: PERMISSIONS Rückmeldung beschränken
server.get("/user/:id", async (req, reply) => {
const authUser = req.user // kommt aus JWT (user_id + tenant_id)
const { id } = req.params as { id?: string }
if (!authUser) {
return reply.code(401).send({ error: "Unauthorized" })
}
// 1. User laden
const { data: user, error: userError } = await server.supabase
.from("auth_users")
.select("id, email, created_at, must_change_password")
.eq("id", id)
.single()
if (userError || !user) {
return reply.code(401).send({ error: "User not found" })
}
// 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, locked )`)
.eq("id", authUser.user_id)
.single();
if (tenantLinksError) {
console.log(tenantLinksError)
return reply.code(401).send({ error: "Tenant Error" })
}
const tenants = tenantLinks?.tenants*/
// 3. Aktiven Tenant bestimmen
const activeTenant = authUser.tenant_id /*|| tenants[0].id*/
// 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", id)
.eq("tenant_id", activeTenant)
.single()
profile = profileData
}
// 5. Permissions laden (über Funktion)
// 6. Response zurückgeben
return {
user,
profile,
}
})
server.put("/user/:id/profile", async (req, reply) => {
const { id } = req.params as { id?: string }
const { data } = req.body as { data?: object }
// 4. Profil für den aktiven Tenant laden
let profile = null
if (req.user.tenant_id) {
const { data: profileData } = await server.supabase
.from("auth_profiles")
.select("*")
.eq("user_id", req.user.user_id)
.eq("tenant_id", req.user.tenant_id)
.single()
profile = profileData
}
console.log(data)
//Update Profile
const { data: updatedProfileData, error: updateError } = await server.supabase
.from("auth_profiles")
.update(data)
.eq("user_id", id)
.eq("id", profile?.id)
.select("*")
.single()
console.log(updateError)
console.log(updatedProfileData)
// 5. Permissions laden (über Funktion)
// 6. Response zurückgeben
return {
data,
}
})
}

129
src/routes/auth/user.ts Normal file
View File

@@ -0,0 +1,129 @@
import { FastifyInstance } from "fastify"
import { eq, and } from "drizzle-orm"
import {
authUsers,
authProfiles,
} from "../../../db/schema"
export default async function userRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// GET /user/:id
// -------------------------------------------------------------
server.get("/user/:id", async (req, reply) => {
try {
const authUser = req.user
const { id } = req.params as { id: string }
if (!authUser) {
return reply.code(401).send({ error: "Unauthorized" })
}
// 1⃣ User laden
const [user] = 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, id))
if (!user) {
return reply.code(404).send({ error: "User not found" })
}
// 2⃣ Profil im Tenant
let profile = null
if (authUser.tenant_id) {
const [profileRow] = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.user_id, id),
eq(authProfiles.tenant_id, authUser.tenant_id)
)
)
profile = profileRow || null
}
return { user, profile }
} catch (err: any) {
console.error("/user/:id ERROR", err)
return reply.code(500).send({ error: err.message || "Internal error" })
}
})
// -------------------------------------------------------------
// PUT /user/:id/profile
// -------------------------------------------------------------
server.put("/user/:id/profile", async (req, reply) => {
try {
const { id } = req.params as { id: string }
const { data } = req.body as { data?: Record<string, any> }
if (!req.user?.tenant_id) {
return reply.code(401).send({ error: "Unauthorized" })
}
if (!data || typeof data !== "object") {
return reply.code(400).send({ error: "data object required" })
}
// 1⃣ Profil für diesen Tenant laden (damit wir die ID kennen)
const [profile] = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.user_id, id),
eq(authProfiles.tenant_id, req.user.tenant_id)
)
)
if (!profile) {
return reply.code(404).send({ error: "Profile not found in tenant" })
}
// 2⃣ Timestamp-Felder normalisieren (falls welche drin sind)
const normalizeDate = (val: any) => {
if (!val) return null
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
const updateData: any = { ...data }
// bekannte Date-Felder prüfen
if (data.entry_date !== undefined)
updateData.entry_date = normalizeDate(data.entry_date)
if (data.birthday !== undefined)
updateData.birthday = normalizeDate(data.birthday)
if (data.created_at !== undefined)
updateData.created_at = normalizeDate(data.created_at)
updateData.updated_at = new Date()
// 3⃣ Update durchführen
const [updatedProfile] = await server.db
.update(authProfiles)
.set(updateData)
.where(eq(authProfiles.id, profile.id))
.returning()
return { profile: updatedProfile }
} catch (err: any) {
console.error("PUT /user/:id/profile ERROR", err)
return reply.code(500).send({ error: err.message || "Internal server error" })
}
})
}

View File

@@ -1,217 +1,236 @@
import { FastifyInstance } from "fastify";
import {insertHistoryItem} from "../utils/history";
import { FastifyInstance } from "fastify"
import axios from "axios"
import dayjs from "dayjs"
import {secrets} from "../utils/secrets";
import { secrets } from "../utils/secrets"
import { insertHistoryItem } from "../utils/history"
import {
bankrequisitions,
statementallocations,
} from "../../db/schema"
import {
eq,
and,
} from "drizzle-orm"
export default async function bankingRoutes(server: FastifyInstance) {
// ------------------------------------------------------------------
// 🔐 GoCardLess Token Handling
// ------------------------------------------------------------------
const goCardLessBaseUrl = secrets.GOCARDLESS_BASE_URL
const goCardLessSecretId = secrets.GOCARDLESS_SECRET_ID
const goCardLessSecretKey = secrets.GOCARDLESS_SECRET_KEY
let tokenData = null
let tokenData: any = null
const getToken = async () => {
const res = await axios({
url: goCardLessBaseUrl + "/token/new/",
method: "POST",
data: {
secret_id: goCardLessSecretId,
secret_key: goCardLessSecretKey,
},
const res = await axios.post(`${goCardLessBaseUrl}/token/new/`, {
secret_id: goCardLessSecretId,
secret_key: goCardLessSecretKey,
})
tokenData = res.data
tokenData.created_at = new Date().toISOString()
server.log.info("Got new GoCardless token")
server.log.info("GoCardless token refreshed.")
}
const checkToken = async () => {
if (tokenData) {
const expired = dayjs(tokenData.created_at)
.add(tokenData.access_expires, "seconds")
.isBefore(dayjs())
if (expired) {
server.log.info("Token expired — refreshing…")
await getToken()
}
} else {
if (!tokenData) return await getToken()
const expired = dayjs(tokenData.created_at)
.add(tokenData.access_expires, "seconds")
.isBefore(dayjs())
if (expired) {
server.log.info("Refreshing expired GoCardless token …")
await getToken()
}
}
// 🔹 Generate Link
// ------------------------------------------------------------------
// 🔗 Create GoCardless Banking Link
// ------------------------------------------------------------------
server.get("/banking/link/:institutionid", async (req, reply) => {
await checkToken()
const {institutionid} = req.params as {institutionid: string}
try {
const { data } = await axios({
url: `${goCardLessBaseUrl}/requisitions/`,
method: "POST",
headers: {
Authorization: `Bearer ${tokenData.access}`,
accept: "application/json",
},
data: {
await checkToken()
const { institutionid } = req.params as { institutionid: string }
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const { data } = await axios.post(
`${goCardLessBaseUrl}/requisitions/`,
{
redirect: "https://app.fedeo.de/settings/banking",
institution_id: institutionid,
user_language: "de",
},
{
headers: { Authorization: `Bearer ${tokenData.access}` },
}
)
// DB: Requisition speichern
await server.db.insert(bankrequisitions).values({
id: data.id,
tenant: tenantId,
institutionId: institutionid,
status: data.status,
})
await server.supabase
.from("bankrequisitions")
.insert({
tenant: req.user.tenant_id,
institutionId: institutionid,
id: data.id,
status: data.status,
})
return reply.send({ link: data.link })
} catch (err) {
server.log.error(err.response?.data || err.message)
} catch (err: any) {
server.log.error(err?.response?.data || err)
return reply.code(500).send({ error: "Failed to generate link" })
}
})
// 🔹 Check Institution
// ------------------------------------------------------------------
// 🏦 Check Bank Institutions
// ------------------------------------------------------------------
server.get("/banking/institutions/:bic", async (req, reply) => {
const { bic } = req.params as {bic: string}
if (!bic) return reply.code(400).send("BIC not provided")
await checkToken()
try {
const { data } = await axios({
url: `${goCardLessBaseUrl}/institutions/?country=de`,
method: "GET",
headers: {
Authorization: `Bearer ${tokenData.access}`,
},
})
const { bic } = req.params as { bic: string }
if (!bic) return reply.code(400).send("BIC missing")
await checkToken()
const { data } = await axios.get(
`${goCardLessBaseUrl}/institutions/?country=de`,
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
)
const bank = data.find((i: any) => i.bic.toLowerCase() === bic.toLowerCase())
const bank = data.find((i) => i.bic.toLowerCase() === bic.toLowerCase())
if (!bank) return reply.code(404).send("Bank not found")
return reply.send(bank)
} catch (err) {
server.log.error(err.response?.data || err.message)
} catch (err: any) {
server.log.error(err?.response?.data || err)
return reply.code(500).send("Failed to fetch institutions")
}
})
// 🔹 List Requisitions
// ------------------------------------------------------------------
// 📄 Get Requisition Details
// ------------------------------------------------------------------
server.get("/banking/requisitions/:reqId", async (req, reply) => {
const { reqId } = req.params as {reqId: string}
if (!reqId) return reply.code(400).send("Requisition ID not provided")
await checkToken()
try {
const { data } = await axios({
url: `${goCardLessBaseUrl}/requisitions/${reqId}`,
method: "GET",
headers: {
Authorization: `Bearer ${tokenData.access}`,
},
})
const { reqId } = req.params as { reqId: string }
if (!reqId) return reply.code(400).send("Requisition ID missing")
await checkToken()
const { data } = await axios.get(
`${goCardLessBaseUrl}/requisitions/${reqId}`,
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
)
// Load account details
if (data.accounts) {
data.accounts = await Promise.all(
data.accounts.map(async (accId) => {
const { data: accountData } = await axios({
url: `${goCardLessBaseUrl}/accounts/${accId}`,
method: "GET",
headers: {
Authorization: `Bearer ${tokenData.access}`,
accept: "application/json",
},
})
return accountData
data.accounts.map(async (accId: string) => {
const { data: acc } = await axios.get(
`${goCardLessBaseUrl}/accounts/${accId}`,
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
)
return acc
})
)
}
return reply.send(data)
} catch (err) {
server.log.error(err.response?.data || err.message)
return reply.code(500).send("Failed to fetch requisition data")
} catch (err: any) {
server.log.error(err?.response?.data || err)
return reply.code(500).send("Failed to fetch requisition details")
}
})
//Create Banking Statement
// ------------------------------------------------------------------
// 💰 Create Statement Allocation
// ------------------------------------------------------------------
server.post("/banking/statements", async (req, reply) => {
if (!req.user) {
return reply.code(401).send({ error: "Unauthorized" });
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { data: payload } = req.body as { data: any }
const inserted = await server.db.insert(statementallocations).values({
...payload,
tenant: req.user.tenant_id
}).returning()
const createdRecord = inserted[0]
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: createdRecord.id,
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: createdRecord,
text: "Buchung erstellt",
})
return reply.send(createdRecord)
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Failed to create statement" })
}
})
const body = req.body as { data: string };
console.log(body);
const {data,error} = await server.supabase.from("statementallocations").insert({
//@ts-ignore
...body.data,
tenant: req.user.tenant_id,
}).select()
await insertHistoryItem(server,{
entity: "bankstatements",
//@ts-ignore
entityId: data.id,
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: data,
text: `Buchung erstellt`,
});
if(data && !error){
return reply.send(data)
}
});
//Delete Banking Statement
// ------------------------------------------------------------------
// 🗑 Delete Statement Allocation
// ------------------------------------------------------------------
server.delete("/banking/statements/:id", async (req, reply) => {
if (!req.user) {
return reply.code(401).send({ error: "Unauthorized" });
}
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id?: string }
const { id } = req.params as { id: string }
const {data} = await server.supabase.from("statementallocations").select().eq("id",id).single()
const oldRecord = await server.db
.select()
.from(statementallocations)
.where(eq(statementallocations.id, id))
.limit(1)
const {error} = await server.supabase.from("statementallocations").delete().eq("id",id)
const old = oldRecord[0]
if(!error){
if (!old) return reply.code(404).send({ error: "Record not found" })
await insertHistoryItem(server,{
await server.db
.delete(statementallocations)
.where(eq(statementallocations.id, id))
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: id,
action: "deleted",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: data,
oldVal: old,
newVal: null,
text: `Buchung gelöscht`,
});
text: "Buchung gelöscht",
})
return reply.send({success:true})
} else {
return reply.code(500).send({error:"Fehler beim löschen"})
return reply.send({ success: true })
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Failed to delete statement" })
}
})
}

View File

@@ -12,7 +12,9 @@ import {
import {resourceConfig} from "../../resource.config";
import {resourceConfig} from "../../utils/resource.config";
import {useNextNumberRangeNumber} from "../../utils/functions";
import {stafftimeentries} from "../../../db/schema";
// -------------------------------------------------------------
// SQL Volltextsuche auf mehreren Feldern
@@ -87,13 +89,12 @@ export default async function resourceRoutes(server: FastifyInstance) {
const queryData = await q
// RELATION LOADING (MANY-TO-ONE)
let ids = {}
let lists = {}
let maps = {}
let data = []
let data = [...queryData]
if(resourceConfig[resource].mtoLoad) {
resourceConfig[resource].mtoLoad.forEach(relation => {
@@ -101,6 +102,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
})
for await (const relation of resourceConfig[resource].mtoLoad ) {
console.log(relation)
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
}
@@ -120,8 +122,29 @@ export default async function resourceRoutes(server: FastifyInstance) {
return toReturn
});
} else {
data = queryData
}
if(resourceConfig[resource].mtmListLoad) {
for await (const relation of resourceConfig[resource].mtmListLoad) {
console.log(relation)
console.log(resource.substring(0,resource.length-1))
const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id)))
console.log(relationRows.length)
data = data.map(row => {
let toReturn = {
...row
}
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
return toReturn
})
}
}
return data
@@ -271,14 +294,14 @@ export default async function resourceRoutes(server: FastifyInstance) {
};
}
// RELATION LOADING (MANY-TO-ONE)
let ids = {}
let lists = {}
let maps = {}
let data = []
let data = [...rows]
//Many to One
if(resourceConfig[resource].mtoLoad) {
let ids = {}
let lists = {}
let maps = {}
resourceConfig[resource].mtoLoad.forEach(relation => {
ids[relation] = [...new Set(rows.map(r => r[relation]).filter(Boolean))];
})
@@ -303,8 +326,28 @@ export default async function resourceRoutes(server: FastifyInstance) {
return toReturn
});
} else {
data = rows
}
if(resourceConfig[resource].mtmListLoad) {
for await (const relation of resourceConfig[resource].mtmListLoad) {
console.log(relation)
const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id)))
console.log(relationRows)
data = data.map(row => {
let toReturn = {
...row
}
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
return toReturn
})
}
}
// -----------------------------------------------
@@ -365,13 +408,13 @@ export default async function resourceRoutes(server: FastifyInstance) {
data[relation] = await server.db.select().from(resourceConfig[relation + "s"].table).where(eq(resourceConfig[relation + "s"].table.id, data[relation]))
}
}
}
if(resourceConfig[resource].mtmLoad) {
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
@@ -381,4 +424,112 @@ export default async function resourceRoutes(server: FastifyInstance) {
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// 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 table = resourceConfig[resource].table
let createData = {
...body,
tenant: req.user.tenant_id,
archived: false, // Standardwert
}
if (resourceConfig[resource].numberRangeHolder && !body[resourceConfig[resource]]) {
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, resource)
createData[resourceConfig[resource]] = result.usedNumber
}
const [created] = await server.db
.insert(table)
.values(createData)
.returning()
/*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 created;
});
// UPDATE (inkl. Soft-Delete/Archive)
server.put("/resource/:resource/:id", async (req, reply) => {
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"})
}
const table = resourceConfig[resource].table
//TODO: HISTORY
const normalizeDate = (val: any) => {
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
console.log(body)
Object.keys(body).forEach((key) => {
if(key.includes("_at") || key.includes("At")) {
body[key] = normalizeDate(body[key])
}
})
// vorherige Version für History laden
/*const {data: oldItem} = await server.supabase
.from(resource)
.select("*")
.eq("id", id)
.eq("tenant", tenantId)
.single()*/
const [updated] = await server.db
.update(table)
.set({...body, updated_at: new Date().toISOString(), updated_by: userId})
.where(and(
eq(table.id, id),
eq(table.tenant, tenantId)))
.returning()
//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 updated
})
}

View File

@@ -1,143 +1,236 @@
import { FastifyInstance } from 'fastify'
import { StaffTimeEntry } from '../../types/staff'
import { FastifyInstance } from "fastify"
import {
stafftimeentries,
stafftimenetryconnects
} from "../../../db/schema"
import {
eq,
and,
gte,
lte,
desc
} from "drizzle-orm"
export default async function staffTimeRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// ▶ Neue Zeit starten
server.post(
'/staff/time',
async (req, reply) => {
const { started_at, stopped_at, type = 'work', description, user_id } = req.body as any
// -------------------------------------------------------------
server.post("/staff/time", async (req, reply) => {
try {
const userId = req.user.user_id
const tenantId = req.user.tenant_id
const body = req.body as any
let dataToInsert = {
tenant_id: tenantId,
user_id: user_id ? user_id : userId,
// @ts-ignore
...req.body
const normalizeDate = (val: any) => {
if (!val) return null
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
const { data, error } = await server.supabase
.from('staff_time_entries')
.insert([dataToInsert])
.select()
.maybeSingle()
const dataToInsert = {
tenant_id: tenantId,
user_id: body.user_id || userId,
type: body.type || "work",
description: body.description || null,
started_at: normalizeDate(body.started_at),
stopped_at: normalizeDate(body.stopped_at),
}
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
const [created] = await server.db
.insert(stafftimeentries)
.values(dataToInsert)
.returning()
return created
} catch (err: any) {
console.error(err)
return reply.code(400).send({ error: err.message })
}
)
})
// -------------------------------------------------------------
// ▶ Zeit stoppen
server.put<{ Params: { id: string }, Body: { stopped_at: string } }>(
'/staff/time/:id/stop',
async (req, reply) => {
// -------------------------------------------------------------
server.put<{
Params: { id: string },
Body: { stopped_at: string }
}>("/staff/time/:id/stop", async (req, reply) => {
try {
const { id } = req.params
const { stopped_at } = req.body
const { data, error } = await server.supabase
.from('staff_time_entries')
.update({ stopped_at, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.maybeSingle()
// Normalize timestamp
const normalizeDate = (val: any) => {
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
const stopTime = normalizeDate(stopped_at)
if (!stopTime) {
return reply.code(400).send({ error: "Invalid stopped_at timestamp" })
}
const [updated] = await server.db
.update(stafftimeentries)
.set({
stopped_at: stopTime,
updated_at: new Date(),
})
.where(eq(stafftimeentries.id, id))
.returning()
if (!updated) {
return reply.code(404).send({ error: "Time entry not found" })
}
return reply.send(updated)
} catch (err: any) {
console.error("STOP ERROR:", err)
return reply.code(500).send({ error: err.message || "Internal server error" })
}
)
})
// -------------------------------------------------------------
// ▶ Liste aller Zeiten
server.get<{
Querystring: {
from?: string
to?: string
type?: string
user_id?: string
// -------------------------------------------------------------
server.get("/staff/time", async (req, reply) => {
try {
const { from, to, type, user_id } = req.query as any
const { tenant_id, user_id: currentUserId } = req.user
let where = and(eq(stafftimeentries.tenant_id, tenant_id))
// Zugriffsbeschränkung
if (!req.hasPermission("staff.time.read_all")) {
where = and(where, eq(stafftimeentries.user_id, currentUserId))
} else if (user_id) {
where = and(where, eq(stafftimeentries.user_id, user_id))
}
if (from) where = and(where, gte(stafftimeentries.started_at, from))
if (to) where = and(where, lte(stafftimeentries.started_at, to))
if (type) where = and(where, eq(stafftimeentries.type, type))
const rows = await server.db
.select()
.from(stafftimeentries)
.where(where)
.orderBy(desc(stafftimeentries.started_at))
return rows
} catch (err) {
console.error(err)
return reply.code(400).send({ error: (err as Error).message })
}
}>('/staff/time', async (req, reply) => {
const { from, to, type, user_id } = req.query
const { user_id: currentUserId, tenant_id } = req.user
})
// 🧩 Basis-Query für den Tenant
let query = server.supabase
.from('staff_time_entries')
.select('*')
.eq('tenant_id', tenant_id)
.order('started_at', { ascending: false })
// -------------------------------------------------------------
// ▶ Einzelne Zeit (inkl. Connects)
// -------------------------------------------------------------
server.get("/staff/time/:id", async (req, reply) => {
try {
const { id } = req.params as any
// 🔒 Zugriffsbeschränkung: nur eigene Zeiten, außer Berechtigung erlaubt mehr
if (!req.hasPermission('staff.time.read_all')) {
query = query.eq('user_id', currentUserId)
} else if (user_id) {
// falls explizit user_id angegeben wurde
query = query.eq('user_id', user_id)
const rows = await server.db
.select()
.from(stafftimeentries)
.where(eq(stafftimeentries.id, id))
.limit(1)
if (!rows.length) return reply.code(404).send({ error: "Not found" })
const entry = rows[0]
const connects = await server.db
.select()
.from(stafftimenetryconnects)
.where(eq(stafftimenetryconnects.stafftimeentry, id))
return {
...entry,
staff_time_entry_connects: connects
}
} catch (err) {
return reply.code(400).send({ error: (err as Error).message })
}
})
// 📅 Zeitfilter
if (from) query = query.gte('started_at', from)
if (to) query = query.lte('started_at', to)
if (type) query = query.eq('type', type)
// -------------------------------------------------------------
// ▶ Zeit bearbeiten
// -------------------------------------------------------------
// ▶ Zeit bearbeiten
server.put<{
Params: { id: string },
}>("/staff/time/:id", async (req, reply) => {
try {
const { id } = req.params
const body = req.body
const { data, error } = await query
if (error) return reply.code(400).send({ error: error.message })
// Normalize all timestamp fields
const normalizeDate = (val: any) => {
if (!val) return null
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
return reply.send(data)
const updateData: any = {
// @ts-ignore
...body,
updated_at: new Date(),
}
// Only convert if present — avoid overriding with null unless sent
// @ts-ignore
if (body.started_at !== undefined) {
// @ts-ignore
updateData.started_at = normalizeDate(body.started_at)
}
// @ts-ignore
if (body.stopped_at !== undefined) {
// @ts-ignore
updateData.stopped_at = normalizeDate(body.stopped_at)
}
// @ts-ignore
if (body.approved_at !== undefined) {
// @ts-ignore
updateData.approved_at = normalizeDate(body.approved_at)
}
const [updated] = await server.db
.update(stafftimeentries)
.set(updateData)
.where(eq(stafftimeentries.id, id))
.returning()
if (!updated) {
return reply.code(404).send({ error: "Time entry not found" })
}
return reply.send(updated)
} catch (err: any) {
console.error("UPDATE ERROR:", err)
return reply.code(500).send({ error: err.message || "Internal server error" })
}
})
// ▶ Einzelne Zeit abrufen (inkl. Connects)
server.get<{ Params: { id: string } }>(
'/staff/time/:id',
async (req, reply) => {
const { id } = req.params
const { data, error } = await server.supabase
.from('staff_time_entries')
.select(`
*,
staff_time_entry_connects(*)
`)
.eq('id', id)
.maybeSingle()
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
}
)
// ▶ Zeit bearbeiten
server.put<{ Params: { id: string }, Body: Partial<StaffTimeEntry> }>(
'/staff/time/:id',
async (req, reply) => {
const { id } = req.params
const { data, error } = await server.supabase
.from('staff_time_entries')
.update({ ...req.body, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.maybeSingle()
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
}
)
// -------------------------------------------------------------
// ▶ Zeit löschen
server.delete<{ Params: { id: string } }>(
'/staff/time/:id',
async (req, reply) => {
const { id } = req.params
const { error } = await server.supabase
.from('staff_time_entries')
.delete()
.eq('id', id)
// -------------------------------------------------------------
server.delete("/staff/time/:id", async (req, reply) => {
try {
const { id } = req.params as any
if (error) return reply.code(400).send({ error: error.message })
return reply.send({ success: true })
await server.db
.delete(stafftimeentries)
.where(eq(stafftimeentries.id, id))
return { success: true }
} catch (err) {
return reply.code(400).send({ error: (err as Error).message })
}
)
})
}

View File

@@ -1,188 +1,244 @@
import { FastifyInstance } from "fastify";
import jwt from "jsonwebtoken";
import {secrets} from "../utils/secrets";
import { FastifyInstance } from "fastify"
import jwt from "jsonwebtoken"
import { secrets } from "../utils/secrets"
export default async function routes(server: FastifyInstance) {
import {
authTenantUsers,
authUsers,
authProfiles,
tenants
} from "../../db/schema"
import {and, eq, inArray} from "drizzle-orm"
export default async function tenantRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// GET CURRENT TENANT
// -------------------------------------------------------------
server.get("/tenant", async (req) => {
if(req.tenant) {
if (req.tenant) {
return {
message: `Hallo vom Tenant ${req.tenant?.name}`,
tenant_id: req.tenant?.id,
};
} else {
return {
message: `Server ist in MultiTenant Mode. Sie bekommen alles für Sie verfügbare`,
};
}
});
server.post("/tenant/switch", async (req, reply) => {
if (!req.user) {
return reply.code(401).send({ error: "Unauthorized" });
}
const body = req.body as { tenant_id: string };
console.log(body);
// prüfen ob user im Tenant Mitglied ist
const { data: tenantUser, error } = await server.supabase
.from("auth_tenant_users")
.select("*")
.eq("user_id", req.user.user_id)
.eq("tenant_id", body.tenant_id)
.single();
if (error || !tenantUser) {
return reply.code(403).send({ error: "Not a member of this tenant" });
}
// neues JWT mit tenant_id ausstellen
const token = jwt.sign(
{
user_id: req.user.user_id,
email: req.user.email,
tenant_id: body.tenant_id,
},
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
})
return { token };
});
server.get("/tenant/users", async (req, reply) => {
const { tenant_id } = req.params as { tenant_id: string };
const authUser = req.user // kommt aus JWT (user_id + tenant_id)
if (!authUser) {
return reply.code(401).send({ error: "Unauthorized" })
}
const { data, error } = await server.supabase
.from("auth_tenant_users")
.select(`
user_id,
auth_users!tenantusers_user_id_fkey ( id, email, created_at, auth_profiles(*))`)
.eq("tenant_id", authUser.tenant_id);
if (error) {
console.log(error);
return reply.code(400).send({ error: error.message });
}
let correctedData = data.map(i => {
return {
id: i.user_id,
// @ts-ignore
email: i.auth_users.email,
// @ts-ignore
profile: i.auth_users.auth_profiles.find(x => x.tenant_id === authUser.tenant_id),
// @ts-ignore
full_name: i.auth_users.auth_profiles.find(x => x.tenant_id === authUser.tenant_id)?.full_name,
}
})
}
return {
message: "Server ist im MultiTenant-Modus es werden alle verfügbaren Tenants geladen."
}
})
return { tenant_id, users: correctedData };
});
// -------------------------------------------------------------
// SWITCH TENANT
// -------------------------------------------------------------
server.post("/tenant/switch", async (req, reply) => {
try {
if (!req.user) {
return reply.code(401).send({ error: "Unauthorized" })
}
const { tenant_id } = req.body as { tenant_id: string }
if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" })
// prüfen ob der User zu diesem Tenant gehört
const membership = await server.db
.select()
.from(authTenantUsers)
.where(and(
eq(authTenantUsers.user_id, req.user.user_id),
eq(authTenantUsers.tenant_id, Number(tenant_id))
))
if (!membership.length) {
return reply.code(403).send({ error: "Not a member of this tenant" })
}
// JWT neu erzeugen
const token = jwt.sign(
{
user_id: req.user.user_id,
email: req.user.email,
tenant_id,
},
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 }
} catch (err) {
console.error("TENANT SWITCH ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// -------------------------------------------------------------
// TENANT USERS (auth_users + auth_profiles)
// -------------------------------------------------------------
server.get("/tenant/users", async (req, reply) => {
try {
const authUser = req.user
if (!authUser) return reply.code(401).send({ error: "Unauthorized" })
const tenantId = authUser.tenant_id
// 1) auth_tenant_users → user_ids
const tenantUsers = await server.db
.select()
.from(authTenantUsers)
.where(eq(authTenantUsers.tenant_id, tenantId))
const userIds = tenantUsers.map(u => u.user_id)
if (!userIds.length) {
return { tenant_id: tenantId, users: [] }
}
// 2) auth_users laden
const users = await server.db
.select()
.from(authUsers)
.where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden
const profiles = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds)
))
const combined = users.map(u => {
const profile = profiles.find(p => p.user_id === u.id)
return {
id: u.id,
email: u.email,
profile,
full_name: profile?.full_name ?? null
}
})
return { tenant_id: tenantId, users: combined }
} catch (err) {
console.error("/tenant/users ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// -------------------------------------------------------------
// TENANT PROFILES
// -------------------------------------------------------------
server.get("/tenant/profiles", async (req, reply) => {
const { tenant_id } = req.params as { tenant_id: string };
const authUser = req.user // kommt aus JWT (user_id + tenant_id)
try {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
if (!authUser) {
return reply.code(401).send({ error: "Unauthorized" })
}
const { data, error } = await server.supabase
.from("auth_profiles")
.select()
.eq("tenant_id", authUser.tenant_id);
if (error) {
console.log(error);
return reply.code(400).send({ error: error.message });
const data = await server.db
.select()
.from(authProfiles)
.where(eq(authProfiles.tenant_id, tenantId))
return { data }
} catch (err) {
console.error("/tenant/profiles ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
return { data };
});
// -------------------------------------------------------------
// UPDATE NUMBER RANGE
// -------------------------------------------------------------
server.put("/tenant/numberrange/:numberrange", async (req, reply) => {
if (!req.user) {
return reply.code(401).send({ error: "Unauthorized" });
try {
const user = req.user
if (!user) return reply.code(401).send({ error: "Unauthorized" })
const { numberrange } = req.params as { numberrange: string }
const { numberRange } = req.body as { numberRange: any }
if (!numberRange) {
return reply.code(400).send({ error: "numberRange required" })
}
const tenantId = Number(user.tenant_id)
const currentTenantRows = await server.db
.select()
.from(tenants)
.where(eq(tenants.id, tenantId))
const current = currentTenantRows[0]
if (!current) return reply.code(404).send({ error: "Tenant not found" })
const updatedRanges = {
//@ts-ignore
...current.numberRanges,
[numberrange]: numberRange
}
const updated = await server.db
.update(tenants)
.set({ numberRanges: updatedRanges })
.where(eq(tenants.id, tenantId))
.returning()
return updated[0]
} catch (err) {
console.error("/tenant/numberrange ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
const { numberrange } = req.params as { numberrange?: string }
const body = req.body as { numberRange: object };
console.log(body);
if(!body.numberRange) {
return reply.code(400).send({ error: "numberRange required" });
}
const {data:currentTenantData,error:numberRangesError} = await server.supabase.from("tenants").select().eq("id", req.user.tenant_id).single()
console.log(currentTenantData)
console.log(numberRangesError)
})
let numberRanges = {
// @ts-ignore
...currentTenantData.numberRanges
}
// @ts-ignore
numberRanges[numberrange] = body.numberRange
console.log(numberRanges)
const {data,error} = await server.supabase
.from("tenants")
.update({numberRanges: numberRanges})
.eq('id',req.user.tenant_id)
.select()
if(data && !error) {
return reply.send(data)
}
});
// -------------------------------------------------------------
// UPDATE TENANT OTHER FIELDS
// -------------------------------------------------------------
server.put("/tenant/other/:id", async (req, reply) => {
if (!req.user) {
return reply.code(401).send({ error: "Unauthorized" });
try {
const user = req.user
if (!user) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
const { data } = req.body as { data: any }
if (!data) return reply.code(400).send({ error: "data required" })
const updated = await server.db
.update(tenants)
.set(data)
.where(eq(tenants.id, Number(user.tenant_id)))
.returning()
return updated[0]
} catch (err) {
console.error("/tenant/other ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
const { id } = req.params as { id?: string }
})
const body = req.body as { data: object };
console.log(body);
if(!body.data) {
return reply.code(400).send({ error: "data required" });
}
const {data:dataReturn,error} = await server.supabase
.from("tenants")
.update(body.data)
.eq('id',req.user.tenant_id)
.select()
if(dataReturn && !error) {
return reply.send(dataReturn)
}
});
}
}

View File

@@ -1,43 +1,67 @@
// 🔧 Hilfsfunktionen
import {FastifyInstance} from "fastify";
import { FastifyInstance } from "fastify"
import { eq, ilike, and } from "drizzle-orm"
import { contacts, customers } from "../../db/schema"
// -------------------------------------------------------------
// Extract Domain
// -------------------------------------------------------------
export function extractDomain(email: string) {
if (!email) return null
const parts = email.split('@')
const parts = email.split("@")
return parts.length === 2 ? parts[1].toLowerCase() : null
}
export async function findCustomerOrContactByEmailOrDomain(server:FastifyInstance, fromMail: string, tenantId: number) {
// -------------------------------------------------------------
// Kunde oder Kontakt anhand E-Mail oder Domain finden
// -------------------------------------------------------------
export async function findCustomerOrContactByEmailOrDomain(
server: FastifyInstance,
fromMail: string,
tenantId: number
) {
const sender = fromMail.toLowerCase()
const senderDomain = extractDomain(sender)
if (!senderDomain) return null
// 1⃣ Direkter Match über contacts
const { data: contactMatch } = await server.supabase
.from('contacts')
.select('id, customer')
.eq('email', sender)
.eq('tenant', tenantId)
.maybeSingle()
// 1⃣ Direkter Match über Contacts (email)
const contactMatch = await server.db
.select({
id: contacts.id,
customer: contacts.customer,
})
.from(contacts)
.where(
and(
eq(contacts.email, sender),
eq(contacts.tenant, tenantId)
)
)
.limit(1)
if (contactMatch?.customer) {
return { customer: contactMatch.customer, contact: contactMatch.id }
if (contactMatch.length && contactMatch[0].customer) {
return {
customer: contactMatch[0].customer,
contact: contactMatch[0].id,
}
}
// 2⃣ Kunden nach Domain oder Rechnungs-E-Mail durchsuchen
const { data: customers, error } = await server.supabase
.from('customers')
.select('id, infoData')
.eq('tenant', tenantId)
// 2⃣ Kunden anhand Domain vergleichen
const allCustomers = await server.db
.select({
id: customers.id,
infoData: customers.infoData,
})
.from(customers)
.where(eq(customers.tenant, tenantId))
if (error) {
server.log.error(`[Helpdesk] Fehler beim Laden der Kunden: ${error.message}`)
return null
}
for (const c of customers || []) {
for (const c of allCustomers) {
const info = c.infoData || {}
// @ts-ignore
const email = info.email?.toLowerCase()
//@ts-ignore
const invoiceEmail = info.invoiceEmail?.toLowerCase()
const emailDomain = extractDomain(email)
const invoiceDomain = extractDomain(invoiceEmail)
@@ -55,18 +79,28 @@ export async function findCustomerOrContactByEmailOrDomain(server:FastifyInstanc
return null
}
// -------------------------------------------------------------
// getNestedValue (für Sortierung & Suche im Backend)
// -------------------------------------------------------------
export function getNestedValue(obj: any, path: string): any {
return path.split('.').reduce((acc, part) => acc?.[part], obj);
return path
.split(".")
.reduce((acc, part) => (acc && acc[part] !== undefined ? acc[part] : undefined), obj)
}
// -------------------------------------------------------------
// compareValues (Sortierung für paginated)
// -------------------------------------------------------------
export function compareValues(a: any, b: any): number {
if (a === b) return 0;
if (a == null) return 1;
if (b == null) return -1;
if (a === b) return 0
if (a == null) return 1
if (b == null) return -1
if (typeof a === 'string' && typeof b === 'string') {
return a.localeCompare(b);
// String Compare
if (typeof a === "string" && typeof b === "string") {
return a.localeCompare(b)
}
return a < b ? -1 : 1;
}
// Numerisch
return a < b ? -1 : 1
}

View File

@@ -1,26 +1,29 @@
import {
accounts, bankaccounts, bankrequisitions, bankstatements,
contacts,
contracts, costcentres, createddocuments,
customers,
files, filetags, folders, hourrates, inventoryitemgroups,
files, filetags, folders, hourrates, incominginvoices, inventoryitemgroups,
inventoryitems, letterheads, ownaccounts,
plants, productcategories, products,
projects,
projecttypes, servicecategories, services, spaces, tasks, texttemplates, units, vehicles,
projecttypes, servicecategories, services, spaces, statementallocations, tasks, texttemplates, units, vehicles,
vendors
} from "../db/schema";
} from "../../db/schema";
export const resourceConfig = {
projects: {
searchColumns: ["name"],
mtoLoad: ["customer","plant","contract","projecttype"],
mtmLoad: ["tasks", "files"],
table: projects
table: projects,
numberRangeHolder: "projectNumber"
},
customers: {
searchColumns: ["name", "customerNumber", "firstname", "lastname", "notes"],
mtmLoad: ["contacts","projects"],
table: customers,
numberRangeHolder: "customerNumber",
},
contacts: {
searchColumns: ["firstName", "lastName", "email", "phone", "notes"],
@@ -29,7 +32,8 @@ export const resourceConfig = {
},
contracts: {
table: contracts,
searchColumns: ["name", "notes", "contractNumber", "paymentType", "sepaRef", "bankingName"]
searchColumns: ["name", "notes", "contractNumber", "paymentType", "sepaRef", "bankingName"],
numberRangeHolder: "contractNumber",
},
plants: {
table: plants,
@@ -42,6 +46,7 @@ export const resourceConfig = {
vendors: {
table: vendors,
searchColumns: ["name","vendorNumber","notes","defaultPaymentType"],
numberRangeHolder: "vendorNumber",
},
files: {
table: files
@@ -53,7 +58,8 @@ export const resourceConfig = {
table: filetags
},
inventoryitems: {
table: inventoryitems
table: inventoryitems,
numberRangeHolder: "articleNumber",
},
inventoryitemgroups: {
table: inventoryitemgroups
@@ -87,6 +93,7 @@ export const resourceConfig = {
spaces: {
table: spaces,
searchColumns: ["name","space_number","type","info_data"],
numberRangeHolder: "spaceNumber",
},
ownaccounts: {
table: ownaccounts,
@@ -95,7 +102,8 @@ export const resourceConfig = {
costcentres: {
table: costcentres,
searchColumns: ["name","number","description"],
mtoLoad: ["vehicle","project","inventoryitem"]
mtoLoad: ["vehicle","project","inventoryitem"],
numberRangeHolder: "number",
},
tasks: {
table: tasks,
@@ -106,9 +114,34 @@ export const resourceConfig = {
},
createddocuments: {
table: createddocuments,
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead",]
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead"],
mtmLoad: ["statementallocations"],
mtmListLoad: ["statementallocations"],
},
texttemplates: {
table: texttemplates
},
incominginvoices: {
table: incominginvoices,
mtmLoad: ["statementallocations"],
mtmListLoad: ["statementallocations"],
},
statementallocations: {
table: statementallocations,
mtoLoad: ["customer","vendor","incominginvoice","createddocument","ownaccount","bankstatement"]
},
accounts: {
table: accounts,
},
bankstatements: {
table: bankstatements,
mtmListLoad: ["statementallocations"],
mtmLoad: ["statementallocations"],
},
bankaccounts: {
table: bankaccounts,
},
bankrequisitions: {
table: bankrequisitions,
}
}

View File

@@ -8,8 +8,9 @@
"esModuleInterop": true,
"skipLibCheck": true,
"noEmitOnError": false,
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"rootDir": "."
},
"include": ["src"],
"include": ["src","db","*.ts"],
"exclude": ["node_modules", "dist"]
}