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:
Torsten Schulz (local)
2025-10-17 14:11:28 +02:00
commit e95bb4cb76
86 changed files with 19530 additions and 0 deletions

9
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
.env
*.log
.DS_Store
dist/
coverage/

298
backend/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,298 @@
# Backend-Architektur
## Übersicht
Das Backend folgt einer modernen, klassenbasierten Architektur mit klarer Trennung der Verantwortlichkeiten (Separation of Concerns).
## Architektur-Schichten
```
┌─────────────────────────────────────┐
│ Routes (Express) │ ← HTTP-Endpunkte definieren
├─────────────────────────────────────┤
│ Controller-Klassen │ ← HTTP Request/Response Handling
├─────────────────────────────────────┤
│ Service-Klassen │ ← Business-Logik
├─────────────────────────────────────┤
│ Models │ ← Datenmodelle & Validierung
├─────────────────────────────────────┤
│ Datenspeicher │ ← In-Memory / Datenbank
└─────────────────────────────────────┘
```
## Komponenten
### 1. Routes (`src/routes/`)
**Verantwortlichkeit:** Definition der HTTP-Endpunkte
```javascript
// src/routes/timeEntries.js
router.get('/', timeEntryController.getAllEntries.bind(timeEntryController));
```
- Definiert URL-Pfade und HTTP-Methoden
- Bindet Endpunkte an Controller-Methoden
- Keine Business-Logik
### 2. Controller (`src/controllers/`)
**Verantwortlichkeit:** HTTP Request/Response Handling
```javascript
// src/controllers/TimeEntryController.js
class TimeEntryController {
async getAllEntries(req, res) {
try {
const entries = timeEntryService.getAllEntries();
res.json(entries);
} catch (error) {
res.status(500).json({ error: 'Fehler...' });
}
}
}
```
**Controller sind zuständig für:**
- ✅ Request-Parameter extrahieren
- ✅ Service-Methoden aufrufen
- ✅ HTTP-Statuscodes setzen
- ✅ Response formatieren
- ✅ Error-Handling (HTTP-spezifisch)
**Controller sind NICHT zuständig für:**
- ❌ Business-Logik
- ❌ Datenvalidierung (außer HTTP-Parameter)
- ❌ Datenbankzugriff
- ❌ Komplexe Berechnungen
### 3. Services (`src/services/`)
**Verantwortlichkeit:** Business-Logik
```javascript
// src/services/TimeEntryService.js
class TimeEntryService {
createEntry(entryData) {
// Validierung
const runningEntry = this.timeEntries.find(e => e.isRunning);
if (runningEntry) {
throw new Error('Es läuft bereits ein Timer...');
}
// Business-Logik
const newEntry = new TimeEntry({ ... });
newEntry.validate();
// Speichern
this.timeEntries.push(newEntry);
return newEntry;
}
}
```
**Services sind zuständig für:**
- ✅ Komplette Business-Logik
- ✅ Datenvalidierung
- ✅ Datenzugriff
- ✅ Berechnungen
- ✅ Business-Rules
- ✅ Transaktionen
**Services sind NICHT zuständig für:**
- ❌ HTTP-spezifische Logik
- ❌ Response-Formatierung
- ❌ HTTP-Statuscodes
### 4. Models (`src/models/`)
**Verantwortlichkeit:** Datenmodelle und Validierung
```javascript
// src/models/TimeEntry.js
class TimeEntry {
constructor(data) { ... }
validate() {
if (!this.startTime) {
throw new Error('Startzeit ist erforderlich');
}
}
calculateDuration() { ... }
getFormattedDuration() { ... }
}
```
**Models sind zuständig für:**
- ✅ Datenstruktur definieren
- ✅ Datenvalidierung
- ✅ Hilfsmethoden für Daten
- ✅ Formatierung
## Vorteile dieser Architektur
### 1. **Separation of Concerns**
Jede Schicht hat eine klar definierte Verantwortlichkeit:
- Routes → Routing
- Controller → HTTP-Handling
- Services → Business-Logik
- Models → Datenmodelle
### 2. **Testbarkeit**
```javascript
// Services können isoliert getestet werden
const service = new TimeEntryService();
const entry = service.createEntry({ project: 'Test' });
```
### 3. **Wiederverwendbarkeit**
Services können von verschiedenen Controllern oder anderen Services verwendet werden.
### 4. **Wartbarkeit**
Änderungen an der Business-Logik betreffen nur Services, nicht Controller oder Routes.
### 5. **Skalierbarkeit**
- Services können einfach auf Microservices aufgeteilt werden
- Einfache Integration von Datenbanken
- Caching-Schichten hinzufügen
## Datenfluss
### Beispiel: Neuen Zeiteintrag erstellen
```
1. HTTP POST Request
2. Route: POST /api/time-entries
3. Controller.createEntry()
- Extrahiert req.body
- Ruft Service auf
4. Service.createEntry()
- Validiert Daten
- Prüft Business-Rules
- Erstellt Model
- Speichert Daten
5. Controller.createEntry()
- Setzt Status 201
- Sendet JSON Response
6. HTTP Response
```
## Singleton-Pattern
Sowohl Controller als auch Services werden als Singleton exportiert:
```javascript
// Am Ende der Datei
module.exports = new TimeEntryService();
```
**Vorteile:**
- Gleicher Datenspeicher über alle Requests
- Keine Instanziierung bei jedem Request
- Shared State (für In-Memory Storage)
**Hinweis:** Bei Datenbank-Integration kann auf Singleton verzichtet werden.
## Erweiterte Features
### Service-Features
Die `TimeEntryService`-Klasse bietet erweiterte Funktionen:
```javascript
// Statistiken mit Zeitfilter
getStatistics() {
totalEntries,
projectStats,
timeStats: { today, week, month }
}
// Laufenden Timer abrufen
getRunningEntry()
// Nach Projekt filtern
getEntriesByProject(projectName)
// Datumsbereich filtern
getEntriesByDateRange(startDate, endDate)
```
### Controller-Features
Der `TimeEntryController` bietet zusätzliche Endpunkte:
```javascript
// GET /api/time-entries/running
getRunningEntry()
// GET /api/time-entries/project/:projectName
getEntriesByProject()
```
## Best Practices
### 1. Error Handling
**In Services:**
```javascript
throw new Error('Beschreibende Fehlermeldung');
```
**In Controllern:**
```javascript
catch (error) {
if (error.message.includes('nicht gefunden')) {
return res.status(404).json({ error: '...' });
}
res.status(500).json({ error: '...' });
}
```
### 2. Async/Await
Controller-Methoden sind `async`, auch wenn Services aktuell synchron sind:
```javascript
async getAllEntries(req, res) {
// Vorbereitet für asynchrone Service-Calls (z.B. Datenbank)
}
```
### 3. Binding
Bei Klassen-Methoden als Express-Handler muss `bind()` verwendet werden:
```javascript
router.get('/', controller.method.bind(controller));
```
## Migration auf Datenbank
Die Architektur ist vorbereitet für Datenbank-Integration:
```javascript
class TimeEntryService {
async createEntry(entryData) {
// Statt In-Memory:
// return await TimeEntryModel.create(entryData);
}
}
```
Nur die Service-Schicht muss angepasst werden, Controller bleiben unverändert!
## Zusammenfassung
| Schicht | Datei | Verantwortlichkeit | Beispiel |
|---------|-------|-------------------|----------|
| **Routes** | `timeEntries.js` | URL-Mapping | `router.get('/', ...)` |
| **Controller** | `TimeEntryController.js` | HTTP-Handling | `res.json(data)` |
| **Service** | `TimeEntryService.js` | Business-Logik | `validateData()` |
| **Model** | `TimeEntry.js` | Datenmodell | `validate()` |
Diese Architektur ermöglicht eine professionelle, wartbare und skalierbare Backend-Anwendung! 🚀

370
backend/DATABASE.md Normal file
View File

@@ -0,0 +1,370 @@
# Datenbank-Integration
## Übersicht
TimeClock v3 verwendet MySQL mit der bestehenden `stechuhr2` Datenbankstruktur. Die Integration erfolgt über das Repository-Pattern für saubere Abstraktion.
## Architektur
```
Controller
Service (Business-Logik)
Repository (Datenbankzugriff)
MySQL Datenbank
```
## Verwendete Tabellen
### Haupttabellen
#### `worklog` - Zeiteinträge
```sql
CREATE TABLE `worklog` (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` int NOT NULL,
`user_id` bigint NOT NULL,
`state` text NOT NULL, -- JSON mit {action, project, description}
`tstamp` datetime DEFAULT NULL,
`relatedTo_id` bigint DEFAULT NULL, -- Verknüpfung Clock In/Out
PRIMARY KEY (`id`)
)
```
**Verwendung:**
- Clock In: Neuer Eintrag mit `relatedTo_id = NULL`
- Clock Out: Neuer Eintrag mit `relatedTo_id` verweist auf Clock In
- State: JSON-Format `{"action": "Clock In", "project": "Projektname", "description": "Beschreibung"}`
#### `user` - Benutzer
```sql
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`full_name` text NOT NULL,
`daily_hours` int NOT NULL,
`week_hours` int NOT NULL,
...
)
```
#### `weekly_worktime` - Wochenarbeitszeit
```sql
CREATE TABLE `weekly_worktime` (
`id` int NOT NULL AUTO_INCREMENT,
`weekly_work_time` double NOT NULL DEFAULT '40',
`starting_from` date DEFAULT NULL,
`ends_at` date DEFAULT NULL,
`user_id` int DEFAULT NULL,
...
)
```
## Konfiguration
### Umgebungsvariablen (`.env`)
```env
# Datenbank-Verbindung
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=stechuhr2
```
### Connection Pool
Die Datenbankverbindung wird über einen Connection Pool verwaltet:
```javascript
// src/config/database.js
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 10
});
```
## Repository-Pattern
### WorklogRepository
**Verantwortlichkeiten:**
- CRUD-Operationen auf `worklog` Tabelle
- Komplexe Queries (Paare, Statistiken)
- Datenbankzugriff abstrahieren
**Hauptmethoden:**
```javascript
await worklogRepository.findAllByUser(userId)
await worklogRepository.findById(id)
await worklogRepository.create(worklogData)
await worklogRepository.update(id, updateData)
await worklogRepository.delete(id)
await worklogRepository.findPairsByUser(userId)
await worklogRepository.getStatistics(userId)
```
### UserRepository
**Verantwortlichkeiten:**
- Benutzerverwaltung
- Wochenarbeitszeit abrufen
- Benutzereinstellungen
**Hauptmethoden:**
```javascript
await userRepository.findById(userId)
await userRepository.findByEmail(email)
await userRepository.getWeeklyWorktime(userId, date)
await userRepository.getUserSetting(userId, settingId)
```
## Datenfluss
### Timer starten
```
1. Frontend → POST /api/time-entries
{ project: "Website", description: "Feature XY" }
2. Controller → Service.createEntry()
3. Service → WorklogRepository.create()
{
user_id: 1,
state: '{"action":"Clock In","project":"Website","description":"Feature XY"}',
tstamp: '2025-10-15 10:00:00',
relatedTo_id: null
}
4. MySQL → INSERT INTO worklog
→ Gibt neue ID zurück (z.B. 123)
5. Service → Konvertiert zu TimeEntry-Format
{
id: 123,
startTime: '2025-10-15T10:00:00',
endTime: null,
project: 'Website',
description: 'Feature XY',
duration: null,
isRunning: true
}
6. Controller → Frontend
Status 201, JSON Response
```
### Timer stoppen
```
1. Frontend → PUT /api/time-entries/123
{ endTime: '2025-10-15T12:00:00' }
2. Service → WorklogRepository.create()
{
user_id: 1,
state: '{"action":"Clock Out","project":"Website","description":"Feature XY"}',
tstamp: '2025-10-15 12:00:00',
relatedTo_id: 123 // Verweist auf Clock In
}
3. Service → Berechnet Dauer und gibt Pair zurück
```
## Worklog-Paare
Clock In und Clock Out werden als separate Einträge gespeichert, aber logisch als Paar behandelt:
```sql
SELECT
w1.id as start_id,
w1.tstamp as start_time,
w2.id as end_id,
w2.tstamp as end_time,
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 = ? AND w1.relatedTo_id IS NULL
```
**Vorteile:**
- Historische Genauigkeit
- Korrekturen möglich
- Audit-Trail
## State-Format
Der `state` Feld speichert JSON-Daten:
```json
{
"action": "Clock In|Clock Out",
"project": "Projektname",
"description": "Beschreibung der Tätigkeit"
}
```
**Parsing im Service:**
```javascript
let stateData;
try {
stateData = JSON.parse(worklog.state);
} catch {
stateData = { project: 'Allgemein', description: '' };
}
```
## Statistiken
### Basis-Statistiken
```sql
SELECT
COUNT(DISTINCT w1.id) as total_entries,
COUNT(DISTINCT w2.id) as completed_entries,
SUM(TIMESTAMPDIFF(SECOND, w1.tstamp, w2.tstamp)) as total_seconds
FROM worklog w1
LEFT JOIN worklog w2 ON w2.relatedTo_id = w1.id
WHERE w1.user_id = ? AND w1.relatedTo_id IS NULL
```
### Projekt-Statistiken
Werden im Service berechnet durch Gruppierung der Paare nach Projekt.
## Transaktionen
Für zukünftige Erweiterungen können Transaktionen verwendet werden:
```javascript
const connection = await database.getPool().getConnection();
try {
await connection.beginTransaction();
// Multiple Operationen
await connection.execute(sql1, params1);
await connection.execute(sql2, params2);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
```
## Migration von In-Memory
Der Code unterstützt beide Modi:
- **Entwicklung:** In-Memory (schnell, keine DB erforderlich)
- **Produktion:** MySQL (persistent, skalierbar)
Umschalten durch Änderung des Service-Imports.
## Performance-Optimierungen
### Indizes
Wichtige Indizes sind bereits vorhanden:
```sql
KEY `worklog_tstamp_IDX` (`tstamp`)
KEY `worklog_user_id_IDX` (`user_id`, `tstamp`)
```
### Connection Pooling
- Wiederverwendung von Verbindungen
- Limit: 10 gleichzeitige Verbindungen
- Automatisches Reconnect
### Query-Optimierung
- Prepared Statements (SQL Injection Schutz)
- WHERE-Klauseln mit Indizes
- LEFT JOIN statt mehrere Queries
## Fehlerbehandlung
```javascript
try {
const result = await worklogRepository.create(data);
return result;
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
throw new Error('Eintrag existiert bereits');
}
if (error.code === 'ER_NO_REFERENCED_ROW_2') {
throw new Error('Benutzer nicht gefunden');
}
throw error;
}
```
## Backup & Restore
### Backup erstellen
```bash
mysqldump -u root -p stechuhr2 > backup_$(date +%Y%m%d).sql
```
### Restore
```bash
mysql -u root -p stechuhr2 < backup_20251015.sql
```
## Sicherheit
**Implemented:**
- Prepared Statements (SQL Injection Schutz)
- Connection Pooling
- Error Handling
🔄 **TODO:**
- Input Validation
- Rate Limiting
- User Authentication
- Row-Level Security
## Monitoring
Logging von Queries (Development):
```javascript
if (process.env.NODE_ENV === 'development') {
console.log('SQL:', sql);
console.log('Params:', params);
}
```
## Zukünftige Erweiterungen
1. **Authentifizierung**
- Verwendung der `auth_info` Tabelle
- JWT-Token-basiert
2. **Multi-User Support**
- Benutzer-ID aus Session
- Row-Level Security
3. **Erweiterte Features**
- Urlaub (`vacation` Tabelle)
- Krankmeldungen (`sick` Tabelle)
- Feiertage (`holiday` Tabelle)
4. **Caching**
- Redis für häufige Abfragen
- Cache-Invalidierung
5. **Read Replicas**
- Master-Slave Setup
- Read-Write Splitting

316
backend/DB_SETUP.md Normal file
View File

@@ -0,0 +1,316 @@
# Datenbank-Setup für TimeClock v3
## Voraussetzungen
- MySQL 5.7 oder höher
- Zugriff auf MySQL-Server (localhost oder remote)
## Setup-Schritte
### 1. Datenbank-Verbindung konfigurieren
Erstellen Sie eine `.env` Datei im `backend/` Verzeichnis:
```bash
cd backend
cp .env.example .env
```
Bearbeiten Sie die `.env` Datei mit Ihren Datenbankzugangsdaten:
```env
PORT=3000
NODE_ENV=development
# MySQL Datenbank
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=ihr_mysql_passwort
DB_NAME=stechuhr2
```
### 2. Datenbank prüfen
Die Datenbank `stechuhr2` sollte bereits existieren. Prüfen Sie dies:
```bash
mysql -u root -p
```
```sql
SHOW DATABASES;
USE stechuhr2;
SHOW TABLES;
```
Sie sollten die Tabellen sehen:
- `worklog` (Haupttabelle für Zeiteinträge)
- `user` (Benutzerverwaltung)
- `auth_info` (Authentifizierung)
- ... und weitere
### 3. Testbenutzer prüfen/erstellen
Prüfen Sie, ob ein Benutzer mit ID 1 existiert:
```sql
SELECT * FROM user WHERE id = 1;
```
Falls kein Benutzer existiert, erstellen Sie einen:
```sql
INSERT INTO user (
id, version, last_change, role, daily_hours,
state_id, full_name, week_hours, week_workdays, preferred_title_type
) VALUES (
1, 0, NOW(), 0, 8,
NULL, 'Test Benutzer', 40, 5, 0
);
```
### 4. Abhängigkeiten installieren
```bash
cd backend
npm install
```
Dies installiert auch `mysql2` für die Datenbankverbindung.
### 5. Backend starten
```bash
npm run dev
```
**Erwartete Ausgabe:**
```
✅ MySQL-Datenbankverbindung hergestellt
🕐 TimeClock Server läuft auf Port 3010
📍 API verfügbar unter http://localhost:3010/api
```
**Bei Fehlern:**
- **"Access denied for user"**: Überprüfen Sie DB_USER und DB_PASSWORD in `.env`
- **"Unknown database"**: Erstellen Sie die Datenbank `stechuhr2`
- **"ECONNREFUSED"**: MySQL-Server läuft nicht oder falscher DB_HOST/DB_PORT
### 6. API testen
```bash
# Health Check
curl http://localhost:3010/api/health
# Zeiteinträge abrufen
curl http://localhost:3010/api/time-entries
# Neuen Timer starten
curl -X POST http://localhost:3010/api/time-entries \
-H "Content-Type: application/json" \
-d '{"project":"Test","description":"Test-Timer"}'
```
## Worklog-Struktur
Die Haupttabelle `worklog` speichert Zeiteinträge:
```
Clock In (Start):
┌────────────────────────────────────────────┐
│ id: 1 │
│ user_id: 1 │
│ state: {"action":"Clock In","project":... }│
│ tstamp: 2025-10-15 10:00:00 │
│ relatedTo_id: NULL │
└────────────────────────────────────────────┘
Clock Out (End):
┌────────────────────────────────────────────┐
│ id: 2 │
│ user_id: 1 │
│ state: {"action":"Clock Out","project":...}│
│ tstamp: 2025-10-15 12:00:00 │
│ relatedTo_id: 1 ← Verweist auf Clock In │
└────────────────────────────────────────────┘
```
## Bestehende Daten
Falls bereits Worklog-Einträge in der Datenbank existieren:
### Datenformat prüfen
```sql
SELECT id, user_id, state, tstamp, relatedTo_id
FROM worklog
ORDER BY tstamp DESC
LIMIT 10;
```
### Altes Format konvertieren
Falls `state` nicht im JSON-Format vorliegt, können Sie Einträge konvertieren:
```sql
-- Beispiel: state ist "in" oder "out"
UPDATE worklog
SET state = JSON_OBJECT('action', 'Clock In', 'project', 'Allgemein', 'description', '')
WHERE state = 'in' AND relatedTo_id IS NULL;
UPDATE worklog
SET state = JSON_OBJECT('action', 'Clock Out', 'project', 'Allgemein', 'description', '')
WHERE state = 'out' AND relatedTo_id IS NOT NULL;
```
⚠️ **Wichtig:** Erstellen Sie vorher ein Backup!
```bash
mysqldump -u root -p stechuhr2 > backup_before_conversion.sql
```
## Mehrere Benutzer
Der aktuelle Code verwendet standardmäßig Benutzer-ID 1. Für Multi-User-Support:
### Option 1: Benutzer-ID in Service setzen
```javascript
// In zukünftigen Endpunkten
const userId = req.user.id; // Aus Auth-Middleware
const entries = await timeEntryService.getAllEntries(userId);
```
### Option 2: Authentifizierung hinzufügen
Die Datenbank hat bereits `auth_info` und `auth_token` Tabellen für JWT-basierte Authentifizierung.
## Troubleshooting
### Verbindungsprobleme
```bash
# MySQL-Status prüfen
sudo systemctl status mysql
# MySQL starten
sudo systemctl start mysql
# Port prüfen
netstat -an | grep 3306
```
### Berechtigungen
```sql
-- Benutzer-Berechtigungen prüfen
SHOW GRANTS FOR 'root'@'localhost';
-- Falls nötig, Berechtigungen gewähren
GRANT ALL PRIVILEGES ON stechuhr2.* TO 'root'@'localhost';
FLUSH PRIVILEGES;
```
### Connection Pool Limits
Bei vielen gleichzeitigen Anfragen:
```javascript
// In src/config/database.js
connectionLimit: 10 // Erhöhen auf 20 oder mehr
```
### Debugging
Aktivieren Sie Query-Logging:
```javascript
// In src/repositories/WorklogRepository.js
console.log('SQL:', sql);
console.log('Params:', params);
```
## Performance-Tipps
### Indizes prüfen
```sql
SHOW INDEX FROM worklog;
```
Wichtige Indizes:
- `worklog_user_id_IDX` (user_id, tstamp)
- `worklog_tstamp_IDX` (tstamp)
### Query-Performance analysieren
```sql
EXPLAIN SELECT * FROM worklog WHERE user_id = 1 ORDER BY tstamp DESC;
```
### Alte Einträge archivieren
```sql
-- Einträge älter als 2 Jahre in Archiv-Tabelle verschieben
CREATE TABLE worklog_archive LIKE worklog;
INSERT INTO worklog_archive
SELECT * FROM worklog
WHERE tstamp < DATE_SUB(NOW(), INTERVAL 2 YEAR);
DELETE FROM worklog
WHERE tstamp < DATE_SUB(NOW(), INTERVAL 2 YEAR);
```
## Backup-Strategie
### Tägliches Backup (Cron)
```bash
# /etc/cron.daily/mysql-backup.sh
#!/bin/bash
mysqldump -u root -p'password' stechuhr2 > /backup/stechuhr2_$(date +\%Y\%m\%d).sql
find /backup -name "stechuhr2_*.sql" -mtime +30 -delete
```
### Point-in-Time Recovery
Aktivieren Sie Binary Logging in MySQL:
```ini
# /etc/mysql/my.cnf
[mysqld]
log-bin=mysql-bin
expire_logs_days=7
```
## Migration auf andere Datenbank
Die Repository-Struktur ermöglicht einfache Migration:
### PostgreSQL
```javascript
// Ersetzen Sie mysql2 durch pg
const { Pool } = require('pg');
```
### MongoDB
```javascript
// Erstellen Sie neue Repositories mit MongoDB
const mongodb = require('mongodb');
```
Der Service und Controller bleiben unverändert! 🎉
## Nächste Schritte
1. ✅ Datenbank konfiguriert und getestet
2. 🔄 Frontend anpassen (optional)
3. 🔄 Authentifizierung implementieren
4. 🔄 Multi-User Support aktivieren
5. 🔄 Backup-Automatisierung einrichten

164
backend/ID_HASHING.md Normal file
View File

@@ -0,0 +1,164 @@
# ID-Hashing System
## Übersicht
Das TimeClock Backend verwendet ein automatisches ID-Hashing-System, das alle numerischen IDs in API-Responses verschlüsselt und eingehende Hash-IDs automatisch entschlüsselt.
## Warum ID-Hashing?
- **Sicherheit**: Verhindert, dass Angreifer die Anzahl der Datensätze erraten können
- **Obfuscation**: Versteckt die interne Datenbankstruktur
- **Schutz vor ID-Enumeration**: Verhindert systematisches Durchlaufen von Ressourcen
## Funktionsweise
### Automatisches Hashing (Backend → Frontend)
Alle numerischen ID-Felder in API-Responses werden automatisch in Hashes konvertiert:
```javascript
// Datenbank-Daten:
{
id: 123,
user_id: 456,
full_name: "Max Mustermann"
}
// API-Response:
{
id: "xY9kL2mP3qR5.aB7cD8eF9gH0",
user_id: "tU6vW7xY8zZ9.iJ1kL2mN3oP4",
full_name: "Max Mustermann"
}
```
### Automatisches Enthashen (Frontend → Backend)
Alle Hash-IDs in eingehenden Requests werden automatisch zurück in numerische IDs konvertiert:
```javascript
// Frontend sendet:
{
user_id: "xY9kL2mP3qR5.aB7cD8eF9gH0"
}
// Backend erhält:
{
user_id: 123
}
```
## Implementierung
### Backend
Das System besteht aus drei Komponenten:
1. **`utils/hashId.js`**: Utility-Klasse für Encoding/Decoding
2. **`middleware/hashResponse.js`**: Middleware für ausgehende Responses
3. **`middleware/unhashRequest.js`**: Middleware für eingehende Requests
### Konfiguration
In der `.env`-Datei:
```env
HASH_ID_SECRET=your-hash-id-secret-change-in-production
```
⚠️ **Wichtig**: Das Secret sollte in Produktion geändert werden und geheim bleiben!
### Erkannte ID-Felder
Folgende Feldnamen werden automatisch als IDs erkannt und gehashed:
- `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`
## Frontend-Integration
Das Frontend muss keine Änderungen vornehmen - es arbeitet einfach mit den empfangenen Hash-IDs:
```javascript
// GET /api/auth/me
const response = await fetch('/api/auth/me')
const data = await response.json()
console.log(data.user.id) // "xY9kL2mP3qR5.aB7cD8eF9gH0"
// POST /api/some-endpoint
await fetch('/api/some-endpoint', {
method: 'POST',
body: JSON.stringify({
user_id: data.user.id // Hash wird automatisch entschlüsselt
})
})
```
## Manuelle Verwendung
Falls manuelles Encoding/Decoding nötig ist:
```javascript
const hashId = require('./utils/hashId');
// Einzelne ID hashen
const hash = hashId.encode(123); // "xY9kL2mP3qR5.aB7cD8eF9gH0"
// Hash dekodieren
const id = hashId.decode(hash); // 123
// Objekt hashen
const obj = { id: 123, name: "Test" };
const hashed = hashId.encodeObject(obj); // { id: "xY9...", name: "Test" }
// Array hashen
const array = [{ id: 1 }, { id: 2 }];
const hashedArray = hashId.encodeArray(array);
```
## Sicherheitshinweise
1. **Secret ändern**: Ändern Sie `HASH_ID_SECRET` in der Produktion
2. **Secret sicher aufbewahren**: Das Secret sollte niemals im Code oder in der Versionskontrolle erscheinen
3. **Keine zusätzliche Sicherheit**: ID-Hashing ersetzt keine echte Autorisierung - prüfen Sie immer die Zugriffsrechte!
## Hash-Format
Das Hash-Format: `{encrypted_id}.{hash_prefix}`
- **encrypted_id**: AES-256-CBC verschlüsselte ID
- **hash_prefix**: HMAC-SHA256 Hash (erste 12 Zeichen) zur Verifizierung
- **Encoding**: base64url (URL-sicher)
Beispiel: `xY9kL2mP3qR5.aB7cD8eF9gH0`
## Fehlerbehandlung
Ungültige Hash-IDs werden zu `null` dekodiert. Services/Controller sollten dies behandeln:
```javascript
const userId = req.params.id; // Könnte null sein wenn Hash ungültig
if (!userId) {
return res.status(400).json({ error: 'Ungültige ID' });
}
```
## Performance
- **Encoding**: ~0.1ms pro ID
- **Decoding**: ~0.2ms pro ID
- **Overhead**: Minimal, da deterministisch und ohne Datenbank-Zugriff

248
backend/MYSQL_SETUP.md Normal file
View File

@@ -0,0 +1,248 @@
# MySQL Datenbank Setup für TimeClock
## 1. MySQL Installation prüfen
```bash
# MySQL Version prüfen
mysql --version
# Falls nicht installiert (openSUSE):
sudo zypper install mysql mysql-server
# MySQL-Server starten
sudo systemctl start mysql
sudo systemctl enable mysql
```
## 2. MySQL Root-Passwort setzen (falls nötig)
```bash
# MySQL Secure Installation ausführen
sudo mysql_secure_installation
# Folgende Fragen beantworten:
# - Set root password? [Y/n] Y
# - Remove anonymous users? [Y/n] Y
# - Disallow root login remotely? [Y/n] Y
# - Remove test database? [Y/n] Y
# - Reload privilege tables? [Y/n] Y
```
## 3. Datenbank und Benutzer erstellen
### Option A: Automatisches Setup-Script
```bash
cd /home/torsten/Programs/TimeClock/backend
chmod +x setup-mysql.sh
./setup-mysql.sh
```
Das Script fragt nach:
- MySQL Root-Passwort
- Neuem Datenbank-Benutzer (Standard: timeclock_user)
- Neuem Benutzer-Passwort
### Option B: Manuelles Setup
```bash
# MySQL als root einloggen
mysql -u root -p
```
Dann in der MySQL-Konsole:
```sql
-- 1. Datenbank erstellen
CREATE DATABASE IF NOT EXISTS stechuhr2
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
-- 2. Benutzer erstellen (Passwort anpassen!)
CREATE USER IF NOT EXISTS 'timeclock_user'@'localhost'
IDENTIFIED BY 'IhrSicheresPasswort123!';
-- 3. Berechtigungen vergeben
GRANT ALL PRIVILEGES ON stechuhr2.*
TO 'timeclock_user'@'localhost';
-- 4. Privilegien neu laden
FLUSH PRIVILEGES;
-- 5. Prüfen
SHOW DATABASES;
SELECT User, Host FROM mysql.user WHERE User='timeclock_user';
-- 6. Verlassen
EXIT;
```
## 4. Tabellen erstellen
Die Tabellen existieren bereits in Ihrer DB. Falls Sie sie neu erstellen möchten:
```bash
# SQL-Dump importieren (falls vorhanden)
mysql -u timeclock_user -p stechuhr2 < database-schema.sql
```
Oder mit dem automatischen Migrations-Script:
```bash
cd /home/torsten/Programs/TimeClock/backend
node scripts/create-tables.js
```
## 5. .env Datei konfigurieren
```bash
cd /home/torsten/Programs/TimeClock/backend
nano .env
```
Setzen Sie folgende Werte:
```env
DB_HOST=localhost
DB_PORT=3306
DB_USER=timeclock_user
DB_PASSWORD=IhrSicheresPasswort123!
DB_NAME=stechuhr2
```
## 6. Verbindung testen
```bash
# Mit neuen Credentials verbinden
mysql -u timeclock_user -p stechuhr2
# In MySQL-Konsole:
SHOW TABLES;
EXIT;
```
## 7. Server starten
```bash
cd /home/torsten/Programs/TimeClock
npm run dev
```
**Erwartete Ausgabe:**
```
✅ Sequelize: MySQL-Datenbankverbindung hergestellt
✅ Sequelize: Models initialisiert
🕐 TimeClock Server läuft auf Port 3010
```
## Troubleshooting
### "Access denied for user"
```bash
# Berechtigungen prüfen
mysql -u root -p
```
```sql
SHOW GRANTS FOR 'timeclock_user'@'localhost';
-- Falls leer: Berechtigungen neu vergeben (siehe Schritt 3)
```
### "Unknown database stechuhr2"
```bash
mysql -u root -p
```
```sql
CREATE DATABASE stechuhr2;
```
### "Can't connect to MySQL server"
```bash
# MySQL-Status prüfen
sudo systemctl status mysql
# Starten falls gestoppt
sudo systemctl start mysql
```
### Port 3306 bereits belegt
```bash
# Prüfen was auf Port 3306 läuft
sudo ss -tlnp | grep 3306
# MySQL-Port ändern (in /etc/my.cnf)
[mysqld]
port=3307
```
## Schnell-Setup für Entwicklung
Falls Sie nur schnell testen möchten:
```bash
# MySQL als root ohne Passwort
mysql -u root
CREATE DATABASE stechuhr2;
EXIT;
```
Dann in `.env`:
```env
DB_USER=root
DB_PASSWORD=
DB_NAME=stechuhr2
```
⚠️ **Nicht für Produktion verwenden!**
## Existierende stechuhr2 DB verwenden
Falls Sie bereits eine `stechuhr2` Datenbank haben:
```bash
# Prüfen Sie die Credentials
mysql -u IHR_USER -p stechuhr2
# Tabellen anzeigen
SHOW TABLES;
```
Dann in `.env`:
```env
DB_USER=IHR_EXISTIERENDER_USER
DB_PASSWORD=IHR_EXISTIERENDES_PASSWORT
DB_NAME=stechuhr2
```
## Benutzer erstellen (Template)
```sql
-- Benutzer mit vollem Zugriff
CREATE USER 'timeclock_user'@'localhost' IDENTIFIED BY 'Passwort123';
GRANT ALL PRIVILEGES ON stechuhr2.* TO 'timeclock_user'@'localhost';
FLUSH PRIVILEGES;
-- Nur Lese-Zugriff (optional)
CREATE USER 'timeclock_readonly'@'localhost' IDENTIFIED BY 'Passwort123';
GRANT SELECT ON stechuhr2.* TO 'timeclock_readonly'@'localhost';
FLUSH PRIVILEGES;
```
## Backup erstellen
```bash
# Vor Änderungen immer Backup!
mysqldump -u timeclock_user -p stechuhr2 > backup_$(date +%Y%m%d_%H%M%S).sql
# Restore
mysql -u timeclock_user -p stechuhr2 < backup_20251015_143000.sql
```

145
backend/README.md Normal file
View File

@@ -0,0 +1,145 @@
# TimeClock Backend API v3.0
Node.js/Express Backend für die TimeClock Zeiterfassungsanwendung.
## Installation
```bash
npm install
```
## Konfiguration
Erstellen Sie eine `.env` Datei im Backend-Verzeichnis:
```env
PORT=3000
NODE_ENV=development
```
## Starten
### Entwicklung (mit Auto-Reload)
```bash
npm run dev
```
### Produktion
```bash
npm start
```
## API-Dokumentation
### Base URL
```
http://localhost:3010/api
```
### Endpunkte
#### Health Check
```
GET /api/health
```
**Response:**
```json
{
"status": "ok",
"message": "TimeClock API v3.0.0",
"timestamp": "2025-10-15T10:00:00.000Z"
}
```
#### Alle Zeiteinträge abrufen
```
GET /api/time-entries
```
#### Einzelnen Zeiteintrag abrufen
```
GET /api/time-entries/:id
```
#### Neuen Zeiteintrag erstellen
```
POST /api/time-entries
Content-Type: application/json
{
"project": "Mein Projekt",
"description": "Beschreibung"
}
```
#### Zeiteintrag aktualisieren
```
PUT /api/time-entries/:id
Content-Type: application/json
{
"endTime": "2025-10-15T10:30:00.000Z",
"description": "Aktualisierte Beschreibung"
}
```
#### Zeiteintrag löschen
```
DELETE /api/time-entries/:id
```
#### Statistiken abrufen
```
GET /api/time-entries/stats/summary
```
**Response:**
```json
{
"totalEntries": 10,
"completedEntries": 8,
"runningEntries": 2,
"totalHours": "42.50",
"totalSeconds": 153000,
"projectStats": {
"Projekt A": {
"duration": 86400,
"count": 5
}
}
}
```
## Datenmodell
### TimeEntry
```javascript
{
id: Number,
startTime: String (ISO 8601),
endTime: String (ISO 8601) | null,
description: String,
project: String,
duration: Number (Sekunden) | null,
isRunning: Boolean
}
```
## Dependencies
- **express** - Web-Framework
- **cors** - CORS-Middleware
- **helmet** - Sicherheits-Middleware
- **morgan** - HTTP Request Logger
- **dotenv** - Environment Variables
## Entwicklung
Der aktuelle Stand verwendet In-Memory-Speicher. Für eine Produktionsumgebung sollte eine Datenbank integriert werden:
- MongoDB mit mongoose
- PostgreSQL mit pg/sequelize
- MySQL mit mysql2/sequelize

565
backend/SEQUELIZE.md Normal file
View File

@@ -0,0 +1,565 @@
# Sequelize ORM Integration
## Übersicht
TimeClock v3 verwendet **Sequelize** als ORM (Object-Relational Mapping) für die MySQL-Datenbank. Dies ermöglicht eine typsichere, objektorientierte Arbeitsweise mit der Datenbank.
## Vorteile von Sequelize
**Type-Safety** - Models definieren Datentypen
**Validierung** - Automatische Datenvalidierung
**Assoziationen** - Einfache Beziehungen zwischen Models
**Migrations** - Versionskontrolle für Datenbankschema
**Abstraktion** - Datenbank-unabhängiger Code
**Query Builder** - Typsichere Queries
## Konfiguration
### .env Datei
Alle Datenbankeinstellungen werden über `.env` konfiguriert:
```env
# MySQL Datenbank-Verbindung
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=ihr_passwort
DB_NAME=stechuhr2
# Datenbank-Optionen
DB_LOGGING=false # SQL-Queries in Console loggen (true/false)
DB_TIMEZONE=+01:00 # Timezone für Timestamps
DB_POOL_MAX=10 # Max. Connections im Pool
DB_POOL_MIN=0 # Min. Connections im Pool
```
### Initialisierung
```javascript
// src/config/database.js
const sequelize = new Sequelize(
process.env.DB_NAME,
process.env.DB_USER,
process.env.DB_PASSWORD,
{
host: process.env.DB_HOST,
port: process.env.DB_PORT,
dialect: 'mysql',
logging: process.env.DB_LOGGING === 'true',
pool: {
max: parseInt(process.env.DB_POOL_MAX),
min: parseInt(process.env.DB_POOL_MIN)
}
}
);
```
## Models
### Model-Struktur
Jedes Model repräsentiert eine Datenbanktabelle:
```javascript
// src/models/User.js
const { Model, DataTypes } = require('sequelize');
class User extends Model {
static initialize(sequelize) {
User.init(
{
id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true
},
full_name: {
type: DataTypes.TEXT,
allowNull: false
}
// ... weitere Felder
},
{
sequelize,
tableName: 'user',
timestamps: false
}
);
return User;
}
}
```
### Verfügbare Models
| Model | Tabelle | Beschreibung |
|-------|---------|--------------|
| **User** | `user` | Benutzer |
| **Worklog** | `worklog` | Zeiteinträge (Clock In/Out) |
| **AuthInfo** | `auth_info` | Authentifizierung |
| **State** | `state` | Bundesländer/Regionen |
| **WeeklyWorktime** | `weekly_worktime` | Wochenarbeitszeit |
| **Holiday** | `holiday` | Feiertage |
| **Vacation** | `vacation` | Urlaub |
| **Sick** | `sick` | Krankmeldungen |
| **SickType** | `sick_type` | Krankmeldungstypen |
### Model-Features
#### 1. Getter/Setter
```javascript
// Worklog Model
class Worklog extends Model {
// State als JSON speichern/laden
state: {
type: DataTypes.TEXT,
get() {
const rawValue = this.getDataValue('state');
try {
return JSON.parse(rawValue);
} catch {
return { action: rawValue };
}
},
set(value) {
if (typeof value === 'object') {
this.setDataValue('state', JSON.stringify(value));
}
}
}
}
// Verwendung:
worklog.state = { action: 'Clock In', project: 'Website' };
console.log(worklog.state.project); // 'Website'
```
#### 2. Instance Methods
```javascript
class Worklog extends Model {
isClockIn() {
return this.relatedTo_id === null;
}
getProject() {
return this.state.project || 'Allgemein';
}
}
// Verwendung:
if (worklog.isClockIn()) {
console.log(`Projekt: ${worklog.getProject()}`);
}
```
#### 3. Class Methods
```javascript
class User extends Model {
getFullName() {
return this.full_name;
}
}
```
## Assoziationen
Models sind miteinander verknüpft:
```javascript
// User → Worklog (1:n)
User.hasMany(Worklog, { foreignKey: 'user_id', as: 'worklogs' });
Worklog.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
// Worklog → Worklog (Clock In/Out)
Worklog.belongsTo(Worklog, { foreignKey: 'relatedTo_id', as: 'relatedTo' });
Worklog.hasOne(Worklog, { foreignKey: 'relatedTo_id', as: 'clockOut' });
// User → AuthInfo (1:1)
User.hasOne(AuthInfo, { foreignKey: 'user_id', as: 'authInfo' });
AuthInfo.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
```
### Include in Queries
```javascript
// User mit Worklogs laden
const user = await User.findByPk(1, {
include: [{
model: Worklog,
as: 'worklogs',
limit: 10
}]
});
console.log(user.worklogs); // Array von Worklog-Instanzen
```
## Repository-Pattern
Repositories verwenden Sequelize Models:
```javascript
class WorklogRepository {
async findById(id) {
const { Worklog } = database.getModels();
return await Worklog.findByPk(id);
}
async create(data) {
const { Worklog } = database.getModels();
return await Worklog.create(data);
}
async update(id, updates) {
const { Worklog } = database.getModels();
const worklog = await Worklog.findByPk(id);
await worklog.update(updates);
return worklog;
}
}
```
## Query-Beispiele
### Einfache Queries
```javascript
// Alle Benutzer
const users = await User.findAll();
// Benutzer nach ID
const user = await User.findByPk(1);
// Erstellen
const newUser = await User.create({
full_name: 'Max Mustermann',
daily_hours: 8
});
// Aktualisieren
await user.update({ full_name: 'Max M.' });
// Löschen
await user.destroy();
```
### WHERE-Bedingungen
```javascript
const { Op } = require('sequelize');
// Einfache WHERE
const worklogs = await Worklog.findAll({
where: { user_id: 1 }
});
// Operatoren
const recent = await Worklog.findAll({
where: {
tstamp: {
[Op.gte]: new Date('2025-01-01')
}
}
});
// Mehrere Bedingungen
const active = await Worklog.findAll({
where: {
user_id: 1,
relatedTo_id: null
}
});
```
### Order & Limit
```javascript
const latest = await Worklog.findAll({
where: { user_id: 1 },
order: [['tstamp', 'DESC']],
limit: 10,
offset: 0
});
```
### Aggregationen
```javascript
// COUNT
const count = await Worklog.count({
where: { user_id: 1 }
});
// SUM (mit raw query)
const [result] = await sequelize.query(`
SELECT SUM(TIMESTAMPDIFF(SECOND, w1.tstamp, w2.tstamp)) as total
FROM worklog w1
JOIN worklog w2 ON w2.relatedTo_id = w1.id
WHERE w1.user_id = ?
`, {
replacements: [1],
type: QueryTypes.SELECT
});
```
## Raw Queries
Für komplexe Queries können Raw SQL verwendet werden:
```javascript
const sequelize = database.getSequelize();
const [results] = await sequelize.query(`
SELECT
w1.id as start_id,
w2.id as end_id,
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
`, {
replacements: { userId: 1 },
type: sequelize.QueryTypes.SELECT
});
```
## Transaktionen
```javascript
const sequelize = database.getSequelize();
const t = await sequelize.transaction();
try {
// Clock In erstellen
const clockIn = await Worklog.create({
user_id: 1,
state: { action: 'Clock In' },
tstamp: new Date()
}, { transaction: t });
// Weitere Operationen...
await t.commit();
} catch (error) {
await t.rollback();
throw error;
}
```
## Validierung
```javascript
class User extends Model {
static initialize(sequelize) {
User.init({
email: {
type: DataTypes.STRING,
validate: {
isEmail: true,
notEmpty: true
}
},
full_name: {
type: DataTypes.TEXT,
validate: {
len: [2, 255]
}
},
daily_hours: {
type: DataTypes.INTEGER,
validate: {
min: 1,
max: 24
}
}
}, {
sequelize,
tableName: 'user'
});
}
}
```
## Hooks (Lifecycle Events)
```javascript
class User extends Model {
static initialize(sequelize) {
User.init({ /* ... */ }, {
sequelize,
tableName: 'user',
hooks: {
beforeCreate: (user, options) => {
user.last_change = new Date();
},
beforeUpdate: (user, options) => {
user.version += 1;
user.last_change = new Date();
}
}
});
}
}
```
## Migrations (Optional)
Für Datenbankschema-Änderungen:
```bash
# Sequelize CLI installieren
npm install --save-dev sequelize-cli
# Initialisieren
npx sequelize-cli init
# Migration erstellen
npx sequelize-cli migration:generate --name add-user-field
# Migrations ausführen
npx sequelize-cli db:migrate
# Migrations rückgängig machen
npx sequelize-cli db:migrate:undo
```
## Best Practices
### 1. Model-Zugriff über Database
```javascript
// ✅ Gut
const { Worklog } = database.getModels();
const worklogs = await Worklog.findAll();
// ❌ Nicht
const Worklog = require('../models/Worklog');
```
### 2. Eager Loading für Beziehungen
```javascript
// ✅ Gut - Eine Query
const user = await User.findByPk(1, {
include: [{ model: Worklog, as: 'worklogs' }]
});
// ❌ Nicht - N+1 Problem
const user = await User.findByPk(1);
const worklogs = await Worklog.findAll({ where: { user_id: user.id } });
```
### 3. Transactions für kritische Operationen
```javascript
// ✅ Gut
const t = await sequelize.transaction();
try {
await operation1({ transaction: t });
await operation2({ transaction: t });
await t.commit();
} catch (error) {
await t.rollback();
}
```
### 4. Raw Queries für Performance
```javascript
// Für komplexe Aggregationen sind Raw Queries oft schneller
const stats = await sequelize.query(complexSQL, {
replacements: { userId },
type: QueryTypes.SELECT
});
```
## Debugging
### SQL-Queries anzeigen
```env
# In .env
DB_LOGGING=true
```
Dann werden alle SQL-Queries in der Console geloggt:
```
Executing (default): SELECT * FROM `user` WHERE `id` = 1;
```
### Model-Daten anzeigen
```javascript
console.log(user.toJSON());
```
## Performance-Tipps
1. **Indizes nutzen** - Models definieren Indizes
2. **Eager Loading** - Include statt separate Queries
3. **Pagination** - Limit & Offset verwenden
4. **Raw Queries** - Für komplexe Aggregationen
5. **Connection Pooling** - Bereits konfiguriert
## Troubleshooting
### "Sequelize not initialized"
```javascript
// Sicherstellen, dass database.initialize() aufgerufen wurde
await database.initialize();
```
### "Model not found"
```javascript
// Models werden in database.js initialisiert
// Prüfen Sie, ob Model in initializeModels() geladen wird
```
### Timezone-Probleme
```env
# In .env die richtige Timezone setzen
DB_TIMEZONE=+01:00
```
## Migration von Raw SQL
### Vorher (Raw SQL):
```javascript
const [rows] = await db.execute(
'SELECT * FROM user WHERE id = ?',
[userId]
);
```
### Nachher (Sequelize):
```javascript
const user = await User.findByPk(userId);
```
Viel einfacher und typsicher! 🎉
## Zusammenfassung
Sequelize bietet:
- ✅ Typsichere Models
- ✅ Automatische Validierung
- ✅ Einfache Assoziationen
- ✅ Query Builder
- ✅ .env-basierte Konfiguration
- ✅ Migrations-Support
Perfekt für professionelle Node.js-Anwendungen!

View File

@@ -0,0 +1,28 @@
-- Füge fehlende Worklog-Einträge für Mittwoch, 15.10.2025 hinzu
-- Lokale Zeit (MESZ = UTC+2): 08:43:02, 12:12:04, 13:33:20, 17:53:06
-- UTC-Zeit (für DB): 06:43:02, 10:12:04, 11:33:20, 15:53:06
-- Variablen für die IDs (werden nach dem INSERT gesetzt)
SET @start_work_id = NULL;
SET @start_pause_id = NULL;
-- 1. start work (keine relatedTo_id)
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
VALUES (1, 'start work', '2025-10-15 06:43:02', NULL, 0);
SET @start_work_id = LAST_INSERT_ID();
-- 2. start pause (relatedTo_id = start work)
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
VALUES (1, 'start pause', '2025-10-15 10:12:04', @start_work_id, 0);
SET @start_pause_id = LAST_INSERT_ID();
-- 3. stop pause (relatedTo_id = start pause)
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
VALUES (1, 'stop pause', '2025-10-15 11:33:20', @start_pause_id, 0);
-- 4. stop work (relatedTo_id = start work)
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
VALUES (1, 'stop work', '2025-10-15 15:53:06', @start_work_id, 0);
SELECT 'Worklog-Einträge für 15.10.2025 erfolgreich eingefügt' AS Status;

284
backend/database-schema.sql Normal file
View File

@@ -0,0 +1,284 @@
-- TimeClock v3 - Datenbankschema
-- Basierend auf stechuhr2 Struktur
-- ============================================================================
-- Basis-Tabellen
-- ============================================================================
-- State Tabelle (Bundesländer/Regionen)
CREATE TABLE IF NOT EXISTS `state` (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` int NOT NULL DEFAULT 0,
`state_name` text NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- User Tabelle
CREATE TABLE IF NOT EXISTS `user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` int NOT NULL DEFAULT 0,
`last_change` datetime DEFAULT NULL,
`role` int NOT NULL DEFAULT 0,
`daily_hours` int NOT NULL DEFAULT 8,
`state_id` bigint DEFAULT NULL,
`full_name` text NOT NULL,
`week_hours` int NOT NULL DEFAULT 40,
`week_workdays` int NOT NULL DEFAULT 5,
`preferred_title_type` int NOT NULL DEFAULT 0,
`overtime_offset_minutes` int DEFAULT 0 COMMENT 'Überstunden-Startwert in Minuten (z.B. Übertrag aus altem System)',
PRIMARY KEY (`id`),
KEY `fk_user_state` (`state_id`),
CONSTRAINT `fk_user_state` FOREIGN KEY (`state_id`) REFERENCES `state` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================================
-- Authentifizierung
-- ============================================================================
-- Auth Info Tabelle
CREATE TABLE IF NOT EXISTS `auth_info` (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` int NOT NULL DEFAULT 0,
`user_id` bigint DEFAULT NULL,
`password_hash` varchar(100) NOT NULL,
`password_method` varchar(20) NOT NULL,
`password_salt` varchar(60) NOT NULL,
`status` int NOT NULL DEFAULT 1,
`failed_login_attempts` int NOT NULL DEFAULT 0,
`last_login_attempt` datetime DEFAULT NULL,
`email` varchar(256) NOT NULL,
`unverified_email` varchar(256) NOT NULL DEFAULT '',
`email_token` varchar(64) NOT NULL DEFAULT '',
`email_token_expires` datetime DEFAULT NULL,
`email_token_role` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `email_unique` (`email`),
KEY `fk_auth_info_user` (`user_id`),
CONSTRAINT `fk_auth_info_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Auth Token Tabelle
CREATE TABLE IF NOT EXISTS `auth_token` (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` int NOT NULL DEFAULT 0,
`auth_info_id` bigint DEFAULT NULL,
`value` varchar(64) NOT NULL,
`expires` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_auth_token_auth_info` (`auth_info_id`),
CONSTRAINT `fk_auth_token_auth_info` FOREIGN KEY (`auth_info_id`) REFERENCES `auth_info` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Auth Identity Tabelle
CREATE TABLE IF NOT EXISTS `auth_identity` (
`id` bigint NOT NULL,
`version` int NOT NULL DEFAULT 0,
`auth_info_id` bigint DEFAULT NULL,
`provider` varchar(64) NOT NULL,
`identity` varchar(512) NOT NULL,
KEY `fk_auth_identity_auth_info` (`auth_info_id`),
CONSTRAINT `fk_auth_identity_auth_info` FOREIGN KEY (`auth_info_id`) REFERENCES `auth_info` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================================
-- Zeiterfassung
-- ============================================================================
-- Worklog Tabelle (Haupttabelle für Zeiteinträge)
CREATE TABLE IF NOT EXISTS `worklog` (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` int NOT NULL DEFAULT 0,
`user_id` bigint NOT NULL,
`state` text NOT NULL,
`tstamp` datetime DEFAULT NULL,
`relatedTo_id` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_worklog_relatedTo` (`relatedTo_id`),
KEY `worklog_tstamp_IDX` (`tstamp`) USING BTREE,
KEY `worklog_user_id_IDX` (`user_id`,`tstamp`) USING BTREE,
CONSTRAINT `fk_worklog_relatedTo` FOREIGN KEY (`relatedTo_id`) REFERENCES `worklog` (`id`),
CONSTRAINT `fk_worklog_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Timefix Tabelle (Zeitkorrekturen)
CREATE TABLE IF NOT EXISTS `timefix` (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` int NOT NULL DEFAULT 0,
`user_id` bigint DEFAULT NULL,
`worklog_id` bigint DEFAULT NULL,
`fix_type` text NOT NULL,
`fix_date_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_timefix_user` (`user_id`),
KEY `fk_timefix_worklog` (`worklog_id`),
KEY `timefix_fix_date_time_IDX` (`fix_date_time`) USING BTREE,
CONSTRAINT `fk_timefix_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`),
CONSTRAINT `fk_timefix_worklog` FOREIGN KEY (`worklog_id`) REFERENCES `worklog` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================================
-- Arbeitszeit-Einstellungen
-- ============================================================================
-- Weekly Worktime Tabelle
CREATE TABLE IF NOT EXISTS `weekly_worktime` (
`id` int NOT NULL AUTO_INCREMENT,
`weekly_work_time` double NOT NULL DEFAULT 40,
`starting_from` date DEFAULT NULL,
`ends_at` date DEFAULT NULL,
`user_id` int DEFAULT NULL,
`version` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- User Settings Type Tabelle
CREATE TABLE IF NOT EXISTS `user_settings_type` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`version` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- User Setting Tabelle
CREATE TABLE IF NOT EXISTS `user_setting` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`setting_id` int NOT NULL,
`begin_date` date DEFAULT NULL,
`end_date` date DEFAULT NULL,
`value` varchar(100) DEFAULT NULL,
`version` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `user_setting_user_FK` (`user_id`),
KEY `user_setting_user_settings_type_FK` (`setting_id`),
CONSTRAINT `user_setting_user_FK` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`),
CONSTRAINT `user_setting_user_settings_type_FK` FOREIGN KEY (`setting_id`) REFERENCES `user_settings_type` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Timewish Tabelle
CREATE TABLE IF NOT EXISTS `timewish` (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` int NOT NULL DEFAULT 0,
`user_id` bigint DEFAULT NULL,
`day` int NOT NULL COMMENT '1=Mo, 2=Di, 3=Mi, 4=Do, 5=Fr, 6=Sa, 7=So',
`wishtype` int NOT NULL COMMENT '1=Ende nach Uhrzeit, 2=Ende nach Stunden',
`hours` float NOT NULL,
`end_time` time DEFAULT NULL,
`start_date` date NOT NULL COMMENT 'Ab welchem Datum gilt dieser Timewish',
`end_date` date DEFAULT NULL COMMENT 'Bis welchem Datum gilt dieser Timewish (NULL = bis heute)',
PRIMARY KEY (`id`),
KEY `fk_timewish_user` (`user_id`),
CONSTRAINT `fk_timewish_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================================
-- Urlaub & Krankmeldungen
-- ============================================================================
-- Sick Type Tabelle
CREATE TABLE IF NOT EXISTS `sick_type` (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` int NOT NULL DEFAULT 0,
`description` text NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Sick Tabelle (Krankmeldungen)
CREATE TABLE IF NOT EXISTS `sick` (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` int NOT NULL DEFAULT 0,
`user_id` bigint DEFAULT NULL,
`first_day` date DEFAULT NULL,
`last_day` date DEFAULT NULL,
`sick_type_id` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_sick_user` (`user_id`),
KEY `fk_sick_sick_type` (`sick_type_id`),
CONSTRAINT `fk_sick_sick_type` FOREIGN KEY (`sick_type_id`) REFERENCES `sick_type` (`id`),
CONSTRAINT `fk_sick_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Vacation Tabelle (Urlaub)
CREATE TABLE IF NOT EXISTS `vacation` (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` int NOT NULL DEFAULT 0,
`user_id` bigint NOT NULL,
`first_day` date DEFAULT NULL,
`last_day` date DEFAULT NULL,
`vacation_type` int NOT NULL,
PRIMARY KEY (`id`),
KEY `fk_vacation_user` (`user_id`),
CONSTRAINT `fk_vacation_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================================
-- Feiertage
-- ============================================================================
-- Holiday Tabelle
CREATE TABLE IF NOT EXISTS `holiday` (
`id` bigint NOT NULL AUTO_INCREMENT,
`version` int NOT NULL DEFAULT 0,
`date` date DEFAULT NULL,
`hours` int NOT NULL DEFAULT 8,
`description` text NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Holiday State Verknüpfung
CREATE TABLE IF NOT EXISTS `holiday_state` (
`state_id` bigint NOT NULL,
`holiday_id` bigint NOT NULL,
PRIMARY KEY (`state_id`,`holiday_id`),
KEY `holiday_state_state` (`state_id`),
KEY `holiday_state_holiday` (`holiday_id`),
CONSTRAINT `fk_holiday_state_key1` FOREIGN KEY (`state_id`) REFERENCES `state` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_holiday_state_key2` FOREIGN KEY (`holiday_id`) REFERENCES `holiday` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================================
-- ACL (Access Control List)
-- ============================================================================
-- ACL User Tabelle
CREATE TABLE IF NOT EXISTS `acl_user` (
`id` bigint NOT NULL,
`version` int NOT NULL DEFAULT 0,
`user_id` bigint DEFAULT NULL,
`to_email` text NOT NULL,
KEY `fk_acl_user_user` (`user_id`),
CONSTRAINT `fk_acl_user_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ACL Type Tabelle
CREATE TABLE IF NOT EXISTS `acl_type` (
`id` bigint NOT NULL,
`version` int NOT NULL DEFAULT 0,
`description` text NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ACL Tabelle
CREATE TABLE IF NOT EXISTS `acl` (
`id` bigint NOT NULL,
`version` int NOT NULL DEFAULT 0,
`acl_user_id` bigint NOT NULL,
`acl_type` int NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================================
-- Hilfstabellen
-- ============================================================================
-- Dates Tabelle
CREATE TABLE IF NOT EXISTS `dates` (
`id` int NOT NULL AUTO_INCREMENT,
`date` date NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================================
-- Erfolgsmeldung
-- ============================================================================
SELECT '✅ Alle Tabellen erfolgreich erstellt!' AS Status;

View File

@@ -0,0 +1,69 @@
/**
* Korrektes Timezone-Fix für Worklog-Einträge
*
* Situation:
* - DB hat DATETIME-Spalten (ohne Timezone-Info)
* - Alte App hat lokale Zeit gespeichert: 08:07 → in DB: "2025-10-14 08:07:18"
* - Sequelize liest mit timezone='+00:00' und interpretiert als UTC
* - Sequelize gibt zurück: "2025-10-14T08:07:18.000Z"
* - JavaScript interpretiert das als 08:07 UTC
* - JavaScript zeigt: 10:07 lokal (UTC+2 MESZ)
*
* Was wir wollen:
* - DB hat: "2025-10-14 08:07:18" (lokale Zeit)
* - Anzeige: "08:07" (lokale Zeit)
*
* Lösung: NICHTS in der DB ändern!
* Stattdessen: UTC-Komponenten direkt als lokale Zeit interpretieren
*/
const mysql = require('mysql2/promise');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '.env') });
async function checkTimezones() {
let connection;
try {
connection = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'stechuhr2'
});
console.log('✅ Datenbankverbindung hergestellt\n');
// Hole Beispiel-Einträge vom 14. und 15. Oktober
const [rows] = await connection.execute(
`SELECT id, tstamp, state
FROM worklog
WHERE id IN (4153, 4154, 4155, 4156, 4157, 4158, 4159, 4160)
ORDER BY id ASC`
);
console.log('📊 Aktuelle DB-Einträge:\n');
rows.forEach(row => {
const state = JSON.parse(row.tstamp);
console.log(`ID ${row.id}: ${row.tstamp} (Rohdaten aus DB)`);
});
console.log('\n💡 Diese Zeiten sollten die LOKALEN Zeiten sein (MEZ/MESZ)');
console.log('💡 JavaScript sollte sie direkt so anzeigen, OHNE Timezone-Konvertierung\n');
} catch (error) {
console.error('❌ Fehler:', error);
process.exit(1);
} finally {
if (connection) {
await connection.end();
}
}
}
checkTimezones().then(() => {
process.exit(0);
});

156
backend/fix-timezone.js Normal file
View File

@@ -0,0 +1,156 @@
/**
* Timezone-Fix für Worklog-Einträge
*
* Problem: Die alte Anwendung hat lokale Zeiten direkt in die DB geschrieben
* Die DB-Spalte ist DATETIME (ohne Timezone-Info), aber Sequelize interpretiert sie als UTC
*
* Beispiel:
* - Tatsächliche Aktion: 08:07 Uhr lokale Zeit (MEZ/MESZ)
* - In DB gespeichert: 2025-10-14 08:07:18 (als DATETIME ohne TZ)
* - Sequelize liest: 2025-10-14T08:07:18.000Z (interpretiert als UTC)
* - JavaScript zeigt: 10:07 lokal (UTC + 2h MESZ) ❌
*
* Lösung: Konvertiere DATETIME zu echtem UTC basierend auf Sommer-/Winterzeit
*/
const mysql = require('mysql2/promise');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '.env') });
/**
* Prüft, ob ein Datum in der Sommerzeit liegt
* Sommerzeit in Europa: letzter Sonntag im März (02:00) bis letzter Sonntag im Oktober (03:00)
*/
function isSummertime(date) {
const year = date.getFullYear();
// Finde letzten Sonntag im März
const marchEnd = new Date(year, 2, 31);
const marchSunday = 31 - marchEnd.getDay();
const summerStart = new Date(year, 2, marchSunday, 2, 0, 0);
// Finde letzten Sonntag im Oktober
const octoberEnd = new Date(year, 9, 31);
const octoberSunday = 31 - octoberEnd.getDay();
const summerEnd = new Date(year, 9, octoberSunday, 3, 0, 0);
return date >= summerStart && date < summerEnd;
}
async function fixTimezones() {
let connection;
try {
connection = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'stechuhr2',
timezone: '+00:00' // UTC
});
console.log('✅ Datenbankverbindung hergestellt\n');
// Hole alle Worklog-Einträge
const [rows] = await connection.execute(
'SELECT id, tstamp FROM worklog ORDER BY id ASC'
);
console.log(`📊 Gefunden: ${rows.length} Worklog-Einträge\n`);
const updates = [];
for (const row of rows) {
// row.tstamp ist ein JavaScript Date-Objekt
// MySQL hat die lokale Zeit gespeichert (z.B. 08:07:18)
// Sequelize interpretiert das als UTC
// Wir erstellen ein neues Date-Objekt aus den Komponenten
const storedDate = new Date(row.tstamp);
// Extrahiere die Komponenten (das sind die "falschen" UTC-Werte)
const year = storedDate.getUTCFullYear();
const month = storedDate.getUTCMonth();
const day = storedDate.getUTCDate();
const hours = storedDate.getUTCHours();
const minutes = storedDate.getUTCMinutes();
const seconds = storedDate.getUTCSeconds();
// Diese Komponenten sind eigentlich die lokale Zeit
// Erstelle ein Datum mit diesen Werten als lokale Zeit
const localDate = new Date(year, month, day, hours, minutes, seconds);
// Prüfe ob Sommer- oder Winterzeit
const offset = isSummertime(localDate) ? 2 : 1;
// Konvertiere zu UTC: Lokale Zeit - Offset
const utcDate = new Date(localDate.getTime() - (offset * 60 * 60 * 1000));
updates.push({
id: row.id,
old: `${year}-${(month+1).toString().padStart(2,'0')}-${day.toString().padStart(2,'0')} ${hours.toString().padStart(2,'0')}:${minutes.toString().padStart(2,'0')}:${seconds.toString().padStart(2,'0')}`,
new: utcDate.toISOString().replace('T', ' ').replace('.000Z', ''),
offset: offset
});
}
// Zeige Statistiken
const summerCount = updates.filter(u => u.offset === 2).length;
const winterCount = updates.filter(u => u.offset === 1).length;
console.log(`📋 Analyse-Ergebnis:`);
console.log(` - Sommerzeit (UTC+2): ${summerCount} Einträge`);
console.log(` - Winterzeit (UTC+1): ${winterCount} Einträge`);
console.log(` - Gesamt zu korrigieren: ${updates.length}`);
// Zeige erste 10 Beispiele
console.log(`\n📝 Beispiele (erste 10):`);
updates.slice(0, 10).forEach(u => {
console.log(` ID ${u.id}: ${u.old} (lokal) → ${u.new} (UTC, offset: -${u.offset}h)`);
});
console.log(`\n⚠️ WARNUNG: Diese Operation ändert ${updates.length} Einträge in der Datenbank!`);
console.log(`\n📝 Backup erstellen:`);
console.log(` mysqldump -u ${process.env.DB_USER} -p ${process.env.DB_NAME} worklog > worklog_backup_$(date +%Y%m%d_%H%M%S).sql`);
console.log(`\nFühre das Script mit dem Parameter 'apply' aus, um die Änderungen anzuwenden:`);
console.log(` node backend/fix-timezone.js apply\n`);
// Nur anwenden, wenn 'apply' als Parameter übergeben wurde
if (process.argv.includes('apply')) {
console.log('🔄 Wende Korrekturen an...\n');
let updatedCount = 0;
for (const update of updates) {
await connection.execute(
'UPDATE worklog SET tstamp = ? WHERE id = ?',
[update.new, update.id]
);
updatedCount++;
if (updatedCount % 100 === 0) {
console.log(` ${updatedCount}/${updates.length} aktualisiert...`);
}
}
console.log(`\n${updatedCount} Einträge erfolgreich aktualisiert!`);
console.log(`\n🔄 Bitte starte den Backend-Server neu, um die Änderungen zu sehen.`);
}
} catch (error) {
console.error('❌ Fehler:', error);
process.exit(1);
} finally {
if (connection) {
await connection.end();
console.log('\n🔌 Datenbankverbindung geschlossen');
}
}
}
// Script ausführen
fixTimezones().then(() => {
process.exit(0);
}).catch(err => {
console.error('Fataler Fehler:', err);
process.exit(1);
});

View File

@@ -0,0 +1,39 @@
-- Script zur Korrektur der Worklog Zeitstempel
-- Die alte C++ App hat lokale Zeit gespeichert, aber als UTC interpretiert
-- Wir müssen die Zeitstempel korrigieren, indem wir sie als lokale Zeit interpretieren
-- WICHTIG: Erstelle erst ein Backup!
-- mysqldump -u root -p stechuhr2 worklog > worklog_backup_$(date +%Y%m%d_%H%M%S).sql
-- Prüfe aktuelle Daten (erste 10 Einträge)
SELECT
id,
user_id,
state,
tstamp AS tstamp_alt,
CONVERT_TZ(tstamp, '+00:00', '+01:00') AS tstamp_neu_mez,
CONVERT_TZ(tstamp, '+00:00', '+02:00') AS tstamp_neu_mesz
FROM worklog
ORDER BY id
LIMIT 10;
-- INFO: Welche Zeitzone soll verwendet werden?
-- MEZ (Winterzeit) = UTC+1 = '+01:00'
-- MESZ (Sommerzeit) = UTC+2 = '+02:00'
-- OPTION A: Alle Timestamps als MEZ (UTC+1) interpretieren
-- UPDATE worklog SET tstamp = CONVERT_TZ(tstamp, '+00:00', '+01:00');
-- OPTION B: Alle Timestamps als MESZ (UTC+2) interpretieren
-- UPDATE worklog SET tstamp = CONVERT_TZ(tstamp, '+00:00', '+02:00');
-- OPTION C: Intelligente Korrektur basierend auf Sommerzeit/Winterzeit
-- (Komplexer, aber genauer - berücksichtigt die tatsächliche Zeitzone zum Zeitpunkt der Speicherung)
SELECT '⚠️ WICHTIG: Prüfe die Ausgabe oben und wähle die passende UPDATE-Anweisung!' AS Warnung;
SELECT 'Die C++ App hat wahrscheinlich lokale Zeit (MEZ/MESZ) gespeichert, aber MySQL hat sie als UTC interpretiert.' AS Info;
SELECT 'Wenn die Zeiten im Sommer (März-Oktober) 2h früher sind, nutze OPTION B (MESZ = +02:00)' AS Tipp1;
SELECT 'Wenn die Zeiten im Winter (November-Februar) 1h früher sind, nutze OPTION A (MEZ = +01:00)' AS Tipp2;

View File

@@ -0,0 +1,21 @@
-- Migration Script für timewish Tabelle (vereinfacht)
-- 1. Spalten hinzufügen (mit Default-Wert)
ALTER TABLE `timewish`
ADD COLUMN `start_date` DATE DEFAULT '2023-01-01' COMMENT 'Ab welchem Datum gilt dieser Timewish' AFTER `end_time`,
ADD COLUMN `end_date` DATE DEFAULT NULL COMMENT 'Bis welchem Datum gilt dieser Timewish (NULL = bis heute)' AFTER `start_date`;
-- 2. Alle bestehenden Einträge auf 2024-11-01 setzen
UPDATE `timewish` SET `start_date` = '2024-11-01';
-- 3. start_date darf nicht NULL sein
ALTER TABLE `timewish`
MODIFY COLUMN `start_date` DATE NOT NULL COMMENT 'Ab welchem Datum gilt dieser Timewish';
-- 4. Zeige aktuelle Einträge
SELECT id, user_id, day, hours, start_date, end_date FROM timewish;
SELECT '✅ Migration erfolgreich!' AS Status;

View File

@@ -0,0 +1,24 @@
-- Migration Script für timewish Tabelle
-- Fügt start_date und end_date Spalten hinzu
-- 1. Spalten hinzufügen
ALTER TABLE `timewish`
ADD COLUMN `start_date` DATE COMMENT 'Ab welchem Datum gilt dieser Timewish' AFTER `end_time`,
ADD COLUMN `end_date` DATE DEFAULT NULL COMMENT 'Bis welchem Datum gilt dieser Timewish (NULL = bis heute)' AFTER `start_date`;
-- 2. Für bestehende Einträge: Setze start_date auf den 01.01.2023 (oder ein anderes passendes Datum)
-- und end_date auf NULL (bedeutet: bis heute gültig)
UPDATE `timewish`
SET `start_date` = '2023-01-01',
`end_date` = NULL
WHERE `start_date` IS NULL;
-- 3. start_date darf nicht NULL sein
ALTER TABLE `timewish`
MODIFY COLUMN `start_date` DATE NOT NULL COMMENT 'Ab welchem Datum gilt dieser Timewish';
SELECT '✅ timewish Tabelle erfolgreich migriert!' AS Status;
SELECT '⚠️ WICHTIG: Prüfe die start_date Werte und passe sie bei Bedarf an!' AS Hinweis;

14
backend/nodemon.json Normal file
View File

@@ -0,0 +1,14 @@
{
"watch": ["src"],
"ext": "js,json",
"ignore": ["src/**/*.spec.js", "src/**/*.test.js", "node_modules"],
"exec": "node src/index.js",
"env": {
"NODE_ENV": "development"
},
"delay": 1000,
"verbose": false
}

38
backend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "timeclock-backend",
"version": "3.0.0",
"description": "TimeClock v3 - Backend API für Zeiterfassung",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "echo \"Fehler: Kein Test angegeben\" && exit 1"
},
"keywords": [
"timeclock",
"zeiterfassung",
"api"
],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
"dotenv": "^16.3.1",
"mysql2": "^3.6.5",
"sequelize": "^6.35.2",
"bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.7",
"crypto": "^1.0.1",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"express-session": "^1.18.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

View File

@@ -0,0 +1,56 @@
-- Ersetze Worklog-Einträge für Donnerstag, 16.10.2025
-- Lokale Zeit (MESZ = UTC+2): 08:02:35, 12:07:53, 13:06:14, 16:45:02, 17:00:00, 17:13:24
-- UTC-Zeit (für DB): 06:02:35, 10:07:53, 11:06:14, 14:45:02, 15:00:00, 15:13:24
-- Schritt 1: Lösche alte Einträge für 16.10.2025
-- Wegen Foreign Key Constraints müssen wir in der richtigen Reihenfolge löschen:
-- Erst die Einträge, die auf andere verweisen (stop work, stop pause)
-- Dann die Einträge, die referenziert werden (start pause, start work)
-- 1a. Lösche stop work und stop pause Einträge
DELETE FROM worklog
WHERE user_id = 1
AND DATE(tstamp) = '2025-10-16'
AND state IN ('stop work', 'stop pause');
-- 1b. Lösche start pause und start work Einträge
DELETE FROM worklog
WHERE user_id = 1
AND DATE(tstamp) = '2025-10-16'
AND state IN ('start pause', 'start work');
-- Schritt 2: Füge neue Einträge ein
-- Variablen für die IDs
SET @start_work_id = NULL;
SET @start_pause1_id = NULL;
SET @start_pause2_id = NULL;
-- 1. start work (06:02:35 UTC = 08:02:35 lokal)
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
VALUES (1, 'start work', '2025-10-16 06:02:35', NULL, 0);
SET @start_work_id = LAST_INSERT_ID();
-- 2. start pause (10:07:53 UTC = 12:07:53 lokal) → relatedTo = start work
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
VALUES (1, 'start pause', '2025-10-16 10:07:53', @start_work_id, 0);
SET @start_pause1_id = LAST_INSERT_ID();
-- 3. stop pause (11:06:14 UTC = 13:06:14 lokal) → relatedTo = start pause
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
VALUES (1, 'stop pause', '2025-10-16 11:06:14', @start_pause1_id, 0);
-- 4. start pause (14:45:02 UTC = 16:45:02 lokal) → relatedTo = start work
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
VALUES (1, 'start pause', '2025-10-16 14:45:02', @start_work_id, 0);
SET @start_pause2_id = LAST_INSERT_ID();
-- 5. stop pause (15:00:00 UTC = 17:00:00 lokal) → relatedTo = start pause
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
VALUES (1, 'stop pause', '2025-10-16 15:00:00', @start_pause2_id, 0);
-- 6. stop work (15:13:24 UTC = 17:13:24 lokal) → relatedTo = start work
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
VALUES (1, 'stop work', '2025-10-16 15:13:24', @start_work_id, 0);
SELECT 'Worklog-Einträge für 16.10.2025 erfolgreich ersetzt' AS Status;

View File

@@ -0,0 +1,100 @@
/**
* Rollback des Timezone-Fix
* Macht die Änderungen rückgängig
*/
const mysql = require('mysql2/promise');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '.env') });
/**
* Prüft, ob ein Datum in der Sommerzeit liegt
*/
function isSummertime(date) {
const year = date.getFullYear();
const marchEnd = new Date(year, 2, 31);
const marchSunday = 31 - marchEnd.getDay();
const summerStart = new Date(year, 2, marchSunday, 2, 0, 0);
const octoberEnd = new Date(year, 9, 31);
const octoberSunday = 31 - octoberEnd.getDay();
const summerEnd = new Date(year, 9, octoberSunday, 3, 0, 0);
return date >= summerStart && date < summerEnd;
}
async function rollbackTimezones() {
let connection;
try {
connection = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'stechuhr2',
timezone: '+00:00'
});
console.log('✅ Datenbankverbindung hergestellt\n');
const [rows] = await connection.execute(
'SELECT id, tstamp FROM worklog ORDER BY id ASC'
);
console.log(`📊 Gefunden: ${rows.length} Worklog-Einträge\n`);
console.log('🔄 Wende Rollback an (addiere Offset zurück)...\n');
let updatedCount = 0;
for (const row of rows) {
const storedDate = new Date(row.tstamp);
// Erstelle lokales Datum aus UTC-Komponenten
const year = storedDate.getUTCFullYear();
const month = storedDate.getUTCMonth();
const day = storedDate.getUTCDate();
const hours = storedDate.getUTCHours();
const minutes = storedDate.getUTCMinutes();
const seconds = storedDate.getUTCSeconds();
const localDate = new Date(year, month, day, hours, minutes, seconds);
const offset = isSummertime(localDate) ? 2 : 1;
// Addiere den Offset ZURÜCK
const correctedDate = new Date(localDate.getTime() + (offset * 60 * 60 * 1000));
const correctedISO = correctedDate.toISOString().replace('T', ' ').replace('.000Z', '');
await connection.execute(
'UPDATE worklog SET tstamp = ? WHERE id = ?',
[correctedISO, row.id]
);
updatedCount++;
if (updatedCount % 100 === 0) {
console.log(` ${updatedCount}/${rows.length} aktualisiert...`);
}
}
console.log(`\n${updatedCount} Einträge erfolgreich zurückgesetzt!`);
} catch (error) {
console.error('❌ Fehler:', error);
process.exit(1);
} finally {
if (connection) {
await connection.end();
console.log('\n🔌 Datenbankverbindung geschlossen');
}
}
}
rollbackTimezones().then(() => {
process.exit(0);
}).catch(err => {
console.error('Fataler Fehler:', err);
process.exit(1);
});

156
backend/setup-mysql.sh Executable file
View File

@@ -0,0 +1,156 @@
#!/bin/bash
# TimeClock MySQL Setup Script
# Erstellt automatisch Datenbank und Benutzer
set -e
echo "🕐 TimeClock - MySQL Setup"
echo "=========================="
echo ""
# Farben für Output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Standardwerte
DB_NAME="stechuhr2"
DEFAULT_USER="timeclock_user"
DEFAULT_PASSWORD="timeclock_dev_2025"
echo "Dieses Script erstellt:"
echo " - Datenbank: $DB_NAME"
echo " - Benutzer: $DEFAULT_USER"
echo ""
# MySQL Root-Passwort abfragen
read -sp "MySQL Root-Passwort eingeben: " MYSQL_ROOT_PASSWORD
echo ""
echo ""
# Test MySQL-Verbindung
echo "Teste MySQL-Verbindung..."
if ! mysql -u root -p"$MYSQL_ROOT_PASSWORD" -e "SELECT 1;" &> /dev/null; then
echo -e "${RED}❌ Fehler: MySQL-Verbindung fehlgeschlagen${NC}"
echo "Bitte prüfen Sie:"
echo " - MySQL Server läuft: sudo systemctl status mysql"
echo " - Root-Passwort ist korrekt"
exit 1
fi
echo -e "${GREEN}✅ MySQL-Verbindung erfolgreich${NC}"
echo ""
# Benutzer-Credentials abfragen
read -p "Datenbank-Benutzer [$DEFAULT_USER]: " DB_USER
DB_USER=${DB_USER:-$DEFAULT_USER}
read -sp "Passwort für '$DB_USER' [$DEFAULT_PASSWORD]: " DB_PASSWORD
echo ""
DB_PASSWORD=${DB_PASSWORD:-$DEFAULT_PASSWORD}
echo ""
# Datenbank und Benutzer erstellen
echo "Erstelle Datenbank und Benutzer..."
mysql -u root -p"$MYSQL_ROOT_PASSWORD" <<MYSQL_SCRIPT
-- Datenbank erstellen
CREATE DATABASE IF NOT EXISTS $DB_NAME
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
-- Benutzer erstellen (falls existiert, überspringen)
CREATE USER IF NOT EXISTS '$DB_USER'@'localhost'
IDENTIFIED BY '$DB_PASSWORD';
-- Berechtigungen vergeben
GRANT ALL PRIVILEGES ON $DB_NAME.*
TO '$DB_USER'@'localhost';
-- Privilegien neu laden
FLUSH PRIVILEGES;
-- Info ausgeben
SELECT 'Datenbank erstellt' AS Status;
SHOW DATABASES LIKE '$DB_NAME';
SELECT User, Host FROM mysql.user WHERE User='$DB_USER';
MYSQL_SCRIPT
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ Datenbank und Benutzer erfolgreich erstellt${NC}"
echo ""
else
echo -e "${RED}❌ Fehler beim Erstellen${NC}"
exit 1
fi
# .env Datei erstellen/aktualisieren
ENV_FILE=".env"
echo "Aktualisiere .env Datei..."
cat > "$ENV_FILE" <<ENV_CONTENT
# Server-Konfiguration
PORT=3010
NODE_ENV=development
# MySQL Datenbank-Verbindung
DB_HOST=localhost
DB_PORT=3306
DB_USER=$DB_USER
DB_PASSWORD=$DB_PASSWORD
DB_NAME=$DB_NAME
# Datenbank-Optionen
DB_LOGGING=false
DB_TIMEZONE=+01:00
DB_POOL_MAX=10
DB_POOL_MIN=0
# JWT-Konfiguration
JWT_SECRET=timeclock-dev-secret-change-in-production-12345
JWT_EXPIRATION=24h
# E-Mail-Konfiguration (für Passwort-Reset)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@timeclock.com
ENV_CONTENT
echo -e "${GREEN}✅ .env Datei erstellt${NC}"
echo ""
# Test-Verbindung
echo "Teste Verbindung mit neuen Credentials..."
if mysql -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" -e "SELECT 1;" &> /dev/null; then
echo -e "${GREEN}✅ Verbindung erfolgreich!${NC}"
else
echo -e "${RED}❌ Verbindung fehlgeschlagen${NC}"
exit 1
fi
echo ""
# Zusammenfassung
echo "=========================="
echo -e "${GREEN}🎉 Setup abgeschlossen!${NC}"
echo ""
echo "Ihre Konfiguration:"
echo " Datenbank: $DB_NAME"
echo " Benutzer: $DB_USER"
echo " Host: localhost:3306"
echo ""
echo "Die .env Datei wurde erstellt mit den Zugangsdaten."
echo ""
echo "Nächste Schritte:"
echo " 1. Server starten: npm run dev"
echo " 2. Browser öffnen: http://localhost:5010"
echo ""
echo "Erste Schritte:"
echo " 1. Account registrieren unter /register"
echo " 2. Einloggen und Timer starten"
echo ""

View File

@@ -0,0 +1,37 @@
-- Komplettes Setup für timewish mit Zeiträumen
-- Führe dieses Script in deiner stechuhr2 Datenbank aus
-- Schritt 1: Tabelle anpassen (Spalten hinzufügen)
ALTER TABLE `timewish`
ADD COLUMN IF NOT EXISTS `start_date` DATE DEFAULT '2023-01-01' COMMENT 'Ab welchem Datum gilt dieser Timewish' AFTER `end_time`,
ADD COLUMN IF NOT EXISTS `end_date` DATE DEFAULT NULL COMMENT 'Bis welchem Datum gilt dieser Timewish (NULL = bis heute)' AFTER `start_date`;
-- Schritt 2: Bestehende Einträge löschen (falls vorhanden)
DELETE FROM `timewish` WHERE user_id = 1;
-- Schritt 3: Alte Timewishes (2023-01-01 bis 2024-10-31): 8h für alle Tage
INSERT INTO timewish (version, user_id, day, wishtype, hours, end_time, start_date, end_date) VALUES
(0, 1, 1, 2, 8.0, NULL, '2023-01-01', '2024-10-31'), -- Montag: 8h
(0, 1, 2, 2, 8.0, NULL, '2023-01-01', '2024-10-31'), -- Dienstag: 8h
(0, 1, 3, 2, 8.0, NULL, '2023-01-01', '2024-10-31'), -- Mittwoch: 8h
(0, 1, 4, 2, 8.0, NULL, '2023-01-01', '2024-10-31'), -- Donnerstag: 8h
(0, 1, 5, 2, 8.0, NULL, '2023-01-01', '2024-10-31'); -- Freitag: 8h
-- Schritt 4: Neue Timewishes (ab 2024-11-01): Di=7h, Fr=7,5h, Rest=8h
INSERT INTO timewish (version, user_id, day, wishtype, hours, end_time, start_date, end_date) VALUES
(0, 1, 1, 2, 8.0, NULL, '2024-11-01', NULL), -- Montag: 8h
(0, 1, 2, 2, 7.0, NULL, '2024-11-01', NULL), -- Dienstag: 7h
(0, 1, 3, 2, 8.0, NULL, '2024-11-01', NULL), -- Mittwoch: 8h
(0, 1, 4, 2, 8.0, NULL, '2024-11-01', NULL), -- Donnerstag: 8h
(0, 1, 5, 2, 7.5, NULL, '2024-11-01', NULL); -- Freitag: 7,5h
-- Schritt 5: start_date als NOT NULL setzen
ALTER TABLE `timewish`
MODIFY COLUMN `start_date` DATE NOT NULL COMMENT 'Ab welchem Datum gilt dieser Timewish';
-- Schritt 6: Ergebnis anzeigen
SELECT '✅ Timewish Tabelle erfolgreich eingerichtet!' AS Status;
SELECT * FROM timewish WHERE user_id = 1 ORDER BY day, start_date;

View 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();

View 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;

View 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();

View 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();

View 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();

View 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
View 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;

View 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
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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
};

View 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();

View 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();

View 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;

View 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;

View 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;

View 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();

View 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();

File diff suppressed because it is too large Load Diff

143
backend/src/utils/hashId.js Normal file
View 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;

View File

@@ -0,0 +1,15 @@
-- Update overtime_offset_minutes für User 1
-- Aktuell: 5573 Minuten (92:53)
-- Neu: 5620 Minuten (93:40)
-- Differenz: +47 Minuten
-- Resultat: Überstunden ändern sich von 2:47 auf 3:34
UPDATE user
SET overtime_offset_minutes = 5620
WHERE id = 1;
SELECT id, full_name, overtime_offset_minutes,
CONCAT(FLOOR(overtime_offset_minutes / 60), ':', LPAD(overtime_offset_minutes % 60, 2, '0')) AS overtime_formatted
FROM user
WHERE id = 1;