Initial commit: TimeClock v3 - Node.js/Vue.js Zeiterfassung
Features: - Backend: Node.js/Express mit MySQL/MariaDB - Frontend: Vue.js 3 mit Composition API - UTC-Zeithandling für korrekte Zeiterfassung - Timewish-basierte Überstundenberechnung - Wochenübersicht mit Urlaubs-/Krankheits-/Feiertagshandling - Bereinigtes Arbeitsende (Generell/Woche) - Überstunden-Offset für historische Daten - Fixed Layout mit scrollbarem Content - Kompakte UI mit grünem Theme
This commit is contained in:
202
backend/src/config/database.js
Normal file
202
backend/src/config/database.js
Normal file
@@ -0,0 +1,202 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Sequelize-Datenbank-Konfiguration
|
||||
*/
|
||||
class Database {
|
||||
constructor() {
|
||||
this.sequelize = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequelize-Instanz initialisieren
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Sequelize-Instanz mit .env-Konfiguration erstellen
|
||||
this.sequelize = new Sequelize(
|
||||
process.env.DB_NAME || 'stechuhr2',
|
||||
process.env.DB_USER || 'root',
|
||||
process.env.DB_PASSWORD || '',
|
||||
{
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT) || 3306,
|
||||
dialect: 'mysql',
|
||||
|
||||
// Timezone-Konfiguration
|
||||
// Die DB speichert lokale Zeit ohne TZ-Info
|
||||
// Worklog.tstamp ist als STRING definiert, daher erfolgt KEINE automatische TZ-Konvertierung
|
||||
timezone: '+00:00', // Keine Konvertierung für andere DATE-Felder
|
||||
|
||||
// Logging
|
||||
logging: process.env.DB_LOGGING === 'true' ? console.log : false,
|
||||
|
||||
// Connection Pool
|
||||
pool: {
|
||||
max: parseInt(process.env.DB_POOL_MAX) || 10,
|
||||
min: parseInt(process.env.DB_POOL_MIN) || 0,
|
||||
acquire: 30000,
|
||||
idle: 10000
|
||||
},
|
||||
|
||||
// Optionen
|
||||
define: {
|
||||
timestamps: false, // Wir verwenden eigene timestamp-Felder
|
||||
underscored: true, // snake_case in DB
|
||||
freezeTableName: true // Tabellennamen nicht pluralisieren
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Verbindung testen
|
||||
await this.sequelize.authenticate();
|
||||
console.log('✅ Sequelize: MySQL-Datenbankverbindung hergestellt');
|
||||
|
||||
// Models initialisieren
|
||||
await this.initializeModels();
|
||||
|
||||
return this.sequelize;
|
||||
} catch (error) {
|
||||
console.error('❌ Sequelize: Fehler bei der Datenbankverbindung:', error.message);
|
||||
console.error('Details:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models laden und Assoziationen definieren
|
||||
*/
|
||||
async initializeModels() {
|
||||
// Models importieren
|
||||
const User = require('../models/User');
|
||||
const Worklog = require('../models/Worklog');
|
||||
const AuthInfo = require('../models/AuthInfo');
|
||||
const AuthToken = require('../models/AuthToken');
|
||||
const AuthIdentity = require('../models/AuthIdentity');
|
||||
const State = require('../models/State');
|
||||
const WeeklyWorktime = require('../models/WeeklyWorktime');
|
||||
const Holiday = require('../models/Holiday');
|
||||
const Vacation = require('../models/Vacation');
|
||||
const Sick = require('../models/Sick');
|
||||
const SickType = require('../models/SickType');
|
||||
const Timefix = require('../models/Timefix');
|
||||
const Timewish = require('../models/Timewish');
|
||||
|
||||
// Models mit Sequelize-Instanz initialisieren
|
||||
User.initialize(this.sequelize);
|
||||
Worklog.initialize(this.sequelize);
|
||||
AuthInfo.initialize(this.sequelize);
|
||||
AuthToken.initialize(this.sequelize);
|
||||
AuthIdentity.initialize(this.sequelize);
|
||||
State.initialize(this.sequelize);
|
||||
WeeklyWorktime.initialize(this.sequelize);
|
||||
Holiday.initialize(this.sequelize);
|
||||
Vacation.initialize(this.sequelize);
|
||||
Sick.initialize(this.sequelize);
|
||||
SickType.initialize(this.sequelize);
|
||||
Timefix.initialize(this.sequelize);
|
||||
Timewish.initialize(this.sequelize);
|
||||
|
||||
// Assoziationen definieren
|
||||
this.defineAssociations();
|
||||
|
||||
console.log('✅ Sequelize: Models initialisiert');
|
||||
}
|
||||
|
||||
/**
|
||||
* Model-Assoziationen definieren
|
||||
*/
|
||||
defineAssociations() {
|
||||
const { User, Worklog, AuthInfo, AuthToken, AuthIdentity, State, WeeklyWorktime, Vacation, Sick, SickType, Timefix, Timewish } = this.sequelize.models;
|
||||
|
||||
// User Assoziationen
|
||||
User.hasMany(Worklog, { foreignKey: 'user_id', as: 'worklogs' });
|
||||
User.hasOne(AuthInfo, { foreignKey: 'user_id', as: 'authInfo' });
|
||||
User.belongsTo(State, { foreignKey: 'state_id', as: 'state' });
|
||||
User.hasMany(WeeklyWorktime, { foreignKey: 'user_id', as: 'weeklyWorktimes' });
|
||||
User.hasMany(Vacation, { foreignKey: 'user_id', as: 'vacations' });
|
||||
User.hasMany(Sick, { foreignKey: 'user_id', as: 'sickLeaves' });
|
||||
|
||||
// Worklog Assoziationen
|
||||
Worklog.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
Worklog.belongsTo(Worklog, { foreignKey: 'relatedTo_id', as: 'relatedTo' });
|
||||
Worklog.hasOne(Worklog, { foreignKey: 'relatedTo_id', as: 'clockOut' });
|
||||
Worklog.hasMany(Timefix, { foreignKey: 'worklog_id', as: 'timefixes' });
|
||||
|
||||
// Timefix Assoziationen
|
||||
Timefix.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
Timefix.belongsTo(Worklog, { foreignKey: 'worklog_id', as: 'worklog' });
|
||||
|
||||
// AuthInfo Assoziationen
|
||||
AuthInfo.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
AuthInfo.hasMany(AuthToken, { foreignKey: 'auth_info_id', as: 'tokens' });
|
||||
AuthInfo.hasMany(AuthIdentity, { foreignKey: 'auth_info_id', as: 'identities' });
|
||||
|
||||
// AuthToken Assoziationen
|
||||
AuthToken.belongsTo(AuthInfo, { foreignKey: 'auth_info_id', as: 'authInfo' });
|
||||
|
||||
// AuthIdentity Assoziationen
|
||||
AuthIdentity.belongsTo(AuthInfo, { foreignKey: 'auth_info_id', as: 'authInfo' });
|
||||
|
||||
// WeeklyWorktime Assoziationen
|
||||
WeeklyWorktime.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
|
||||
// Vacation Assoziationen
|
||||
Vacation.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
|
||||
// Sick Assoziationen
|
||||
Sick.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
Sick.belongsTo(SickType, { foreignKey: 'sick_type_id', as: 'sickType' });
|
||||
|
||||
// SickType Assoziationen
|
||||
SickType.hasMany(Sick, { foreignKey: 'sick_type_id', as: 'sickLeaves' });
|
||||
|
||||
// Timewish Assoziationen
|
||||
Timewish.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
User.hasMany(Timewish, { foreignKey: 'user_id', as: 'timewishes' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequelize-Instanz zurückgeben
|
||||
*/
|
||||
getSequelize() {
|
||||
if (!this.sequelize) {
|
||||
throw new Error('Sequelize nicht initialisiert. Rufen Sie zuerst initialize() auf.');
|
||||
}
|
||||
return this.sequelize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modelle zurückgeben
|
||||
*/
|
||||
getModels() {
|
||||
if (!this.sequelize) {
|
||||
throw new Error('Sequelize nicht initialisiert. Rufen Sie zuerst initialize() auf.');
|
||||
}
|
||||
return this.sequelize.models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindung schließen
|
||||
*/
|
||||
async close() {
|
||||
if (this.sequelize) {
|
||||
await this.sequelize.close();
|
||||
console.log('🔒 Sequelize: Datenbankverbindung geschlossen');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Datenbank synchronisieren (nur für Entwicklung!)
|
||||
* @param {Object} options - Sync-Optionen
|
||||
*/
|
||||
async sync(options = {}) {
|
||||
if (this.sequelize) {
|
||||
await this.sequelize.sync(options);
|
||||
console.log('🔄 Sequelize: Datenbank synchronisiert');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz exportieren
|
||||
module.exports = new Database();
|
||||
48
backend/src/config/passport.js
Normal file
48
backend/src/config/passport.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const passport = require('passport');
|
||||
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
||||
const oauthService = require('../services/OAuthService');
|
||||
|
||||
/**
|
||||
* Passport-Konfiguration für OAuth
|
||||
*/
|
||||
class PassportConfig {
|
||||
static initialize() {
|
||||
// Google OAuth Strategy
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||
passport.use(new GoogleStrategy({
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3010/api/auth/google/callback',
|
||||
scope: ['profile', 'email']
|
||||
},
|
||||
async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
const result = await oauthService.authenticateWithProvider(profile, 'google');
|
||||
return done(null, result);
|
||||
} catch (error) {
|
||||
return done(error, null);
|
||||
}
|
||||
}));
|
||||
|
||||
console.log('✅ Google OAuth konfiguriert');
|
||||
} else {
|
||||
console.log('⚠️ Google OAuth nicht konfiguriert (GOOGLE_CLIENT_ID/SECRET fehlen)');
|
||||
}
|
||||
|
||||
// Serialisierung (für Session-basierte Strategien)
|
||||
passport.serializeUser((user, done) => {
|
||||
done(null, user);
|
||||
});
|
||||
|
||||
passport.deserializeUser((user, done) => {
|
||||
done(null, user);
|
||||
});
|
||||
|
||||
return passport;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PassportConfig;
|
||||
|
||||
|
||||
|
||||
256
backend/src/controllers/AuthController.js
Normal file
256
backend/src/controllers/AuthController.js
Normal file
@@ -0,0 +1,256 @@
|
||||
const authService = require('../services/AuthService');
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
* Verwaltet Auth-bezogene HTTP-Requests
|
||||
*/
|
||||
class AuthController {
|
||||
/**
|
||||
* Benutzer registrieren
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
async register(req, res) {
|
||||
try {
|
||||
const { email, password, full_name } = req.body;
|
||||
|
||||
const result = await authService.register({
|
||||
email,
|
||||
password,
|
||||
full_name
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Registrierung erfolgreich',
|
||||
user: result.user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registrierungsfehler:', error);
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer einloggen
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
async login(req, res) {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'E-Mail und Passwort sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await authService.login(email, password);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Login erfolgreich',
|
||||
token: result.token,
|
||||
user: result.user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login-Fehler:', error);
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ausloggen
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
async logout(req, res) {
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (token) {
|
||||
await authService.logout(token);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logout erfolgreich'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout-Fehler:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Logout'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuellen Benutzer abrufen
|
||||
* GET /api/auth/me
|
||||
*/
|
||||
async getCurrentUser(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
|
||||
const profile = await authService.getUserProfile(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: profile
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen des Benutzers:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Abrufen der Benutzerdaten'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort-Reset anfordern
|
||||
* POST /api/auth/request-reset
|
||||
*/
|
||||
async requestPasswordReset(req, res) {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'E-Mail ist erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const resetToken = await authService.requestPasswordReset(email);
|
||||
|
||||
// Debug-Logs
|
||||
console.log('Reset-Token:', resetToken);
|
||||
console.log('NODE_ENV:', process.env.NODE_ENV);
|
||||
console.log('isDevelopment:', !process.env.NODE_ENV || process.env.NODE_ENV === 'development');
|
||||
|
||||
// In Produktion: E-Mail mit Reset-Link senden
|
||||
// Für Entwicklung: Token zurückgeben (auch wenn E-Mail nicht existiert)
|
||||
const isDevelopment = !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
|
||||
|
||||
if (isDevelopment) {
|
||||
// Im Development-Modus immer einen Token zurückgeben für Testing
|
||||
const devToken = resetToken || 'dev-test-token-' + Date.now() + '-' + email;
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Reset-Link wurde gesendet (DEV)',
|
||||
resetToken: devToken // NUR FÜR ENTWICKLUNG!
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Falls ein Account mit dieser E-Mail existiert, wurde ein Reset-Link gesendet'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Senden des Reset-Links'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort zurücksetzen
|
||||
* POST /api/auth/reset-password
|
||||
*/
|
||||
async resetPassword(req, res) {
|
||||
try {
|
||||
const { token, password } = req.body;
|
||||
|
||||
if (!token || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Token und neues Passwort sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
await authService.resetPassword(token, password);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Passwort wurde erfolgreich zurückgesetzt'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Passwort-Reset:', error);
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort ändern (eingeloggter Benutzer)
|
||||
* POST /api/auth/change-password
|
||||
*/
|
||||
async changePassword(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { oldPassword, newPassword } = req.body;
|
||||
|
||||
if (!oldPassword || !newPassword) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Altes und neues Passwort sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
await authService.changePassword(userId, oldPassword, newPassword);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Passwort wurde erfolgreich geändert'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Passwort-Ändern:', error);
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token validieren
|
||||
* GET /api/auth/validate
|
||||
*/
|
||||
async validateToken(req, res) {
|
||||
try {
|
||||
// Wenn Middleware durchgelaufen ist, ist Token valid
|
||||
res.json({
|
||||
success: true,
|
||||
valid: true,
|
||||
user: req.user
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
valid: false,
|
||||
error: 'Ungültiger Token'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthController();
|
||||
|
||||
101
backend/src/controllers/OAuthController.js
Normal file
101
backend/src/controllers/OAuthController.js
Normal file
@@ -0,0 +1,101 @@
|
||||
const passport = require('passport');
|
||||
|
||||
/**
|
||||
* OAuth Controller
|
||||
* Verwaltet OAuth-Logins
|
||||
*/
|
||||
class OAuthController {
|
||||
/**
|
||||
* Google OAuth initiieren
|
||||
* GET /api/auth/google
|
||||
*/
|
||||
googleAuth(req, res, next) {
|
||||
passport.authenticate('google', {
|
||||
scope: ['profile', 'email'],
|
||||
session: false
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth Callback
|
||||
* GET /api/auth/google/callback
|
||||
*/
|
||||
googleCallback(req, res, next) {
|
||||
passport.authenticate('google', {
|
||||
session: false,
|
||||
failureRedirect: `${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`
|
||||
}, (err, result) => {
|
||||
if (err || !result) {
|
||||
console.error('Google OAuth Fehler:', err);
|
||||
return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`);
|
||||
}
|
||||
|
||||
// Redirect zum Frontend mit Token
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5010';
|
||||
res.redirect(`${frontendUrl}/oauth-callback?token=${result.token}`);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Identities für Benutzer abrufen
|
||||
* GET /api/auth/identities
|
||||
*/
|
||||
async getIdentities(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const oauthService = require('../services/OAuthService');
|
||||
|
||||
const identities = await oauthService.getUserIdentities(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
identities
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Identities:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Abrufen der OAuth-Verknüpfungen'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Provider entfernen
|
||||
* DELETE /api/auth/identity/:provider
|
||||
*/
|
||||
async unlinkProvider(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { provider } = req.params;
|
||||
const oauthService = require('../services/OAuthService');
|
||||
|
||||
const unlinked = await oauthService.unlinkProvider(userId, provider);
|
||||
|
||||
if (unlinked) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${provider} wurde erfolgreich entfernt`
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Verknüpfung nicht gefunden'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Entfernen der Identity:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new OAuthController();
|
||||
|
||||
|
||||
|
||||
265
backend/src/controllers/TimeEntryController.js
Normal file
265
backend/src/controllers/TimeEntryController.js
Normal file
@@ -0,0 +1,265 @@
|
||||
const timeEntryService = require('../services/TimeEntryService');
|
||||
|
||||
/**
|
||||
* Controller-Klasse für Zeiteinträge
|
||||
* Verantwortlich für HTTP-Request/Response-Handling
|
||||
*/
|
||||
class TimeEntryController {
|
||||
/**
|
||||
* Alle Zeiteinträge abrufen
|
||||
* GET /api/time-entries
|
||||
*/
|
||||
async getAllEntries(req, res) {
|
||||
try {
|
||||
const entries = timeEntryService.getAllEntries();
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Einträge:', error);
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Abrufen der Einträge',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelnen Zeiteintrag anhand der ID abrufen
|
||||
* GET /api/time-entries/:id
|
||||
*/
|
||||
async getEntryById(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const entry = timeEntryService.getEntryById(id);
|
||||
|
||||
if (!entry) {
|
||||
return res.status(404).json({
|
||||
error: 'Eintrag nicht gefunden',
|
||||
id: parseInt(id)
|
||||
});
|
||||
}
|
||||
|
||||
res.json(entry);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen des Eintrags:', error);
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Abrufen des Eintrags',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Zeiteintrag erstellen (Timer starten)
|
||||
* POST /api/time-entries
|
||||
*/
|
||||
async createEntry(req, res) {
|
||||
try {
|
||||
const entryData = req.body;
|
||||
const newEntry = timeEntryService.createEntry(entryData);
|
||||
|
||||
res.status(201).json(newEntry);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen des Eintrags:', error);
|
||||
|
||||
// Spezifische Fehlerbehandlung
|
||||
if (error.message.includes('läuft bereits')) {
|
||||
return res.status(400).json({
|
||||
error: 'Validierungsfehler',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Erstellen des Eintrags',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeiteintrag aktualisieren (Timer stoppen oder Daten ändern)
|
||||
* PUT /api/time-entries/:id
|
||||
*/
|
||||
async updateEntry(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
const updatedEntry = timeEntryService.updateEntry(id, updateData);
|
||||
res.json(updatedEntry);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren des Eintrags:', error);
|
||||
|
||||
// Spezifische Fehlerbehandlung
|
||||
if (error.message.includes('nicht gefunden')) {
|
||||
return res.status(404).json({
|
||||
error: 'Eintrag nicht gefunden',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('Endzeit') || error.message.includes('Startzeit')) {
|
||||
return res.status(400).json({
|
||||
error: 'Validierungsfehler',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Aktualisieren des Eintrags',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeiteintrag löschen
|
||||
* DELETE /api/time-entries/:id
|
||||
*/
|
||||
async deleteEntry(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
timeEntryService.deleteEntry(id);
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Eintrags:', error);
|
||||
|
||||
if (error.message.includes('nicht gefunden')) {
|
||||
return res.status(404).json({
|
||||
error: 'Eintrag nicht gefunden',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Löschen des Eintrags',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuellen Status abrufen (letzter Worklog-Eintrag)
|
||||
* GET /api/time-entries/current-state
|
||||
*/
|
||||
async getCurrentState(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const state = await timeEntryService.getCurrentState(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
state: state
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen des aktuellen Status:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Abrufen des Status'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stempeln (Clock In/Out, Pause Start/Stop)
|
||||
* POST /api/time-entries/clock
|
||||
*/
|
||||
async clock(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { action } = req.body;
|
||||
|
||||
if (!action) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Aktion ist erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await timeEntryService.clock(userId, action);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Erfolgreich gestempelt',
|
||||
entry: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Stempeln:', error);
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiken abrufen
|
||||
* GET /api/time-entries/stats/summary
|
||||
*/
|
||||
async getStats(req, res) {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
const stats = await timeEntryService.getStatistics(userId);
|
||||
res.json(stats || {});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Statistiken:', error);
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Abrufen der Statistiken',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuell laufenden Timer abrufen
|
||||
* GET /api/time-entries/running
|
||||
*/
|
||||
async getRunningEntry(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const runningEntry = await timeEntryService.getRunningEntry(userId);
|
||||
|
||||
if (!runningEntry) {
|
||||
return res.json({}); // Leeres Objekt wenn nichts läuft
|
||||
}
|
||||
|
||||
res.json(runningEntry);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen des laufenden Timers:', error);
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Abrufen des laufenden Timers',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einträge nach Projekt filtern
|
||||
* GET /api/time-entries/project/:projectName
|
||||
*/
|
||||
async getEntriesByProject(req, res) {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const entries = timeEntryService.getEntriesByProject(projectName);
|
||||
|
||||
res.json({
|
||||
project: projectName,
|
||||
count: entries.length,
|
||||
entries
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Filtern nach Projekt:', error);
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Filtern nach Projekt',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz exportieren
|
||||
module.exports = new TimeEntryController();
|
||||
|
||||
35
backend/src/controllers/WeekOverviewController.js
Normal file
35
backend/src/controllers/WeekOverviewController.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const timeEntryService = require('../services/TimeEntryService');
|
||||
|
||||
/**
|
||||
* WeekOverview Controller
|
||||
* Verwaltet Wochenübersicht-Daten
|
||||
*/
|
||||
class WeekOverviewController {
|
||||
|
||||
/**
|
||||
* Wochenübersicht für einen Benutzer abrufen
|
||||
* GET /api/week-overview
|
||||
*/
|
||||
async getWeekOverview(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { weekOffset = 0 } = req.query; // 0 = aktuelle Woche, -1 = letzte Woche, etc.
|
||||
|
||||
const weekData = await timeEntryService.getWeekOverview(userId, parseInt(weekOffset));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: weekData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Wochenübersicht:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der Wochenübersicht'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WeekOverviewController();
|
||||
110
backend/src/index.js
Normal file
110
backend/src/index.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
const session = require('express-session');
|
||||
const passport = require('passport');
|
||||
require('dotenv').config();
|
||||
|
||||
const database = require('./config/database');
|
||||
const PassportConfig = require('./config/passport');
|
||||
const unhashRequestIds = require('./middleware/unhashRequest');
|
||||
const hashResponseIds = require('./middleware/hashResponse');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3010;
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5010',
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(morgan('dev'));
|
||||
|
||||
// Session für Passport (OAuth)
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'session-secret-change-in-production',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 Stunden
|
||||
}
|
||||
}));
|
||||
|
||||
// Passport initialisieren
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
PassportConfig.initialize();
|
||||
|
||||
// Routes
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'TimeClock API v3.0.0',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Auth routes (öffentlich) - OHNE ID-Hashing
|
||||
const authRouter = require('./routes/auth');
|
||||
app.use('/api/auth', authRouter);
|
||||
|
||||
// ID-Hashing Middleware (nur für geschützte Routes)
|
||||
app.use(unhashRequestIds);
|
||||
app.use(hashResponseIds);
|
||||
|
||||
// Time entries routes (geschützt) - MIT ID-Hashing
|
||||
const timeEntriesRouter = require('./routes/timeEntries');
|
||||
const { authenticateToken } = require('./middleware/auth');
|
||||
app.use('/api/time-entries', authenticateToken, timeEntriesRouter);
|
||||
|
||||
// Week overview routes (geschützt) - MIT ID-Hashing
|
||||
const weekOverviewRouter = require('./routes/weekOverview');
|
||||
app.use('/api/week-overview', authenticateToken, weekOverviewRouter);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({
|
||||
error: 'Etwas ist schiefgelaufen!',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Route nicht gefunden' });
|
||||
});
|
||||
|
||||
// Datenbank initialisieren und Server starten
|
||||
database.initialize()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🕐 TimeClock Server läuft auf Port ${PORT}`);
|
||||
console.log(`📍 API verfügbar unter http://localhost:${PORT}/api`);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ Server konnte nicht gestartet werden:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful Shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM empfangen, fahre Server herunter...');
|
||||
await database.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\nSIGINT empfangen, fahre Server herunter...');
|
||||
await database.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
||||
66
backend/src/middleware/auth.js
Normal file
66
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const authService = require('../services/AuthService');
|
||||
|
||||
/**
|
||||
* Authentication Middleware
|
||||
* Validiert JWT-Token und fügt Benutzer-Info zu req hinzu
|
||||
*/
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
try {
|
||||
// Token aus Authorization-Header extrahieren
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Kein Token vorhanden',
|
||||
code: 'NO_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
// Token validieren
|
||||
const decoded = await authService.validateToken(token);
|
||||
|
||||
// Benutzer-Info zu Request hinzufügen
|
||||
req.user = decoded;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Auth-Middleware-Fehler:', error.message);
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Ungültiger oder abgelaufener Token',
|
||||
code: 'INVALID_TOKEN'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional Authentication Middleware
|
||||
* Validiert Token falls vorhanden, erlaubt aber auch Requests ohne Token
|
||||
*/
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (token) {
|
||||
const decoded = await authService.validateToken(token);
|
||||
req.user = decoded;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
// Bei optionalem Auth ignorieren wir Fehler
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
optionalAuth
|
||||
};
|
||||
|
||||
|
||||
|
||||
104
backend/src/middleware/hashResponse.js
Normal file
104
backend/src/middleware/hashResponse.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const hashId = require('../utils/hashId');
|
||||
|
||||
/**
|
||||
* Middleware zum automatischen Hashen von IDs in Response-Daten
|
||||
*/
|
||||
const hashResponseIds = (req, res, next) => {
|
||||
// Originale json-Methode speichern
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
// json-Methode überschreiben
|
||||
res.json = function(data) {
|
||||
// Wenn Daten vorhanden sind, IDs hashen
|
||||
if (data) {
|
||||
data = hashResponseData(data);
|
||||
}
|
||||
|
||||
// Originale Methode aufrufen
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Rekursiv IDs in Datenstruktur hashen
|
||||
* @param {*} data - Zu verarbeitende Daten
|
||||
* @returns {*} Daten mit gehashten IDs
|
||||
*/
|
||||
function hashResponseData(data) {
|
||||
// Primitive Typen direkt zurückgeben
|
||||
if (data === null || data === undefined) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Array: Jedes Element verarbeiten
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(item => hashResponseData(item));
|
||||
}
|
||||
|
||||
// Object: ID-Felder hashen
|
||||
if (typeof data === 'object') {
|
||||
const result = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// ID-Felder identifizieren und hashen
|
||||
if (isIdField(key) && typeof value === 'number') {
|
||||
result[key] = hashId.encode(value);
|
||||
}
|
||||
// Verschachtelte Objekte/Arrays rekursiv verarbeiten
|
||||
else if (typeof value === 'object') {
|
||||
result[key] = hashResponseData(value);
|
||||
}
|
||||
// Andere Werte unverändert übernehmen
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Andere Typen unverändert
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Feldname eine ID repräsentiert
|
||||
* @param {string} fieldName - Name des Feldes
|
||||
* @returns {boolean} True wenn ID-Feld
|
||||
*/
|
||||
function isIdField(fieldName) {
|
||||
// Felder die als ID erkannt werden sollen
|
||||
const idPatterns = [
|
||||
'id',
|
||||
'_id',
|
||||
'user_id',
|
||||
'userId',
|
||||
'auth_info_id',
|
||||
'authInfoId',
|
||||
'auth_token_id',
|
||||
'authTokenId',
|
||||
'worklog_id',
|
||||
'worklogId',
|
||||
'vacation_id',
|
||||
'vacationId',
|
||||
'sick_id',
|
||||
'sickId',
|
||||
'holiday_id',
|
||||
'holidayId',
|
||||
'state_id',
|
||||
'stateId',
|
||||
'sick_type_id',
|
||||
'sickTypeId',
|
||||
'weekly_worktime_id',
|
||||
'weeklyWorktimeId'
|
||||
];
|
||||
|
||||
return idPatterns.includes(fieldName);
|
||||
}
|
||||
|
||||
module.exports = hashResponseIds;
|
||||
|
||||
|
||||
|
||||
116
backend/src/middleware/unhashRequest.js
Normal file
116
backend/src/middleware/unhashRequest.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const hashId = require('../utils/hashId');
|
||||
|
||||
/**
|
||||
* Middleware zum automatischen Enthashen von IDs in Request-Daten
|
||||
*/
|
||||
const unhashRequestIds = (req, res, next) => {
|
||||
// Body-Parameter verarbeiten
|
||||
if (req.body && typeof req.body === 'object') {
|
||||
req.body = unhashRequestData(req.body);
|
||||
}
|
||||
|
||||
// Query-Parameter verarbeiten
|
||||
if (req.query && typeof req.query === 'object') {
|
||||
req.query = unhashRequestData(req.query);
|
||||
}
|
||||
|
||||
// Route-Parameter verarbeiten
|
||||
if (req.params && typeof req.params === 'object') {
|
||||
req.params = unhashRequestData(req.params);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Rekursiv Hash-IDs in Datenstruktur entschlüsseln
|
||||
* @param {*} data - Zu verarbeitende Daten
|
||||
* @returns {*} Daten mit entschlüsselten IDs
|
||||
*/
|
||||
function unhashRequestData(data) {
|
||||
// Primitive Typen direkt zurückgeben
|
||||
if (data === null || data === undefined) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// String: Könnte ein Hash sein
|
||||
if (typeof data === 'string') {
|
||||
// Versuche als Hash zu dekodieren
|
||||
const decoded = hashId.decode(data);
|
||||
if (decoded !== null) {
|
||||
return decoded;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Array: Jedes Element verarbeiten
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(item => unhashRequestData(item));
|
||||
}
|
||||
|
||||
// Object: Rekursiv verarbeiten
|
||||
if (typeof data === 'object') {
|
||||
const result = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// ID-Felder identifizieren und enthashen
|
||||
if (isIdField(key) && typeof value === 'string') {
|
||||
const decoded = hashId.decode(value);
|
||||
result[key] = decoded !== null ? decoded : value;
|
||||
}
|
||||
// Verschachtelte Objekte/Arrays rekursiv verarbeiten
|
||||
else if (typeof value === 'object') {
|
||||
result[key] = unhashRequestData(value);
|
||||
}
|
||||
// Andere Werte unverändert übernehmen
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Andere Typen unverändert
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Feldname eine ID repräsentiert
|
||||
* @param {string} fieldName - Name des Feldes
|
||||
* @returns {boolean} True wenn ID-Feld
|
||||
*/
|
||||
function isIdField(fieldName) {
|
||||
// Felder die als ID erkannt werden sollen
|
||||
const idPatterns = [
|
||||
'id',
|
||||
'_id',
|
||||
'user_id',
|
||||
'userId',
|
||||
'auth_info_id',
|
||||
'authInfoId',
|
||||
'auth_token_id',
|
||||
'authTokenId',
|
||||
'worklog_id',
|
||||
'worklogId',
|
||||
'vacation_id',
|
||||
'vacationId',
|
||||
'sick_id',
|
||||
'sickId',
|
||||
'holiday_id',
|
||||
'holidayId',
|
||||
'state_id',
|
||||
'stateId',
|
||||
'sick_type_id',
|
||||
'sickTypeId',
|
||||
'weekly_worktime_id',
|
||||
'weeklyWorktimeId'
|
||||
];
|
||||
|
||||
return idPatterns.includes(fieldName);
|
||||
}
|
||||
|
||||
module.exports = unhashRequestIds;
|
||||
|
||||
|
||||
|
||||
87
backend/src/models/AuthIdentity.js
Normal file
87
backend/src/models/AuthIdentity.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* AuthIdentity Model
|
||||
* Repräsentiert die auth_identity-Tabelle für OAuth/SSO-Provider
|
||||
*/
|
||||
class AuthIdentity extends Model {
|
||||
static initialize(sequelize) {
|
||||
AuthIdentity.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
auth_info_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'auth_info',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
provider: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
comment: 'OAuth Provider: google, github, microsoft, etc.'
|
||||
},
|
||||
identity: {
|
||||
type: DataTypes.STRING(512),
|
||||
allowNull: false,
|
||||
comment: 'Provider User ID oder E-Mail'
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'auth_identity',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_auth_identity_auth_info',
|
||||
fields: ['auth_info_id']
|
||||
},
|
||||
{
|
||||
name: 'auth_identity_provider_identity',
|
||||
unique: true,
|
||||
fields: ['provider', 'identity']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return AuthIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Provider-Namen zurück
|
||||
*/
|
||||
getProvider() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob dies ein Google-Login ist
|
||||
*/
|
||||
isGoogle() {
|
||||
return this.provider === 'google';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob dies ein GitHub-Login ist
|
||||
*/
|
||||
isGitHub() {
|
||||
return this.provider === 'github';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AuthIdentity;
|
||||
|
||||
|
||||
|
||||
99
backend/src/models/AuthInfo.js
Normal file
99
backend/src/models/AuthInfo.js
Normal file
@@ -0,0 +1,99 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* AuthInfo Model
|
||||
* Repräsentiert die auth_info-Tabelle
|
||||
*/
|
||||
class AuthInfo extends Model {
|
||||
static initialize(sequelize) {
|
||||
AuthInfo.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
password_hash: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false
|
||||
},
|
||||
password_method: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false
|
||||
},
|
||||
password_salt: {
|
||||
type: DataTypes.STRING(60),
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
failed_login_attempts: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
last_login_attempt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING(256),
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
unverified_email: {
|
||||
type: DataTypes.STRING(256),
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
},
|
||||
email_token: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
},
|
||||
email_token_expires: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
email_token_role: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'auth_info',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_auth_info_user',
|
||||
fields: ['user_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return AuthInfo;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AuthInfo;
|
||||
|
||||
67
backend/src/models/AuthToken.js
Normal file
67
backend/src/models/AuthToken.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* AuthToken Model
|
||||
* Repräsentiert die auth_token-Tabelle für JWT-Tokens
|
||||
*/
|
||||
class AuthToken extends Model {
|
||||
static initialize(sequelize) {
|
||||
AuthToken.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
auth_info_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'auth_info',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
value: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false
|
||||
},
|
||||
expires: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'auth_token',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_auth_token_auth_info',
|
||||
fields: ['auth_info_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return AuthToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob Token abgelaufen ist
|
||||
*/
|
||||
isExpired() {
|
||||
if (!this.expires) return false;
|
||||
return new Date() > new Date(this.expires);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AuthToken;
|
||||
|
||||
|
||||
|
||||
50
backend/src/models/Holiday.js
Normal file
50
backend/src/models/Holiday.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Holiday Model
|
||||
* Repräsentiert die holiday-Tabelle
|
||||
*/
|
||||
class Holiday extends Model {
|
||||
static initialize(sequelize) {
|
||||
Holiday.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
date: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
hours: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 8
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'holiday',
|
||||
timestamps: false
|
||||
}
|
||||
);
|
||||
|
||||
return Holiday;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Holiday;
|
||||
|
||||
|
||||
|
||||
71
backend/src/models/Sick.js
Normal file
71
backend/src/models/Sick.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Sick Model
|
||||
* Repräsentiert die sick-Tabelle (Krankmeldungen)
|
||||
*/
|
||||
class Sick extends Model {
|
||||
static initialize(sequelize) {
|
||||
Sick.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
first_day: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
last_day: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
sick_type_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'sick_type',
|
||||
key: 'id'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'sick',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_sick_user',
|
||||
fields: ['user_id']
|
||||
},
|
||||
{
|
||||
name: 'fk_sick_sick_type',
|
||||
fields: ['sick_type_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return Sick;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Sick;
|
||||
|
||||
|
||||
|
||||
41
backend/src/models/SickType.js
Normal file
41
backend/src/models/SickType.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* SickType Model
|
||||
* Repräsentiert die sick_type-Tabelle
|
||||
*/
|
||||
class SickType extends Model {
|
||||
static initialize(sequelize) {
|
||||
SickType.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'sick_type',
|
||||
timestamps: false
|
||||
}
|
||||
);
|
||||
|
||||
return SickType;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SickType;
|
||||
|
||||
|
||||
|
||||
41
backend/src/models/State.js
Normal file
41
backend/src/models/State.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* State Model
|
||||
* Repräsentiert die state-Tabelle
|
||||
*/
|
||||
class State extends Model {
|
||||
static initialize(sequelize) {
|
||||
State.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
state_name: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'state',
|
||||
timestamps: false
|
||||
}
|
||||
);
|
||||
|
||||
return State;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = State;
|
||||
|
||||
|
||||
|
||||
88
backend/src/models/Timefix.js
Normal file
88
backend/src/models/Timefix.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Timefix Model
|
||||
* Repräsentiert die timefix-Tabelle für Zeitkorrekturen
|
||||
*/
|
||||
class Timefix extends Model {
|
||||
static initialize(sequelize) {
|
||||
Timefix.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
worklog_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'worklog',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
fix_type: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false
|
||||
},
|
||||
fix_date_time: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'timefix',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'timefix_worklog_id_IDX',
|
||||
fields: ['worklog_id']
|
||||
},
|
||||
{
|
||||
name: 'timefix_user_id_IDX',
|
||||
fields: ['user_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return Timefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Definiert Assoziationen mit anderen Models
|
||||
*/
|
||||
static associate(models) {
|
||||
// Timefix gehört zu einem User
|
||||
Timefix.belongsTo(models.User, {
|
||||
foreignKey: 'user_id',
|
||||
as: 'user'
|
||||
});
|
||||
|
||||
// Timefix bezieht sich auf einen Worklog-Eintrag
|
||||
Timefix.belongsTo(models.Worklog, {
|
||||
foreignKey: 'worklog_id',
|
||||
as: 'worklog'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Timefix;
|
||||
|
||||
|
||||
|
||||
74
backend/src/models/Timewish.js
Normal file
74
backend/src/models/Timewish.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
class Timewish extends Model {
|
||||
static initialize(sequelize) {
|
||||
Timewish.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
day: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: '1=Montag, 2=Dienstag, ..., 5=Freitag, 6=Samstag, 7=Sonntag'
|
||||
},
|
||||
wishtype: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: '1=Ende nach Uhrzeit, 2=Ende nach Stunden'
|
||||
},
|
||||
hours: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true,
|
||||
comment: 'Gewünschte Stunden (bei wishtype=2)'
|
||||
},
|
||||
end_time: {
|
||||
type: DataTypes.TIME,
|
||||
allowNull: true,
|
||||
comment: 'Gewünschte Endzeit (bei wishtype=1)'
|
||||
},
|
||||
start_date: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
comment: 'Ab welchem Datum gilt dieser Timewish (YYYY-MM-DD)'
|
||||
},
|
||||
end_date: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
comment: 'Bis zu welchem Datum gilt dieser Timewish (NULL = bis heute)'
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'timewish',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_timewish_user',
|
||||
fields: ['user_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
return Timewish;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Timewish;
|
||||
|
||||
105
backend/src/models/User.js
Normal file
105
backend/src/models/User.js
Normal file
@@ -0,0 +1,105 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* User Model
|
||||
* Repräsentiert die user-Tabelle
|
||||
*/
|
||||
class User extends Model {
|
||||
static initialize(sequelize) {
|
||||
User.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
last_change: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
daily_hours: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 8
|
||||
},
|
||||
state_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true
|
||||
},
|
||||
full_name: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
week_hours: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 40
|
||||
},
|
||||
week_workdays: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 5
|
||||
},
|
||||
preferred_title_type: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
overtime_offset_minutes: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
comment: 'Überstunden-Startwert in Minuten (z.B. Übertrag aus altem System)'
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'user',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_user_state',
|
||||
fields: ['state_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return User;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vollständigen Namen zurückgeben
|
||||
*/
|
||||
getFullName() {
|
||||
return this.full_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tägliche Arbeitsstunden zurückgeben
|
||||
*/
|
||||
getDailyHours() {
|
||||
return this.daily_hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wöchentliche Arbeitsstunden zurückgeben
|
||||
*/
|
||||
getWeeklyHours() {
|
||||
return this.week_hours;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = User;
|
||||
|
||||
64
backend/src/models/Vacation.js
Normal file
64
backend/src/models/Vacation.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Vacation Model
|
||||
* Repräsentiert die vacation-Tabelle
|
||||
*/
|
||||
class Vacation extends Model {
|
||||
static initialize(sequelize) {
|
||||
Vacation.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
first_day: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
last_day: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
vacation_type: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'vacation',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_vacation_user',
|
||||
fields: ['user_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return Vacation;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Vacation;
|
||||
|
||||
|
||||
|
||||
58
backend/src/models/WeeklyWorktime.js
Normal file
58
backend/src/models/WeeklyWorktime.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* WeeklyWorktime Model
|
||||
* Repräsentiert die weekly_worktime-Tabelle
|
||||
*/
|
||||
class WeeklyWorktime extends Model {
|
||||
static initialize(sequelize) {
|
||||
WeeklyWorktime.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
weekly_work_time: {
|
||||
type: DataTypes.DOUBLE,
|
||||
allowNull: false,
|
||||
defaultValue: 40
|
||||
},
|
||||
starting_from: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
ends_at: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'weekly_worktime',
|
||||
timestamps: false
|
||||
}
|
||||
);
|
||||
|
||||
return WeeklyWorktime;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WeeklyWorktime;
|
||||
|
||||
|
||||
|
||||
128
backend/src/models/Worklog.js
Normal file
128
backend/src/models/Worklog.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Worklog Model
|
||||
* Repräsentiert die worklog-Tabelle für Zeiteinträge
|
||||
*/
|
||||
class Worklog extends Model {
|
||||
static initialize(sequelize) {
|
||||
Worklog.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
state: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('state');
|
||||
try {
|
||||
return JSON.parse(rawValue);
|
||||
} catch {
|
||||
return { action: rawValue, project: 'Allgemein', description: '' };
|
||||
}
|
||||
},
|
||||
set(value) {
|
||||
if (typeof value === 'object') {
|
||||
this.setDataValue('state', JSON.stringify(value));
|
||||
} else {
|
||||
this.setDataValue('state', value);
|
||||
}
|
||||
}
|
||||
},
|
||||
tstamp: {
|
||||
type: DataTypes.STRING(19), // 'YYYY-MM-DD HH:MM:SS' = 19 Zeichen
|
||||
allowNull: true,
|
||||
comment: 'Lokale Zeit ohne TZ-Info, Format: YYYY-MM-DD HH:MM:SS'
|
||||
},
|
||||
relatedTo_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'relatedTo_id', // Explizit den Spaltennamen in der DB angeben
|
||||
references: {
|
||||
model: 'worklog',
|
||||
key: 'id'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'worklog',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_worklog_relatedTo',
|
||||
fields: ['relatedTo_id']
|
||||
},
|
||||
{
|
||||
name: 'worklog_tstamp_IDX',
|
||||
fields: ['tstamp']
|
||||
},
|
||||
{
|
||||
name: 'worklog_user_id_IDX',
|
||||
fields: ['user_id', 'tstamp']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return Worklog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob dies ein Clock-In Eintrag ist
|
||||
*/
|
||||
isClockIn() {
|
||||
return this.relatedTo_id === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob dies ein Clock-Out Eintrag ist
|
||||
*/
|
||||
isClockOut() {
|
||||
return this.relatedTo_id !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den State-Action zurück
|
||||
*/
|
||||
getAction() {
|
||||
const state = this.state;
|
||||
return state.action || state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt das Projekt zurück
|
||||
*/
|
||||
getProject() {
|
||||
const state = this.state;
|
||||
return state.project || 'Allgemein';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Beschreibung zurück
|
||||
*/
|
||||
getDescription() {
|
||||
const state = this.state;
|
||||
return state.description || '';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Worklog;
|
||||
|
||||
31
backend/src/models/index.js
Normal file
31
backend/src/models/index.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Model Index
|
||||
* Exportiert alle Sequelize Models für einfachen Import
|
||||
*/
|
||||
|
||||
const User = require('./User');
|
||||
const Worklog = require('./Worklog');
|
||||
const AuthInfo = require('./AuthInfo');
|
||||
const State = require('./State');
|
||||
const WeeklyWorktime = require('./WeeklyWorktime');
|
||||
const Holiday = require('./Holiday');
|
||||
const Vacation = require('./Vacation');
|
||||
const Sick = require('./Sick');
|
||||
const SickType = require('./SickType');
|
||||
const Timefix = require('./Timefix');
|
||||
const Timewish = require('./Timewish');
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
Worklog,
|
||||
AuthInfo,
|
||||
State,
|
||||
WeeklyWorktime,
|
||||
Holiday,
|
||||
Vacation,
|
||||
Sick,
|
||||
SickType,
|
||||
Timefix,
|
||||
Timewish
|
||||
};
|
||||
|
||||
195
backend/src/repositories/UserRepository.js
Normal file
195
backend/src/repositories/UserRepository.js
Normal file
@@ -0,0 +1,195 @@
|
||||
const database = require('../config/database');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Repository für User-Datenbankzugriff
|
||||
* Verwendet Sequelize ORM
|
||||
*/
|
||||
class UserRepository {
|
||||
/**
|
||||
* Alle Benutzer abrufen
|
||||
* @returns {Promise<Array>} Liste aller Benutzer
|
||||
*/
|
||||
async findAll() {
|
||||
const { User, State } = database.getModels();
|
||||
|
||||
return await User.findAll({
|
||||
include: [{
|
||||
model: State,
|
||||
as: 'state',
|
||||
required: false
|
||||
}],
|
||||
order: [['full_name', 'ASC']]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer anhand der ID abrufen
|
||||
* @param {number} id - Benutzer-ID
|
||||
* @returns {Promise<Object|null>} Benutzer oder null
|
||||
*/
|
||||
async findById(id) {
|
||||
const { User, State } = database.getModels();
|
||||
|
||||
return await User.findByPk(id, {
|
||||
include: [{
|
||||
model: State,
|
||||
as: 'state',
|
||||
required: false
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer anhand der E-Mail abrufen
|
||||
* @param {string} email - E-Mail-Adresse
|
||||
* @returns {Promise<Object|null>} Benutzer oder null
|
||||
*/
|
||||
async findByEmail(email) {
|
||||
const { User, AuthInfo } = database.getModels();
|
||||
|
||||
return await User.findOne({
|
||||
include: [{
|
||||
model: AuthInfo,
|
||||
as: 'authInfo',
|
||||
where: { email },
|
||||
required: true
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wochenarbeitszeit für Benutzer abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {Date} date - Datum (optional, Standard: heute)
|
||||
* @returns {Promise<Object|null>} Wochenarbeitszeit-Einstellung
|
||||
*/
|
||||
async getWeeklyWorktime(userId, date = new Date()) {
|
||||
const { WeeklyWorktime } = database.getModels();
|
||||
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
return await WeeklyWorktime.findOne({
|
||||
where: {
|
||||
user_id: userId,
|
||||
[Op.or]: [
|
||||
{ starting_from: null },
|
||||
{ starting_from: { [Op.lte]: dateStr } }
|
||||
],
|
||||
[Op.or]: [
|
||||
{ ends_at: null },
|
||||
{ ends_at: { [Op.gte]: dateStr } }
|
||||
]
|
||||
},
|
||||
order: [['starting_from', 'DESC']]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer erstellen
|
||||
* @param {Object} userData - Benutzerdaten
|
||||
* @returns {Promise<Object>} Erstellter Benutzer
|
||||
*/
|
||||
async create(userData) {
|
||||
const { User } = database.getModels();
|
||||
|
||||
const {
|
||||
full_name,
|
||||
role = 0,
|
||||
daily_hours = 8,
|
||||
week_hours = 40,
|
||||
week_workdays = 5,
|
||||
state_id = null,
|
||||
preferred_title_type = 0
|
||||
} = userData;
|
||||
|
||||
return await User.create({
|
||||
version: 0,
|
||||
full_name,
|
||||
role,
|
||||
daily_hours,
|
||||
week_hours,
|
||||
week_workdays,
|
||||
state_id,
|
||||
preferred_title_type,
|
||||
last_change: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer aktualisieren
|
||||
* @param {number} id - Benutzer-ID
|
||||
* @param {Object} updateData - Zu aktualisierende Daten
|
||||
* @returns {Promise<Object>} Aktualisierter Benutzer
|
||||
*/
|
||||
async update(id, updateData) {
|
||||
const { User } = database.getModels();
|
||||
|
||||
const user = await User.findByPk(id);
|
||||
if (!user) {
|
||||
throw new Error(`Benutzer mit ID ${id} nicht gefunden`);
|
||||
}
|
||||
|
||||
const allowedFields = [
|
||||
'full_name', 'role', 'daily_hours', 'week_hours',
|
||||
'week_workdays', 'state_id', 'preferred_title_type'
|
||||
];
|
||||
|
||||
const updates = {};
|
||||
allowedFields.forEach(field => {
|
||||
if (updateData[field] !== undefined) {
|
||||
updates[field] = updateData[field];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates.version = user.version + 1;
|
||||
updates.last_change = new Date();
|
||||
|
||||
await user.update(updates);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer mit allen Beziehungen abrufen
|
||||
* @param {number} id - Benutzer-ID
|
||||
* @returns {Promise<Object|null>} Benutzer mit Beziehungen
|
||||
*/
|
||||
async findByIdWithRelations(id) {
|
||||
const { User, State, AuthInfo, WeeklyWorktime, Vacation, Sick } = database.getModels();
|
||||
|
||||
return await User.findByPk(id, {
|
||||
include: [
|
||||
{
|
||||
model: State,
|
||||
as: 'state',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: AuthInfo,
|
||||
as: 'authInfo',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: WeeklyWorktime,
|
||||
as: 'weeklyWorktimes',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: Vacation,
|
||||
as: 'vacations',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: Sick,
|
||||
as: 'sickLeaves',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UserRepository();
|
||||
564
backend/src/repositories/WorklogRepository.js
Normal file
564
backend/src/repositories/WorklogRepository.js
Normal file
@@ -0,0 +1,564 @@
|
||||
const database = require('../config/database');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Repository für Worklog-Datenbankzugriff
|
||||
* Verwendet Sequelize ORM
|
||||
*/
|
||||
class WorklogRepository {
|
||||
/**
|
||||
* Alle Worklog-Einträge für einen Benutzer abrufen
|
||||
* @param {number} userId - ID des Benutzers
|
||||
* @param {Object} options - Filteroptionen
|
||||
* @returns {Promise<Array>} Liste der Worklog-Einträge
|
||||
*/
|
||||
async findAllByUser(userId, options = {}) {
|
||||
const { Worklog } = database.getModels();
|
||||
const { limit, offset, orderBy = 'tstamp', order = 'DESC' } = options;
|
||||
|
||||
return await Worklog.findAll({
|
||||
where: { user_id: userId },
|
||||
order: [[orderBy, order]],
|
||||
limit: limit || null,
|
||||
offset: offset || 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Worklog-Eintrag anhand der ID abrufen
|
||||
* @param {number} id - Worklog-ID
|
||||
* @returns {Promise<Object|null>} Worklog-Eintrag oder null
|
||||
*/
|
||||
async findById(id) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
return await Worklog.findByPk(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Worklog-Eintrag erstellen
|
||||
* @param {Object} worklogData - Worklog-Daten
|
||||
* @returns {Promise<Object>} Erstellter Worklog-Eintrag
|
||||
*/
|
||||
async create(worklogData) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
const { user_id, state, tstamp, relatedTo_id = null } = worklogData;
|
||||
|
||||
return await Worklog.create({
|
||||
version: 0,
|
||||
user_id,
|
||||
state,
|
||||
tstamp: tstamp || new Date(),
|
||||
relatedTo_id
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Worklog-Eintrag aktualisieren
|
||||
* @param {number} id - Worklog-ID
|
||||
* @param {Object} updateData - Zu aktualisierende Daten
|
||||
* @returns {Promise<Object>} Aktualisierter Worklog-Eintrag
|
||||
*/
|
||||
async update(id, updateData) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
const worklog = await Worklog.findByPk(id);
|
||||
if (!worklog) {
|
||||
throw new Error(`Worklog mit ID ${id} nicht gefunden`);
|
||||
}
|
||||
|
||||
// Update-Daten vorbereiten
|
||||
const updates = {};
|
||||
|
||||
if (updateData.state !== undefined) {
|
||||
updates.state = updateData.state;
|
||||
}
|
||||
if (updateData.tstamp !== undefined) {
|
||||
updates.tstamp = updateData.tstamp;
|
||||
}
|
||||
if (updateData.relatedTo_id !== undefined) {
|
||||
updates.relatedTo_id = updateData.relatedTo_id;
|
||||
}
|
||||
|
||||
// Version inkrementieren
|
||||
updates.version = worklog.version + 1;
|
||||
|
||||
await worklog.update(updates);
|
||||
return worklog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worklog-Eintrag löschen
|
||||
* @param {number} id - Worklog-ID
|
||||
* @returns {Promise<boolean>} true wenn erfolgreich
|
||||
*/
|
||||
async delete(id) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
const deleted = await Worklog.destroy({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzten Worklog-Eintrag für Benutzer finden
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Object|null>} Letzter Worklog-Eintrag oder null
|
||||
*/
|
||||
async findLatestByUser(userId) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
return await Worklog.findOne({
|
||||
where: { user_id: userId },
|
||||
order: [['tstamp', 'DESC'], ['id', 'DESC']],
|
||||
raw: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Worklog-Einträge für Benutzer finden
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Array>} Array von Worklog-Einträgen
|
||||
*/
|
||||
async findByUser(userId) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
return await Worklog.findAll({
|
||||
where: { user_id: userId },
|
||||
order: [['tstamp', 'ASC'], ['id', 'ASC']],
|
||||
raw: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzten offenen Worklog-Eintrag für Benutzer finden
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Object|null>} Laufender Worklog-Eintrag oder null
|
||||
*/
|
||||
async findRunningByUser(userId) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
return await Worklog.findOne({
|
||||
where: {
|
||||
user_id: userId,
|
||||
relatedTo_id: null
|
||||
},
|
||||
order: [['tstamp', 'DESC']],
|
||||
include: [{
|
||||
model: Worklog,
|
||||
as: 'clockOut',
|
||||
required: false
|
||||
}]
|
||||
}).then(worklog => {
|
||||
// Nur zurückgeben wenn kein clockOut existiert
|
||||
if (worklog && !worklog.clockOut) {
|
||||
return worklog;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Vacation-Einträge für einen Benutzer in einem Datumsbereich abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {Date} startDate - Start-Datum
|
||||
* @param {Date} endDate - End-Datum
|
||||
* @returns {Promise<Array>} Array von Vacation-Einträgen mit expandierten Tagen
|
||||
*/
|
||||
async getVacationsByUserInDateRange(userId, startDate, endDate) {
|
||||
const { Vacation } = database.getModels();
|
||||
|
||||
try {
|
||||
// Hole alle Vacation-Einträge, die sich mit dem Datumsbereich überschneiden
|
||||
const vacations = await Vacation.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
[Op.or]: [
|
||||
{
|
||||
first_day: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
{
|
||||
last_day: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
{
|
||||
[Op.and]: [
|
||||
{ first_day: { [Op.lte]: startDate } },
|
||||
{ last_day: { [Op.gte]: endDate } }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
raw: true,
|
||||
order: [['first_day', 'ASC']]
|
||||
});
|
||||
|
||||
// Expandiere jeden Vacation-Eintrag in einzelne Tage
|
||||
const expandedVacations = [];
|
||||
|
||||
vacations.forEach(vac => {
|
||||
const first = new Date(vac.first_day);
|
||||
const last = new Date(vac.last_day);
|
||||
|
||||
// Iteriere über alle Tage im Urlaubsbereich
|
||||
for (let d = new Date(first); d <= last; d.setDate(d.getDate() + 1)) {
|
||||
// Nur Tage hinzufügen, die im gewünschten Bereich liegen
|
||||
if (d >= startDate && d <= endDate) {
|
||||
// Überspringe Wochenenden (Samstag=6, Sonntag=0)
|
||||
const dayOfWeek = d.getDay();
|
||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||
expandedVacations.push({
|
||||
date: new Date(d),
|
||||
half_day: vac.vacation_type === 1 ? 1 : 0,
|
||||
vacation_type: vac.vacation_type
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return expandedVacations;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Vacation-Einträge:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sick-Einträge für einen Benutzer in einem Datumsbereich abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {Date} startDate - Start-Datum
|
||||
* @param {Date} endDate - End-Datum
|
||||
* @returns {Promise<Array>} Array von Sick-Einträgen mit expandierten Tagen
|
||||
*/
|
||||
async getSickByUserInDateRange(userId, startDate, endDate) {
|
||||
const { Sick, SickType } = database.getModels();
|
||||
|
||||
try {
|
||||
// Hole alle Sick-Einträge, die sich mit dem Datumsbereich überschneiden
|
||||
const sickEntries = await Sick.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
[Op.or]: [
|
||||
{
|
||||
first_day: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
{
|
||||
last_day: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
{
|
||||
[Op.and]: [
|
||||
{ first_day: { [Op.lte]: startDate } },
|
||||
{ last_day: { [Op.gte]: endDate } }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
include: [{
|
||||
model: SickType,
|
||||
as: 'sickType',
|
||||
attributes: ['description']
|
||||
}],
|
||||
order: [['first_day', 'ASC']]
|
||||
});
|
||||
|
||||
// Expandiere jeden Sick-Eintrag in einzelne Tage
|
||||
const expandedSick = [];
|
||||
|
||||
sickEntries.forEach(sick => {
|
||||
const first = new Date(sick.first_day);
|
||||
const last = new Date(sick.last_day);
|
||||
const sickTypeDesc = sick.sickType?.description || 'self';
|
||||
|
||||
// Iteriere über alle Tage im Krankheitsbereich
|
||||
for (let d = new Date(first); d <= last; d.setDate(d.getDate() + 1)) {
|
||||
// Nur Tage hinzufügen, die im gewünschten Bereich liegen
|
||||
if (d >= startDate && d <= endDate) {
|
||||
// Überspringe Wochenenden (Samstag=6, Sonntag=0)
|
||||
const dayOfWeek = d.getDay();
|
||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||
expandedSick.push({
|
||||
date: new Date(d),
|
||||
sick_type: sickTypeDesc,
|
||||
sick_type_id: sick.sick_type_id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return expandedSick;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Sick-Einträge:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timefix-Einträge für Worklog-IDs abrufen
|
||||
* @param {Array<number>} worklogIds - Array von Worklog-IDs
|
||||
* @returns {Promise<Map>} Map von worklog_id zu Timefix-Einträgen
|
||||
*/
|
||||
async getTimefixesByWorklogIds(worklogIds) {
|
||||
if (!worklogIds || worklogIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const { Timefix } = database.getModels();
|
||||
|
||||
try {
|
||||
const timefixes = await Timefix.findAll({
|
||||
where: {
|
||||
worklog_id: {
|
||||
[Op.in]: worklogIds
|
||||
}
|
||||
},
|
||||
raw: true
|
||||
});
|
||||
|
||||
// Gruppiere nach worklog_id
|
||||
const timefixMap = new Map();
|
||||
timefixes.forEach(fix => {
|
||||
if (!timefixMap.has(fix.worklog_id)) {
|
||||
timefixMap.set(fix.worklog_id, []);
|
||||
}
|
||||
timefixMap.get(fix.worklog_id).push(fix);
|
||||
});
|
||||
|
||||
return timefixMap;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Timefix-Einträge:', error);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worklog-Paare für Benutzer in einem Datumsbereich finden
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {Date} startDate - Start-Datum
|
||||
* @param {Date} endDate - End-Datum
|
||||
* @returns {Promise<Array>} Array von Worklog-Paaren
|
||||
*/
|
||||
async findPairsByUserInDateRange(userId, startDate, endDate) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
try {
|
||||
const results = await Worklog.findAll({
|
||||
attributes: ['id', 'version', 'user_id', 'state', 'tstamp', 'relatedTo_id'],
|
||||
where: {
|
||||
user_id: userId,
|
||||
tstamp: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
order: [['tstamp', 'ASC']],
|
||||
raw: true
|
||||
});
|
||||
|
||||
// Gruppiere Start/Stop-Paare basierend auf dem action-Feld
|
||||
const pairs = [];
|
||||
const startEntries = {};
|
||||
|
||||
results.forEach(entry => {
|
||||
// Parse state JSON
|
||||
let action = '';
|
||||
try {
|
||||
const state = typeof entry.state === 'string' ? JSON.parse(entry.state) : entry.state;
|
||||
action = state.action || state;
|
||||
} catch (e) {
|
||||
action = entry.state;
|
||||
}
|
||||
|
||||
if (action === 'start work') {
|
||||
startEntries[entry.id] = entry;
|
||||
} else if (action === 'stop work' && entry.relatedTo_id) {
|
||||
const startEntry = startEntries[entry.relatedTo_id];
|
||||
if (startEntry) {
|
||||
pairs.push({
|
||||
id: startEntry.id,
|
||||
start_time: startEntry.tstamp,
|
||||
end_time: entry.tstamp,
|
||||
start_state: startEntry.state,
|
||||
end_state: entry.state
|
||||
});
|
||||
delete startEntries[entry.relatedTo_id];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Füge laufende Einträge hinzu
|
||||
Object.values(startEntries).forEach(startEntry => {
|
||||
pairs.push({
|
||||
id: startEntry.id,
|
||||
start_time: startEntry.tstamp,
|
||||
end_time: null,
|
||||
start_state: startEntry.state,
|
||||
end_state: null
|
||||
});
|
||||
});
|
||||
|
||||
return pairs;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Worklog-Paare:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worklog-Einträge nach Datumsbereich abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {Date} startDate - Startdatum
|
||||
* @param {Date} endDate - Enddatum
|
||||
* @returns {Promise<Array>} Gefilterte Worklog-Einträge
|
||||
*/
|
||||
async findByDateRange(userId, startDate, endDate) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
return await Worklog.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
tstamp: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
order: [['tstamp', 'ASC']] // Aufsteigend, damit Start vor Stop kommt
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zusammengehörige Worklog-Paare abrufen (Clock In/Out)
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {Object} options - Optionen
|
||||
* @returns {Promise<Array>} Liste von Worklog-Paaren
|
||||
*/
|
||||
async findPairsByUser(userId, options = {}) {
|
||||
const { Worklog } = database.getModels();
|
||||
const sequelize = database.getSequelize();
|
||||
const { limit, offset } = options;
|
||||
|
||||
// Raw Query für bessere Performance bei Pairs
|
||||
const query = `
|
||||
SELECT
|
||||
w1.id as start_id,
|
||||
w1.tstamp as start_time,
|
||||
w1.state as start_state,
|
||||
w2.id as end_id,
|
||||
w2.tstamp as end_time,
|
||||
w2.state as end_state,
|
||||
TIMESTAMPDIFF(SECOND, w1.tstamp, w2.tstamp) as duration
|
||||
FROM worklog w1
|
||||
LEFT JOIN worklog w2 ON w2.relatedTo_id = w1.id
|
||||
WHERE w1.user_id = :userId
|
||||
AND w1.relatedTo_id IS NULL
|
||||
ORDER BY w1.tstamp DESC
|
||||
${limit ? `LIMIT ${limit}` : ''}
|
||||
${offset ? `OFFSET ${offset}` : ''}
|
||||
`;
|
||||
|
||||
const results = await sequelize.query(query, {
|
||||
replacements: { userId },
|
||||
type: sequelize.constructor.QueryTypes.SELECT
|
||||
});
|
||||
|
||||
return Array.isArray(results) ? results : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiken für Benutzer berechnen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Object>} Statistik-Objekt
|
||||
*/
|
||||
async getStatistics(userId) {
|
||||
const sequelize = database.getSequelize();
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(DISTINCT w1.id) as total_entries,
|
||||
COUNT(DISTINCT w2.id) as completed_entries,
|
||||
COUNT(DISTINCT CASE WHEN w2.id IS NULL THEN w1.id END) as running_entries,
|
||||
COALESCE(SUM(TIMESTAMPDIFF(SECOND, w1.tstamp, w2.tstamp)), 0) as total_seconds
|
||||
FROM worklog w1
|
||||
LEFT JOIN worklog w2 ON w2.relatedTo_id = w1.id
|
||||
WHERE w1.user_id = :userId
|
||||
AND w1.relatedTo_id IS NULL
|
||||
`;
|
||||
|
||||
const results = await sequelize.query(query, {
|
||||
replacements: { userId },
|
||||
type: sequelize.constructor.QueryTypes.SELECT
|
||||
});
|
||||
|
||||
return (Array.isArray(results) && results[0]) ? results[0] : {
|
||||
total_entries: 0,
|
||||
completed_entries: 0,
|
||||
running_entries: 0,
|
||||
total_seconds: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiken für heute abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Object>} Heutige Statistiken
|
||||
*/
|
||||
async getTodayStatistics(userId) {
|
||||
const sequelize = database.getSequelize();
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(DISTINCT w1.id) as entries,
|
||||
COALESCE(SUM(TIMESTAMPDIFF(SECOND, w1.tstamp, w2.tstamp)), 0) as seconds
|
||||
FROM worklog w1
|
||||
LEFT JOIN worklog w2 ON w2.relatedTo_id = w1.id
|
||||
WHERE w1.user_id = :userId
|
||||
AND w1.relatedTo_id IS NULL
|
||||
AND DATE(w1.tstamp) = CURDATE()
|
||||
`;
|
||||
|
||||
const results = await sequelize.query(query, {
|
||||
replacements: { userId },
|
||||
type: sequelize.constructor.QueryTypes.SELECT
|
||||
});
|
||||
|
||||
return (Array.isArray(results) && results[0]) ? results[0] : { entries: 0, seconds: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Feiertage in einem Datumsbereich abrufen
|
||||
* @param {Date} startDate - Start-Datum
|
||||
* @param {Date} endDate - End-Datum
|
||||
* @returns {Promise<Array>} Array von Holiday-Einträgen mit Datum und Stunden
|
||||
*/
|
||||
async getHolidaysInDateRange(startDate, endDate) {
|
||||
const { Holiday } = database.getModels();
|
||||
|
||||
try {
|
||||
const holidays = await Holiday.findAll({
|
||||
where: {
|
||||
date: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
raw: true,
|
||||
order: [['date', 'ASC']]
|
||||
});
|
||||
|
||||
return holidays;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Feiertage:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WorklogRepository();
|
||||
26
backend/src/routes/auth.js
Normal file
26
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const authController = require('../controllers/AuthController');
|
||||
const oauthController = require('../controllers/OAuthController');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
// Öffentliche Routes (kein Auth erforderlich)
|
||||
router.post('/register', authController.register.bind(authController));
|
||||
router.post('/login', authController.login.bind(authController));
|
||||
router.post('/request-reset', authController.requestPasswordReset.bind(authController));
|
||||
router.post('/reset-password', authController.resetPassword.bind(authController));
|
||||
|
||||
// OAuth Routes (öffentlich)
|
||||
router.get('/google', oauthController.googleAuth.bind(oauthController));
|
||||
router.get('/google/callback', oauthController.googleCallback.bind(oauthController));
|
||||
|
||||
// Geschützte Routes (Auth erforderlich)
|
||||
router.post('/logout', authenticateToken, authController.logout.bind(authController));
|
||||
router.get('/me', authenticateToken, authController.getCurrentUser.bind(authController));
|
||||
router.post('/change-password', authenticateToken, authController.changePassword.bind(authController));
|
||||
router.get('/validate', authenticateToken, authController.validateToken.bind(authController));
|
||||
router.get('/identities', authenticateToken, oauthController.getIdentities.bind(oauthController));
|
||||
router.delete('/identity/:provider', authenticateToken, oauthController.unlinkProvider.bind(oauthController));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
36
backend/src/routes/timeEntries.js
Normal file
36
backend/src/routes/timeEntries.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const timeEntryController = require('../controllers/TimeEntryController');
|
||||
|
||||
// GET Statistiken (muss vor /:id stehen, um Konflikte zu vermeiden)
|
||||
router.get('/stats/summary', timeEntryController.getStats.bind(timeEntryController));
|
||||
|
||||
// GET aktuellen Status
|
||||
router.get('/current-state', timeEntryController.getCurrentState.bind(timeEntryController));
|
||||
|
||||
// POST Stempeln (Clock In/Out, Pause)
|
||||
router.post('/clock', timeEntryController.clock.bind(timeEntryController));
|
||||
|
||||
// GET aktuell laufenden Timer
|
||||
router.get('/running', timeEntryController.getRunningEntry.bind(timeEntryController));
|
||||
|
||||
// GET Einträge nach Projekt
|
||||
router.get('/project/:projectName', timeEntryController.getEntriesByProject.bind(timeEntryController));
|
||||
|
||||
// GET alle Zeiteinträge
|
||||
router.get('/', timeEntryController.getAllEntries.bind(timeEntryController));
|
||||
|
||||
// GET einzelner Zeiteintrag
|
||||
router.get('/:id', timeEntryController.getEntryById.bind(timeEntryController));
|
||||
|
||||
// POST neuer Zeiteintrag (Clock In)
|
||||
router.post('/', timeEntryController.createEntry.bind(timeEntryController));
|
||||
|
||||
// PUT Zeiteintrag aktualisieren (Clock Out)
|
||||
router.put('/:id', timeEntryController.updateEntry.bind(timeEntryController));
|
||||
|
||||
// DELETE Zeiteintrag löschen
|
||||
router.delete('/:id', timeEntryController.deleteEntry.bind(timeEntryController));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
16
backend/src/routes/weekOverview.js
Normal file
16
backend/src/routes/weekOverview.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const weekOverviewController = require('../controllers/WeekOverviewController');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
/**
|
||||
* Wochenübersicht-Routes
|
||||
* Alle Routes sind geschützt und erfordern Authentifizierung
|
||||
*/
|
||||
|
||||
// GET /api/week-overview - Wochenübersicht abrufen
|
||||
router.get('/', authenticateToken, weekOverviewController.getWeekOverview);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
453
backend/src/services/AuthService.js
Normal file
453
backend/src/services/AuthService.js
Normal file
@@ -0,0 +1,453 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const database = require('../config/database');
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
* Verwaltet Login, Registrierung, Passwort-Reset
|
||||
*/
|
||||
class AuthService {
|
||||
constructor() {
|
||||
this.jwtSecret = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
this.jwtExpiration = process.env.JWT_EXPIRATION || '24h';
|
||||
this.saltRounds = 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer registrieren
|
||||
* @param {Object} userData - Registrierungsdaten
|
||||
* @returns {Promise<Object>} Erstellter Benutzer mit Auth-Info
|
||||
*/
|
||||
async register(userData) {
|
||||
const { User, AuthInfo } = database.getModels();
|
||||
const { email, password, full_name } = userData;
|
||||
|
||||
// Validierung
|
||||
if (!email || !password || !full_name) {
|
||||
throw new Error('E-Mail, Passwort und Name sind erforderlich');
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
}
|
||||
|
||||
// Prüfen ob E-Mail bereits existiert
|
||||
const existingAuth = await AuthInfo.findOne({ where: { email } });
|
||||
if (existingAuth) {
|
||||
throw new Error('Diese E-Mail-Adresse ist bereits registriert');
|
||||
}
|
||||
|
||||
// Passwort hashen
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(password, salt);
|
||||
|
||||
// Benutzer erstellen
|
||||
const user = await User.create({
|
||||
full_name,
|
||||
role: 0,
|
||||
daily_hours: 8,
|
||||
week_hours: 40,
|
||||
week_workdays: 5,
|
||||
preferred_title_type: 0,
|
||||
version: 0,
|
||||
last_change: new Date()
|
||||
});
|
||||
|
||||
// Auth-Info erstellen
|
||||
const authInfo = await AuthInfo.create({
|
||||
user_id: user.id,
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
password_method: 'bcrypt',
|
||||
password_salt: salt,
|
||||
status: 1, // Active
|
||||
failed_login_attempts: 0,
|
||||
email_token: '',
|
||||
email_token_role: 0,
|
||||
unverified_email: '',
|
||||
version: 0
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
full_name: user.full_name,
|
||||
email: authInfo.email
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer einloggen
|
||||
* @param {string} email - E-Mail-Adresse
|
||||
* @param {string} password - Passwort
|
||||
* @returns {Promise<Object>} Token und Benutzer-Info
|
||||
*/
|
||||
async login(email, password) {
|
||||
const { User, AuthInfo, AuthToken } = database.getModels();
|
||||
|
||||
console.log('Login-Versuch für E-Mail:', email);
|
||||
|
||||
// Auth-Info mit Benutzer laden
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: { email },
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user',
|
||||
required: true
|
||||
}]
|
||||
});
|
||||
|
||||
console.log('AuthInfo gefunden:', !!authInfo);
|
||||
if (authInfo) {
|
||||
console.log('AuthInfo E-Mail:', authInfo.email);
|
||||
console.log('User ID:', authInfo.user?.id);
|
||||
}
|
||||
|
||||
if (!authInfo) {
|
||||
throw new Error('Ungültige E-Mail oder Passwort');
|
||||
}
|
||||
|
||||
// Account-Status prüfen
|
||||
if (authInfo.status !== 1) {
|
||||
throw new Error('Ihr Account ist deaktiviert. Bitte kontaktieren Sie den Support.');
|
||||
}
|
||||
|
||||
// Zu viele fehlgeschlagene Versuche?
|
||||
if (authInfo.failed_login_attempts >= 5) {
|
||||
const lastAttempt = authInfo.last_login_attempt;
|
||||
const lockoutTime = 15 * 60 * 1000; // 15 Minuten
|
||||
|
||||
if (lastAttempt && (Date.now() - new Date(lastAttempt).getTime()) < lockoutTime) {
|
||||
throw new Error('Account temporär gesperrt. Bitte versuchen Sie es später erneut.');
|
||||
} else {
|
||||
// Reset nach Lockout-Zeit
|
||||
await authInfo.update({
|
||||
failed_login_attempts: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Passwort verifizieren
|
||||
const isValidPassword = await bcrypt.compare(password, authInfo.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
// Fehlversuch registrieren
|
||||
await authInfo.update({
|
||||
failed_login_attempts: authInfo.failed_login_attempts + 1,
|
||||
last_login_attempt: new Date()
|
||||
});
|
||||
throw new Error('Ungültige E-Mail oder Passwort');
|
||||
}
|
||||
|
||||
// Login erfolgreich - Reset fehlgeschlagene Versuche
|
||||
await authInfo.update({
|
||||
failed_login_attempts: 0,
|
||||
last_login_attempt: new Date()
|
||||
});
|
||||
|
||||
// JWT Token generieren
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: authInfo.user.id,
|
||||
email: authInfo.email,
|
||||
role: authInfo.user.role
|
||||
},
|
||||
this.jwtSecret,
|
||||
{ expiresIn: this.jwtExpiration }
|
||||
);
|
||||
|
||||
// Token in Datenbank speichern
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||
|
||||
await AuthToken.create({
|
||||
auth_info_id: authInfo.id,
|
||||
value: crypto.createHash('sha256').update(token).digest('hex'),
|
||||
expires: expiresAt,
|
||||
version: 0
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: authInfo.user.id,
|
||||
full_name: authInfo.user.full_name,
|
||||
email: authInfo.email,
|
||||
role: authInfo.user.role
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Token validieren
|
||||
* @param {string} token - JWT Token
|
||||
* @returns {Promise<Object>} Dekodierte Token-Daten
|
||||
*/
|
||||
async validateToken(token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, this.jwtSecret);
|
||||
|
||||
// Prüfen ob Token in DB existiert und nicht abgelaufen
|
||||
const { AuthToken } = database.getModels();
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
const dbToken = await AuthToken.findOne({
|
||||
where: { value: tokenHash }
|
||||
});
|
||||
|
||||
if (!dbToken || dbToken.isExpired()) {
|
||||
throw new Error('Token ungültig oder abgelaufen');
|
||||
}
|
||||
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
throw new Error('Ungültiger Token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort-Reset anfordern
|
||||
* @param {string} email - E-Mail-Adresse
|
||||
* @returns {Promise<string>} Reset-Token
|
||||
*/
|
||||
async requestPasswordReset(email) {
|
||||
const { AuthInfo } = database.getModels();
|
||||
|
||||
const authInfo = await AuthInfo.findOne({ where: { email } });
|
||||
|
||||
if (!authInfo) {
|
||||
// Aus Sicherheitsgründen keine Fehlermeldung, dass E-Mail nicht existiert
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reset-Token generieren
|
||||
const resetToken = crypto.randomBytes(32).toString('hex');
|
||||
const tokenExpires = new Date();
|
||||
tokenExpires.setHours(tokenExpires.getHours() + 1); // 1 Stunde gültig
|
||||
|
||||
// Token speichern
|
||||
await authInfo.update({
|
||||
email_token: resetToken,
|
||||
email_token_expires: tokenExpires,
|
||||
email_token_role: 1 // 1 = Password Reset
|
||||
});
|
||||
|
||||
return resetToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort zurücksetzen
|
||||
* @param {string} token - Reset-Token
|
||||
* @param {string} newPassword - Neues Passwort
|
||||
* @returns {Promise<boolean>} Erfolg
|
||||
*/
|
||||
async resetPassword(token, newPassword) {
|
||||
const { AuthInfo } = database.getModels();
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
}
|
||||
|
||||
// Development-Token erkennen und behandeln
|
||||
const isDevelopment = !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
|
||||
const isDevToken = token && token.startsWith('dev-test-token-');
|
||||
|
||||
if (isDevelopment && isDevToken) {
|
||||
// Im Development-Modus: Token akzeptieren und Passwort für spezifischen Benutzer ändern
|
||||
console.log('Development-Token erkannt:', token);
|
||||
|
||||
// E-Mail aus Token extrahieren (Format: dev-test-token-timestamp-email)
|
||||
// Token: "dev-test-token-1760554590751-tsschulz@tsschulz.de"
|
||||
const emailMatch = token.match(/dev-test-token-\d+-(.+)/);
|
||||
const email = emailMatch ? emailMatch[1] : null;
|
||||
|
||||
console.log('Development: Extrahierte E-Mail:', email);
|
||||
|
||||
if (email) {
|
||||
// Finde den Benutzer mit dieser E-Mail
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: { email: email }
|
||||
});
|
||||
|
||||
if (authInfo) {
|
||||
console.log('Development: Ändere Passwort für Benutzer:', authInfo.email);
|
||||
|
||||
// Neues Passwort hashen und speichern
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
await authInfo.update({
|
||||
password_hash: passwordHash,
|
||||
password_salt: salt,
|
||||
email_token: '', // Token löschen
|
||||
email_token_expires: null,
|
||||
email_token_role: 0
|
||||
});
|
||||
|
||||
console.log('Development: Passwort erfolgreich geändert für:', authInfo.email);
|
||||
return true;
|
||||
} else {
|
||||
console.log('Development: Kein Benutzer mit E-Mail', email, 'gefunden');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Ändere Passwort für ersten Benutzer
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
|
||||
if (authInfo) {
|
||||
console.log('Development: Fallback - Ändere Passwort für ersten Benutzer:', authInfo.email);
|
||||
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
await authInfo.update({
|
||||
password_hash: passwordHash,
|
||||
password_salt: salt,
|
||||
email_token: '',
|
||||
email_token_expires: null,
|
||||
email_token_role: 0
|
||||
});
|
||||
|
||||
console.log('Development: Passwort erfolgreich geändert für:', authInfo.email);
|
||||
return true;
|
||||
} else {
|
||||
console.log('Development: Kein Benutzer in der Datenbank gefunden');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: {
|
||||
email_token: token,
|
||||
email_token_role: 1
|
||||
}
|
||||
});
|
||||
|
||||
if (!authInfo) {
|
||||
throw new Error('Ungültiger oder abgelaufener Reset-Token');
|
||||
}
|
||||
|
||||
// Token-Ablauf prüfen
|
||||
if (authInfo.email_token_expires && new Date() > new Date(authInfo.email_token_expires)) {
|
||||
throw new Error('Reset-Token ist abgelaufen');
|
||||
}
|
||||
|
||||
// Neues Passwort hashen
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
// Passwort aktualisieren und Token löschen
|
||||
await authInfo.update({
|
||||
password_hash: passwordHash,
|
||||
password_salt: salt,
|
||||
email_token: '',
|
||||
email_token_expires: null,
|
||||
email_token_role: 0,
|
||||
failed_login_attempts: 0
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort ändern (für eingeloggten Benutzer)
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {string} oldPassword - Altes Passwort
|
||||
* @param {string} newPassword - Neues Passwort
|
||||
* @returns {Promise<boolean>} Erfolg
|
||||
*/
|
||||
async changePassword(userId, oldPassword, newPassword) {
|
||||
const { User, AuthInfo } = database.getModels();
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
}
|
||||
|
||||
const user = await User.findByPk(userId, {
|
||||
include: [{
|
||||
model: AuthInfo,
|
||||
as: 'authInfo',
|
||||
required: true
|
||||
}]
|
||||
});
|
||||
|
||||
if (!user || !user.authInfo) {
|
||||
throw new Error('Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
// Altes Passwort verifizieren
|
||||
const isValidPassword = await bcrypt.compare(oldPassword, user.authInfo.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
throw new Error('Altes Passwort ist falsch');
|
||||
}
|
||||
|
||||
// Neues Passwort hashen
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
// Passwort aktualisieren
|
||||
await user.authInfo.update({
|
||||
password_hash: passwordHash,
|
||||
password_salt: salt,
|
||||
version: user.authInfo.version + 1
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ausloggen (Token invalidieren)
|
||||
* @param {string} token - JWT Token
|
||||
* @returns {Promise<boolean>} Erfolg
|
||||
*/
|
||||
async logout(token) {
|
||||
const { AuthToken } = database.getModels();
|
||||
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
await AuthToken.destroy({
|
||||
where: { value: tokenHash }
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer-Profil abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Object>} Benutzer-Profil
|
||||
*/
|
||||
async getUserProfile(userId) {
|
||||
const { User, AuthInfo } = database.getModels();
|
||||
|
||||
const user = await User.findByPk(userId, {
|
||||
include: [{
|
||||
model: AuthInfo,
|
||||
as: 'authInfo',
|
||||
attributes: ['email']
|
||||
}]
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
full_name: user.full_name,
|
||||
email: user.authInfo?.email,
|
||||
role: user.role,
|
||||
daily_hours: user.daily_hours,
|
||||
week_hours: user.week_hours,
|
||||
week_workdays: user.week_workdays
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthService();
|
||||
|
||||
209
backend/src/services/OAuthService.js
Normal file
209
backend/src/services/OAuthService.js
Normal file
@@ -0,0 +1,209 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const database = require('../config/database');
|
||||
|
||||
/**
|
||||
* OAuth Service
|
||||
* Verwaltet OAuth/SSO-Logins (Google, GitHub, etc.)
|
||||
*/
|
||||
class OAuthService {
|
||||
constructor() {
|
||||
this.jwtSecret = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
this.jwtExpiration = process.env.JWT_EXPIRATION || '24h';
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Login/Registrierung
|
||||
* @param {Object} profile - OAuth-Provider-Profil
|
||||
* @param {string} provider - Provider-Name (google, github, etc.)
|
||||
* @returns {Promise<Object>} Token und Benutzer-Info
|
||||
*/
|
||||
async authenticateWithProvider(profile, provider) {
|
||||
const { User, AuthInfo, AuthIdentity, AuthToken } = database.getModels();
|
||||
|
||||
const providerId = profile.id;
|
||||
const email = profile.emails && profile.emails[0] ? profile.emails[0].value : null;
|
||||
const displayName = profile.displayName || profile.name || email;
|
||||
|
||||
if (!providerId) {
|
||||
throw new Error('OAuth Provider-ID fehlt');
|
||||
}
|
||||
|
||||
// Prüfen ob OAuth-Identity bereits existiert
|
||||
let authIdentity = await AuthIdentity.findOne({
|
||||
where: {
|
||||
provider,
|
||||
identity: providerId
|
||||
},
|
||||
include: [{
|
||||
model: database.getModels().AuthInfo,
|
||||
as: 'authInfo',
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user'
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let user, authInfo;
|
||||
|
||||
if (authIdentity && authIdentity.authInfo) {
|
||||
// Bestehender OAuth-Benutzer
|
||||
authInfo = authIdentity.authInfo;
|
||||
user = authInfo.user;
|
||||
} else {
|
||||
// Neuer OAuth-Benutzer oder Verknüpfung mit bestehendem Account
|
||||
|
||||
// Prüfen ob Benutzer mit dieser E-Mail bereits existiert
|
||||
if (email) {
|
||||
authInfo = await AuthInfo.findOne({
|
||||
where: { email },
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user'
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
if (authInfo) {
|
||||
// Verknüpfe OAuth mit bestehendem Account
|
||||
user = authInfo.user;
|
||||
|
||||
authIdentity = await AuthIdentity.create({
|
||||
auth_info_id: authInfo.id,
|
||||
provider,
|
||||
identity: providerId,
|
||||
version: 0
|
||||
});
|
||||
} else {
|
||||
// Neuen Benutzer erstellen
|
||||
user = await User.create({
|
||||
full_name: displayName,
|
||||
role: 0,
|
||||
daily_hours: 8,
|
||||
week_hours: 40,
|
||||
week_workdays: 5,
|
||||
preferred_title_type: 0,
|
||||
version: 0,
|
||||
last_change: new Date()
|
||||
});
|
||||
|
||||
// Auth-Info erstellen (ohne Passwort für OAuth-only Accounts)
|
||||
authInfo = await AuthInfo.create({
|
||||
user_id: user.id,
|
||||
email: email || `${provider}_${providerId}@oauth.local`,
|
||||
password_hash: '', // Kein Passwort für OAuth-only
|
||||
password_method: 'oauth',
|
||||
password_salt: '',
|
||||
status: 1,
|
||||
failed_login_attempts: 0,
|
||||
email_token: '',
|
||||
email_token_role: 0,
|
||||
unverified_email: '',
|
||||
version: 0
|
||||
});
|
||||
|
||||
// OAuth-Identity erstellen
|
||||
authIdentity = await AuthIdentity.create({
|
||||
auth_info_id: authInfo.id,
|
||||
provider,
|
||||
identity: providerId,
|
||||
version: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// JWT Token generieren
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
email: authInfo.email,
|
||||
role: user.role,
|
||||
provider
|
||||
},
|
||||
this.jwtSecret,
|
||||
{ expiresIn: this.jwtExpiration }
|
||||
);
|
||||
|
||||
// Token in Datenbank speichern
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||
|
||||
await AuthToken.create({
|
||||
auth_info_id: authInfo.id,
|
||||
value: crypto.createHash('sha256').update(token).digest('hex'),
|
||||
expires: expiresAt,
|
||||
version: 0
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
full_name: user.full_name,
|
||||
email: authInfo.email,
|
||||
role: user.role,
|
||||
provider
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Identity für Benutzer abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Array>} Liste verknüpfter OAuth-Provider
|
||||
*/
|
||||
async getUserIdentities(userId) {
|
||||
const { AuthInfo, AuthIdentity } = database.getModels();
|
||||
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: { user_id: userId },
|
||||
include: [{
|
||||
model: AuthIdentity,
|
||||
as: 'identities'
|
||||
}]
|
||||
});
|
||||
|
||||
if (!authInfo || !authInfo.identities) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return authInfo.identities.map(identity => ({
|
||||
provider: identity.provider,
|
||||
identity: identity.identity,
|
||||
id: identity.id
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Identity entfernen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {string} provider - Provider-Name
|
||||
* @returns {Promise<boolean>} Erfolg
|
||||
*/
|
||||
async unlinkProvider(userId, provider) {
|
||||
const { AuthInfo, AuthIdentity } = database.getModels();
|
||||
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: { user_id: userId }
|
||||
});
|
||||
|
||||
if (!authInfo) {
|
||||
throw new Error('Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
const deleted = await AuthIdentity.destroy({
|
||||
where: {
|
||||
auth_info_id: authInfo.id,
|
||||
provider
|
||||
}
|
||||
});
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new OAuthService();
|
||||
|
||||
|
||||
|
||||
2380
backend/src/services/TimeEntryService.js
Normal file
2380
backend/src/services/TimeEntryService.js
Normal file
File diff suppressed because it is too large
Load Diff
143
backend/src/utils/hashId.js
Normal file
143
backend/src/utils/hashId.js
Normal file
@@ -0,0 +1,143 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Utility für ID-Hashing
|
||||
* Konvertiert numerische IDs in Hashes und zurück
|
||||
*/
|
||||
class HashId {
|
||||
constructor() {
|
||||
// Secret aus Umgebungsvariable oder Fallback
|
||||
this.secret = process.env.HASH_ID_SECRET || 'timeclock-hash-secret-change-in-production';
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert eine numerische ID in einen Hash
|
||||
* @param {number} id - Numerische ID
|
||||
* @returns {string} Hash-String
|
||||
*/
|
||||
encode(id) {
|
||||
if (!id) return null;
|
||||
|
||||
// Erstelle einen deterministischen Hash aus ID + Secret
|
||||
const hmac = crypto.createHmac('sha256', this.secret);
|
||||
hmac.update(id.toString());
|
||||
const hash = hmac.digest('base64url'); // base64url ist URL-sicher
|
||||
|
||||
// Füge die ID in verschlüsselter Form hinzu für Dekodierung
|
||||
const encrypted = this.encryptId(id);
|
||||
|
||||
// Format: {verschlüsselte-id}.{hash-prefix}
|
||||
return `${encrypted}.${hash.substring(0, 12)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert einen Hash zurück in eine numerische ID
|
||||
* @param {string} hash - Hash-String
|
||||
* @returns {number|null} Numerische ID oder null bei Fehler
|
||||
*/
|
||||
decode(hash) {
|
||||
if (!hash || typeof hash !== 'string') return null;
|
||||
|
||||
try {
|
||||
// Extrahiere verschlüsselte ID
|
||||
const parts = hash.split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
|
||||
const encrypted = parts[0];
|
||||
const hashPart = parts[1];
|
||||
|
||||
// Entschlüssele ID
|
||||
const id = this.decryptId(encrypted);
|
||||
if (!id) return null;
|
||||
|
||||
// Verifiziere Hash
|
||||
const expectedHash = this.encode(id);
|
||||
if (!expectedHash || !expectedHash.endsWith(hashPart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Dekodieren der Hash-ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschlüsselt eine ID
|
||||
* @private
|
||||
*/
|
||||
encryptId(id) {
|
||||
const cipher = crypto.createCipheriv(
|
||||
'aes-256-cbc',
|
||||
crypto.scryptSync(this.secret, 'salt', 32),
|
||||
Buffer.alloc(16, 0) // IV
|
||||
);
|
||||
|
||||
let encrypted = cipher.update(id.toString(), 'utf8', 'base64url');
|
||||
encrypted += cipher.final('base64url');
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entschlüsselt eine ID
|
||||
* @private
|
||||
*/
|
||||
decryptId(encrypted) {
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-cbc',
|
||||
crypto.scryptSync(this.secret, 'salt', 32),
|
||||
Buffer.alloc(16, 0) // IV
|
||||
);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'base64url', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return parseInt(decrypted, 10);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert ein Objekt mit IDs in eines mit Hashes
|
||||
* @param {Object} obj - Objekt mit ID-Feldern
|
||||
* @param {Array<string>} idFields - Array von Feldnamen, die IDs enthalten
|
||||
* @returns {Object} Objekt mit gehashten IDs
|
||||
*/
|
||||
encodeObject(obj, idFields = ['id', 'user_id', 'auth_info_id']) {
|
||||
if (!obj) return obj;
|
||||
|
||||
const result = { ...obj };
|
||||
|
||||
for (const field of idFields) {
|
||||
if (result[field] !== null && result[field] !== undefined) {
|
||||
result[field] = this.encode(result[field]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert ein Array von Objekten
|
||||
* @param {Array} array - Array von Objekten
|
||||
* @param {Array<string>} idFields - Array von Feldnamen, die IDs enthalten
|
||||
* @returns {Array} Array mit gehashten IDs
|
||||
*/
|
||||
encodeArray(array, idFields = ['id', 'user_id', 'auth_info_id']) {
|
||||
if (!Array.isArray(array)) return array;
|
||||
|
||||
return array.map(obj => this.encodeObject(obj, idFields));
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz
|
||||
const hashId = new HashId();
|
||||
|
||||
module.exports = hashId;
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user