Added Backend

This commit is contained in:
2026-01-06 12:07:43 +01:00
parent b013ef8f4b
commit 6f3d4c0bff
165 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,430 @@
import { FastifyInstance } from "fastify"
import {
stafftimeentries,
stafftimenetryconnects
} from "../../../db/schema"
import {
eq,
and,
gte,
lte,
desc
} from "drizzle-orm"
import {stafftimeevents} from "../../../db/schema/staff_time_events";
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
import {z} from "zod";
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
export default async function staffTimeRoutes(server: FastifyInstance) {
server.post("/staff/time/event", async (req, reply) => {
try {
const userId = req.user.user_id
const tenantId = req.user.tenant_id
const body = req.body as any
const normalizeDate = (val: any) => {
if (!val) return null
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
const dataToInsert = {
tenant_id: tenantId,
user_id: userId,
actortype: "user",
actoruser_id: userId,
eventtime: normalizeDate(body.eventtime),
eventtype: body.eventtype,
source: "WEB",
payload: body.payload // Payload (z.B. Description) mit speichern
}
console.log(dataToInsert)
const [created] = await server.db
.insert(stafftimeevents)
//@ts-ignore
.values(dataToInsert)
.returning()
return created
} catch (err: any) {
console.error(err)
return reply.code(400).send({ error: err.message })
}
})
// 🆕 POST /staff/time/edit (Bearbeiten durch Invalidieren + Neu erstellen)
server.post("/staff/time/edit", async (req, reply) => {
try {
const userId = req.user.user_id;
const tenantId = req.user.tenant_id;
// Wir erwarten das komplette Paket für die Änderung
const {
originalEventIds, // Array der IDs, die "gelöscht" werden sollen (Start ID, End ID)
newStart, // ISO String
newEnd, // ISO String
newType, // z.B. 'work', 'vacation'
description,
reason // Warum wurde geändert? (Audit)
} = req.body as {
originalEventIds: string[],
newStart: string,
newEnd: string | null,
newType: string,
description?: string,
reason?: string
};
if (!originalEventIds || originalEventIds.length === 0) {
return reply.code(400).send({ error: "Keine Events zum Bearbeiten angegeben." });
}
// 1. Transaction starten (damit alles oder nichts passiert)
await server.db.transaction(async (tx) => {
// A. INVALIDIEREN (Die alten Events "löschen")
// Wir erstellen für jedes alte Event ein 'invalidated' Event
const invalidations = originalEventIds.map(id => ({
tenant_id: tenantId,
user_id: userId, // Gehört dem Mitarbeiter
actortype: "user",
actoruser_id: userId, // Wer hat geändert?
eventtime: new Date(),
eventtype: "invalidated", // <--- NEUER TYP: Muss in loadValidEvents gefiltert werden!
source: "WEB",
related_event_id: id, // Zeigt auf das alte Event
metadata: {
reason: reason || "Bearbeitung",
replaced_by_edit: true
}
}));
// Batch Insert
// @ts-ignore
await tx.insert(stafftimeevents).values(invalidations);
// B. NEU ERSTELLEN (Die korrigierten Events anlegen)
// Start Event
// @ts-ignore
await tx.insert(stafftimeevents).values({
tenant_id: tenantId,
user_id: userId,
actortype: "user",
actoruser_id: userId,
eventtime: new Date(newStart),
eventtype: `${newType}_start`, // z.B. work_start
source: "WEB",
payload: { description: description || "" }
});
// End Event (nur wenn vorhanden)
if (newEnd) {
// @ts-ignore
await tx.insert(stafftimeevents).values({
tenant_id: tenantId,
user_id: userId,
actortype: "user",
actoruser_id: userId,
eventtime: new Date(newEnd),
eventtype: `${newType}_end`, // z.B. work_end
source: "WEB"
});
}
});
return { success: true };
} catch (err: any) {
console.error("Fehler beim Bearbeiten:", err);
return reply.code(500).send({ error: err.message });
}
});
// POST /staff/time/submit
server.post("/staff/time/submit", async (req, reply) => {
try {
const userId = req.user.user_id; // Mitarbeiter, der einreicht
const tenantId = req.user.tenant_id;
// Erwartet eine Liste von IDs der faktischen Events (work_start, work_end, etc.)
const { eventIds } = req.body as { eventIds: string[] };
if (eventIds.length === 0) {
return reply.code(400).send({ error: "Keine Events zum Einreichen angegeben." });
}
const inserts = eventIds.map((eventId) => ({
tenant_id: tenantId,
user_id: userId, // Event gehört zum Mitarbeiter
actortype: "user",
actoruser_id: userId, // Mitarbeiter ist der Akteur
eventtime: new Date(),
eventtype: "submitted", // NEU: Event-Typ für Einreichung
source: "WEB",
related_event_id: eventId, // Verweis auf das faktische Event
}));
const createdEvents = await server.db
.insert(stafftimeevents)
//@ts-ignore
.values(inserts)
.returning();
return { submittedCount: createdEvents.length };
} catch (err: any) {
console.error(err);
return reply.code(500).send({ error: err.message });
}
});
// POST /staff/time/approve
server.post("/staff/time/approve", async (req, reply) => {
try {
// 🚨 Berechtigungsprüfung (Voraussetzung: req.user enthält Manager-Status)
/*if (!req.user.isManager) {
return reply.code(403).send({ error: "Keine Genehmigungsberechtigung." });
}*/
const actorId = req.user.user_id; // Manager ist der Akteur
const tenantId = req.user.tenant_id;
const { eventIds, employeeUserId } = req.body as {
eventIds: string[];
employeeUserId: string; // Die ID des Mitarbeiters, dessen Zeit genehmigt wird
};
if (eventIds.length === 0) {
return reply.code(400).send({ error: "Keine Events zur Genehmigung angegeben." });
}
const inserts = eventIds.map((eventId) => ({
tenant_id: tenantId,
user_id: employeeUserId, // Event gehört zum Mitarbeiter
actortype: "user",
actoruser_id: actorId, // Manager ist der Akteur
eventtime: new Date(),
eventtype: "approved", // NEU: Event-Typ für Genehmigung
source: "WEB",
related_event_id: eventId, // Verweis auf das faktische Event
metadata: {
// Optional: Genehmigungskommentar
approvedBy: req.user.email
}
}));
const createdEvents = await server.db
.insert(stafftimeevents)
//@ts-ignore
.values(inserts)
.returning();
return { approvedCount: createdEvents.length };
} catch (err: any) {
console.error(err);
return reply.code(500).send({ error: err.message });
}
});
// POST /staff/time/reject
server.post("/staff/time/reject", async (req, reply) => {
try {
// 🚨 Berechtigungsprüfung
/*if (!req.user.isManager) {
return reply.code(403).send({ error: "Keine Zurückweisungsberechtigung." });
}*/
const actorId = req.user.user_id; // Manager ist der Akteur
const tenantId = req.user.tenant_id;
const { eventIds, employeeUserId, reason } = req.body as {
eventIds: string[];
employeeUserId: string;
reason?: string; // Optionaler Grund für die Ablehnung
};
if (eventIds.length === 0) {
return reply.code(400).send({ error: "Keine Events zur Ablehnung angegeben." });
}
const inserts = eventIds.map((eventId) => ({
tenant_id: tenantId,
user_id: employeeUserId, // Event gehört zum Mitarbeiter
actortype: "user",
actoruser_id: actorId, // Manager ist der Akteur
eventtime: new Date(),
eventtype: "rejected", // NEU: Event-Typ für Ablehnung
source: "WEB",
related_event_id: eventId, // Verweis auf das faktische Event
metadata: {
reason: reason || "Ohne Angabe"
}
}));
const createdEvents = await server.db
.insert(stafftimeevents)
//@ts-ignore
.values(inserts)
.returning();
return { rejectedCount: createdEvents.length };
} catch (err: any) {
console.error(err);
return reply.code(500).send({ error: err.message });
}
});
// GET /api/staff/time/spans
server.get("/staff/time/spans", async (req, reply) => {
try {
// Der eingeloggte User (Anfragesteller)
const actingUserId = req.user.user_id;
const tenantId = req.user.tenant_id;
// Query-Parameter: targetUserId ist optional
const { targetUserId } = req.query as { targetUserId?: string };
// Falls eine targetUserId übergeben wurde, nutzen wir diese, sonst die eigene ID
const evaluatedUserId = targetUserId || actingUserId;
// 💡 "Unendlicher" Zeitraum, wie gewünscht
const startDate = new Date(0); // 1970
const endDate = new Date("2100-12-31");
// SCHRITT 1: Lade ALLE Events für den ZIEL-USER (evaluatedUserId)
// WICHTIG: loadValidEvents muss "invalidated" Events und deren related_ids ausfiltern!
const allEventsInTimeFrame = await loadValidEvents(
server,
tenantId,
evaluatedUserId, // <--- Hier wird jetzt die variable ID genutzt
startDate,
endDate
);
// SCHRITT 2: Filtere faktische Events
const FACTUAL_EVENT_TYPES = new Set([
"work_start", "work_end", "pause_start", "pause_end",
"sick_start", "sick_end", "vacation_start", "vacation_end",
"overtime_compensation_start", "overtime_compensation_end",
"auto_stop"
]);
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
// SCHRITT 3: Hole administrative Events
const factualEventIds = factualEvents.map(e => e.id);
if (factualEventIds.length === 0) {
return [];
}
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
// SCHRITT 4: Kombinieren und Sortieren
const combinedEvents = [
...factualEvents,
...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
// SCHRITT 5: Spans ableiten
const derivedSpans = deriveTimeSpans(combinedEvents);
// SCHRITT 6: Spans anreichern
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
return enrichedSpans;
} catch (error) {
console.error("Fehler beim Laden der Spans:", error);
return reply.code(500).send({ error: "Interner Fehler beim Laden der Zeitspannen." });
}
});
server.get("/staff/time/evaluation", async (req, reply) => {
try {
// --- 1. Eingangsdaten und Validierung des aktuellen Nutzers ---
// Daten des aktuell eingeloggten (anfragenden) Benutzers
const actingUserId = req.user.user_id;
const tenantId = req.user.tenant_id;
// Query-Parameter extrahieren
const { from, to, targetUserId } = req.query as {
from: string,
to: string,
targetUserId?: string // Optionale ID des Benutzers, dessen Daten abgerufen werden sollen
};
// Die ID, für die die Auswertung tatsächlich durchgeführt wird
const evaluatedUserId = targetUserId || actingUserId;
const startDate = new Date(from);
const endDate = new Date(to);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return reply.code(400).send({ error: "Ungültiges Datumsformat." });
}
// --- 3. Ausführung der Logik für den ermittelten Benutzer ---
// SCHRITT 1: Lade ALLE gültigen Events im Zeitraum
// WICHTIG: loadValidEvents muss "invalidated" Events und deren related_ids ausfiltern!
const allEventsInTimeFrame = await loadValidEvents(
server, tenantId, evaluatedUserId, startDate, endDate // Verwendung der evaluatedUserId
);
// 1b: Trenne Faktische und Administrative Events
const FACTUAL_EVENT_TYPES = new Set([
"work_start", "work_end", "pause_start", "pause_end",
"sick_start", "sick_end", "vacation_start", "vacation_end",
"overtime_compensation_start", "overtime_compensation_end",
"auto_stop"
]);
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
// 1c: Sammle alle IDs der faktischen Events im Zeitraum
const factualEventIds = factualEvents.map(e => e.id);
// SCHRITT 2: Lade die administrativen Events, die sich auf diese IDs beziehen (auch NACH dem Zeitraum)
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
// SCHRITT 3: Kombiniere alle Events für die Weiterverarbeitung
const combinedEvents = [
...factualEvents,
...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
// SCHRITT 4: Ableiten und Anreichern
const derivedSpans = deriveTimeSpans(combinedEvents);
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
// SCHRITT 5: Erstellung der finalen Auswertung (Summen und Salden)
const evaluationSummary = await buildTimeEvaluationFromSpans(
server,
evaluatedUserId, // Verwendung der evaluatedUserId
tenantId,
from,
to,
enrichedSpans
);
return {
userId: evaluatedUserId, // Rückgabe der ID, für die ausgewertet wurde
spans: enrichedSpans,
summary: evaluationSummary
};
} catch (error) {
console.error("Fehler in /staff/time/evaluation:", error);
return reply.code(500).send({ error: "Interner Serverfehler bei der Zeitauswertung." });
}
});
}

View File

@@ -0,0 +1,71 @@
import { FastifyInstance } from 'fastify'
import { StaffTimeEntryConnect } from '../../types/staff'
export default async function staffTimeConnectRoutes(server: FastifyInstance) {
// ▶ Connect anlegen
server.post<{ Params: { id: string }, Body: Omit<StaffTimeEntryConnect, 'id' | 'time_entry_id'> }>(
'/staff/time/:id/connects',
async (req, reply) => {
const { id } = req.params
const { started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes } = req.body
const { data, error } = await server.supabase
.from('staff_time_entry_connects')
.insert([{ time_entry_id: id, started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes }])
.select()
.maybeSingle()
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
}
)
// ▶ Connects abrufen
server.get<{ Params: { id: string } }>(
'/staff/time/:id/connects',
async (req, reply) => {
const { id } = req.params
const { data, error } = await server.supabase
.from('staff_time_entry_connects')
.select('*')
.eq('time_entry_id', id)
.order('started_at', { ascending: true })
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
}
)
// ▶ Connect aktualisieren
server.patch<{ Params: { connectId: string }, Body: Partial<StaffTimeEntryConnect> }>(
'/staff/time/connects/:connectId',
async (req, reply) => {
const { connectId } = req.params
const { data, error } = await server.supabase
.from('staff_time_entry_connects')
.update({ ...req.body, updated_at: new Date().toISOString() })
.eq('id', connectId)
.select()
.maybeSingle()
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
}
)
// ▶ Connect löschen
server.delete<{ Params: { connectId: string } }>(
'/staff/time/connects/:connectId',
async (req, reply) => {
const { connectId } = req.params
const { error } = await server.supabase
.from('staff_time_entry_connects')
.delete()
.eq('id', connectId)
if (error) return reply.code(400).send({ error: error.message })
return reply.send({ success: true })
}
)
}