From e95bb4cb769a44c037be70c41e870930370bbf1a Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Oct 2025 14:11:28 +0200 Subject: [PATCH] Initial commit: TimeClock v3 - Node.js/Vue.js Zeiterfassung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 55 + AUTH.md | 408 ++ OAUTH_SETUP.md | 345 ++ QUICKSTART.md | 181 + README.md | 218 + SETUP.md | 173 + backend/.gitignore | 9 + backend/ARCHITECTURE.md | 298 ++ backend/DATABASE.md | 370 ++ backend/DB_SETUP.md | 316 ++ backend/ID_HASHING.md | 164 + backend/MYSQL_SETUP.md | 248 + backend/README.md | 145 + backend/SEQUELIZE.md | 565 +++ backend/add-missing-wednesday.sql | 28 + backend/database-schema.sql | 284 ++ backend/fix-timezone-correct.js | 69 + backend/fix-timezone.js | 156 + backend/fix-worklog-timezone.sql | 39 + backend/migrate-timewish-simple.sql | 21 + backend/migrate-timewish.sql | 24 + backend/nodemon.json | 14 + backend/package.json | 38 + backend/replace-thursday-data.sql | 56 + backend/rollback-timezone.js | 100 + backend/setup-mysql.sh | 156 + backend/setup-timewish-complete.sql | 37 + backend/src/config/database.js | 202 + backend/src/config/passport.js | 48 + backend/src/controllers/AuthController.js | 256 ++ backend/src/controllers/OAuthController.js | 101 + .../src/controllers/TimeEntryController.js | 265 ++ .../src/controllers/WeekOverviewController.js | 35 + backend/src/index.js | 110 + backend/src/middleware/auth.js | 66 + backend/src/middleware/hashResponse.js | 104 + backend/src/middleware/unhashRequest.js | 116 + backend/src/models/AuthIdentity.js | 87 + backend/src/models/AuthInfo.js | 99 + backend/src/models/AuthToken.js | 67 + backend/src/models/Holiday.js | 50 + backend/src/models/Sick.js | 71 + backend/src/models/SickType.js | 41 + backend/src/models/State.js | 41 + backend/src/models/Timefix.js | 88 + backend/src/models/Timewish.js | 74 + backend/src/models/User.js | 105 + backend/src/models/Vacation.js | 64 + backend/src/models/WeeklyWorktime.js | 58 + backend/src/models/Worklog.js | 128 + backend/src/models/index.js | 31 + backend/src/repositories/UserRepository.js | 195 + backend/src/repositories/WorklogRepository.js | 564 +++ backend/src/routes/auth.js | 26 + backend/src/routes/timeEntries.js | 36 + backend/src/routes/weekOverview.js | 16 + backend/src/services/AuthService.js | 453 ++ backend/src/services/OAuthService.js | 209 + backend/src/services/TimeEntryService.js | 2380 ++++++++++ backend/src/utils/hashId.js | 143 + backend/update-overtime-offset.sql | 15 + frontend/.gitignore | 8 + frontend/README.md | 107 + frontend/index.html | 16 + frontend/package.json | 23 + frontend/src/App.vue | 255 ++ frontend/src/assets/main.css | 158 + frontend/src/components/SideMenu.vue | 190 + frontend/src/components/StatusBox.vue | 522 +++ frontend/src/main.js | 25 + frontend/src/router/index.js | 104 + frontend/src/stores/authStore.js | 338 ++ frontend/src/stores/timeStore.js | 125 + frontend/src/views/Dashboard.vue | 265 ++ frontend/src/views/Entries.vue | 209 + frontend/src/views/Login.vue | 363 ++ frontend/src/views/OAuthCallback.vue | 97 + frontend/src/views/PasswordForgot.vue | 309 ++ frontend/src/views/PasswordReset.vue | 288 ++ frontend/src/views/Register.vue | 298 ++ frontend/src/views/Stats.vue | 204 + frontend/src/views/WeekOverview.vue | 697 +++ frontend/vite.config.js | 22 + package-lock.json | 4011 +++++++++++++++++ package.json | 33 + worklog_backup_20251015_215729.sql | 2 + 86 files changed, 19530 insertions(+) create mode 100644 .gitignore create mode 100644 AUTH.md create mode 100644 OAUTH_SETUP.md create mode 100644 QUICKSTART.md create mode 100644 README.md create mode 100644 SETUP.md create mode 100644 backend/.gitignore create mode 100644 backend/ARCHITECTURE.md create mode 100644 backend/DATABASE.md create mode 100644 backend/DB_SETUP.md create mode 100644 backend/ID_HASHING.md create mode 100644 backend/MYSQL_SETUP.md create mode 100644 backend/README.md create mode 100644 backend/SEQUELIZE.md create mode 100644 backend/add-missing-wednesday.sql create mode 100644 backend/database-schema.sql create mode 100644 backend/fix-timezone-correct.js create mode 100644 backend/fix-timezone.js create mode 100644 backend/fix-worklog-timezone.sql create mode 100644 backend/migrate-timewish-simple.sql create mode 100644 backend/migrate-timewish.sql create mode 100644 backend/nodemon.json create mode 100644 backend/package.json create mode 100644 backend/replace-thursday-data.sql create mode 100644 backend/rollback-timezone.js create mode 100755 backend/setup-mysql.sh create mode 100644 backend/setup-timewish-complete.sql create mode 100644 backend/src/config/database.js create mode 100644 backend/src/config/passport.js create mode 100644 backend/src/controllers/AuthController.js create mode 100644 backend/src/controllers/OAuthController.js create mode 100644 backend/src/controllers/TimeEntryController.js create mode 100644 backend/src/controllers/WeekOverviewController.js create mode 100644 backend/src/index.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/middleware/hashResponse.js create mode 100644 backend/src/middleware/unhashRequest.js create mode 100644 backend/src/models/AuthIdentity.js create mode 100644 backend/src/models/AuthInfo.js create mode 100644 backend/src/models/AuthToken.js create mode 100644 backend/src/models/Holiday.js create mode 100644 backend/src/models/Sick.js create mode 100644 backend/src/models/SickType.js create mode 100644 backend/src/models/State.js create mode 100644 backend/src/models/Timefix.js create mode 100644 backend/src/models/Timewish.js create mode 100644 backend/src/models/User.js create mode 100644 backend/src/models/Vacation.js create mode 100644 backend/src/models/WeeklyWorktime.js create mode 100644 backend/src/models/Worklog.js create mode 100644 backend/src/models/index.js create mode 100644 backend/src/repositories/UserRepository.js create mode 100644 backend/src/repositories/WorklogRepository.js create mode 100644 backend/src/routes/auth.js create mode 100644 backend/src/routes/timeEntries.js create mode 100644 backend/src/routes/weekOverview.js create mode 100644 backend/src/services/AuthService.js create mode 100644 backend/src/services/OAuthService.js create mode 100644 backend/src/services/TimeEntryService.js create mode 100644 backend/src/utils/hashId.js create mode 100644 backend/update-overtime-offset.sql create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/assets/main.css create mode 100644 frontend/src/components/SideMenu.vue create mode 100644 frontend/src/components/StatusBox.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/stores/authStore.js create mode 100644 frontend/src/stores/timeStore.js create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/Entries.vue create mode 100644 frontend/src/views/Login.vue create mode 100644 frontend/src/views/OAuthCallback.vue create mode 100644 frontend/src/views/PasswordForgot.vue create mode 100644 frontend/src/views/PasswordReset.vue create mode 100644 frontend/src/views/Register.vue create mode 100644 frontend/src/views/Stats.vue create mode 100644 frontend/src/views/WeekOverview.vue create mode 100644 frontend/vite.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 worklog_backup_20251015_215729.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..414acfd --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Dependencies +node_modules/ +*/node_modules/ + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +logs/ + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Build outputs +dist/ +build/ +*/dist/ +*/build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Cursor/Agent tools +.cursor/ + +# Test coverage +coverage/ +.nyc_output/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# SQL scripts (optional - nur wenn sie sensible Daten enthalten) +# backend/*.sql + +# Database +*.db +*.sqlite +*.sqlite3 diff --git a/AUTH.md b/AUTH.md new file mode 100644 index 0000000..2d07f60 --- /dev/null +++ b/AUTH.md @@ -0,0 +1,408 @@ +# Authentication & Session-Management + +## Übersicht + +TimeClock v3 verfügt über ein vollständiges JWT-basiertes Authentifizierungssystem mit persistenter Session-Verwaltung. + +## Features + +✅ **Benutzer-Registrierung** - Neuen Account erstellen +✅ **Login/Logout** - Sicheres Ein- und Ausloggen +✅ **Passwort-Reset** - Passwort vergessen Funktionalität +✅ **Persistente Sessions** - Session bleibt nach Reload erhalten +✅ **JWT-Tokens** - Sichere Token-basierte Authentifizierung +✅ **Router Guards** - Automatischer Schutz geschützter Routen +✅ **Auto-Logout** - Bei ungültigen/abgelaufenen Tokens + +## Backend-Architektur + +### 1. Auth-Service (`AuthService.js`) + +Hauptfunktionen: +```javascript +authService.register(userData) // Registrierung +authService.login(email, password) // Login +authService.logout(token) // Logout +authService.validateToken(token) // Token validieren +authService.requestPasswordReset(email) // Reset anfordern +authService.resetPassword(token, pw) // Passwort zurücksetzen +authService.changePassword(userId, ...) // Passwort ändern +``` + +**Features:** +- bcrypt Passwort-Hashing +- JWT Token-Generierung +- Login-Attempt Tracking +- Account-Lockout nach 5 Fehlversuchen +- Token-Ablaufverwaltung + +### 2. Auth-Controller (`AuthController.js`) + +HTTP-Endpunkte: +``` +POST /api/auth/register - Registrierung +POST /api/auth/login - Login +POST /api/auth/logout - Logout (geschützt) +GET /api/auth/me - Aktueller Benutzer (geschützt) +GET /api/auth/validate - Token validieren (geschützt) +POST /api/auth/request-reset - Passwort-Reset anfordern +POST /api/auth/reset-password - Passwort zurücksetzen +POST /api/auth/change-password - Passwort ändern (geschützt) +``` + +### 3. Auth-Middleware (`middleware/auth.js`) + +```javascript +// Geschützte Route +app.use('/api/time-entries', authenticateToken, router) + +// Optionale Auth +app.use('/api/stats', optionalAuth, router) +``` + +**Funktionsweise:** +1. Token aus `Authorization: Bearer ` Header extrahieren +2. Token validieren (JWT + Datenbank) +3. Bei Erfolg: `req.user` mit Benutzer-Info setzen +4. Bei Fehler: 401 Unauthorized + +### 4. Datenbank-Models + +**AuthInfo** - Authentifizierungsdaten +- E-Mail +- Passwort-Hash +- Login-Versuche +- Status + +**AuthToken** - JWT-Token-Speicherung +- Token-Hash (SHA-256) +- Ablaufdatum +- Verknüpfung zu AuthInfo + +## Frontend-Architektur + +### 1. Auth-Store (`stores/authStore.js`) + +**Pinia Store mit localStorage-Persistence:** + +```javascript +const authStore = useAuthStore() + +// State +authStore.user // Aktueller Benutzer +authStore.token // JWT Token +authStore.isAuthenticated // Login-Status +authStore.isLoading // Loading-State + +// Actions +await authStore.register(userData) +await authStore.login(credentials) +await authStore.logout() +await authStore.fetchCurrentUser() // Session wiederherstellen +await authStore.validateToken() +await authStore.requestPasswordReset(email) +await authStore.resetPassword(token, password) +await authStore.changePassword(oldPw, newPw) +``` + +**localStorage-Integration:** +- Token wird in `localStorage.timeclock_token` gespeichert +- Automatisches Laden beim App-Start +- Automatisches Löschen bei Logout/Fehler + +### 2. Auth-Views + +**Login (`views/Login.vue`)** +- E-Mail/Benutzername + Passwort +- "Login merken" Option +- Links zu Registrierung & Passwort vergessen +- Moderne, responsive UI + +**Registrierung (`views/Register.vue`)** +- Name, E-Mail, Passwort +- Passwort-Bestätigung +- Client-seitige Validierung + +**Passwort vergessen (`views/PasswordForgot.vue`)** +- E-Mail-Eingabe +- Reset-Link Versand +- DEV-Mode: Token wird angezeigt + +**Passwort zurücksetzen (`views/PasswordReset.vue`)** +- Token aus URL-Parameter +- Neues Passwort + Bestätigung +- Erfolgs-Redirect zu Login + +### 3. Router Guards (`router/index.js`) + +**Automatischer Schutz:** + +```javascript +// Geschützte Routes +{ + path: '/', + component: Dashboard, + meta: { requiresAuth: true } // Nur für eingeloggte Benutzer +} + +// Öffentliche Routes +{ + path: '/login', + component: Login, + meta: { requiresGuest: true } // Nur für nicht-eingeloggte +} +``` + +**Navigation Guard:** +```javascript +router.beforeEach(async (to, from, next) => { + // 1. Session wiederherstellen falls Token vorhanden + // 2. Auth-Status prüfen + // 3. Redirect falls nötig +}) +``` + +### 4. Session-Wiederherstellung + +**Beim App-Start (`main.js`):** +```javascript +const authStore = useAuthStore() +if (authStore.loadToken()) { + await authStore.fetchCurrentUser() +} +``` + +**Bei jeder Navigation:** +```javascript +if (!authStore.isAuthenticated && authStore.loadToken()) { + await authStore.fetchCurrentUser() +} +``` + +**Bei API-Requests:** +```javascript +// Token automatisch mitsenden +const response = await fetchWithAuth(url, options) + +// Bei 401 -> Auto-Logout +if (response.status === 401) { + authStore.clearAuth() + window.location.href = '/login' +} +``` + +## Sicherheits-Features + +### Backend + +1. **Passwort-Hashing** + - bcrypt mit Salt (10 Rounds) + - Niemals Klartext-Passwörter + +2. **Login-Schutz** + - Max. 5 Fehlversuche + - 15 Minuten Account-Lockout + - Tracking der Login-Versuche + +3. **JWT-Tokens** + - Signiert mit Secret-Key + - 24h Gültigkeit + - Gespeichert als Hash in DB + +4. **Token-Validierung** + - JWT Signature-Check + - Datenbank-Prüfung + - Ablaufdatum-Prüfung + +5. **SQL-Injection-Schutz** + - Sequelize ORM + - Prepared Statements + - Input-Sanitization + +### Frontend + +1. **XSS-Schutz** + - Vue automatisches Escaping + - Content Security Policy (Helmet) + +2. **Token-Sicherheit** + - localStorage (HTTPS erforderlich!) + - Automatisches Löschen bei Logout + - Kein Token in URL/Query-Params + +3. **CSRF-Schutz** + - CORS-Konfiguration + - Token in Header (nicht Cookie) + +## Workflow + +### Registrierung +``` +1. Benutzer → /register +2. Formular ausfüllen +3. POST /api/auth/register +4. Backend: Passwort hashen, User + AuthInfo erstellen +5. Success → Redirect zu /login +``` + +### Login +``` +1. Benutzer → /login +2. E-Mail + Passwort eingeben +3. POST /api/auth/login +4. Backend: Credentials prüfen, JWT generieren +5. Frontend: Token in localStorage speichern +6. Redirect zu / +7. Session ist aktiv! +``` + +### Session-Wiederherstellung (Reload) +``` +1. App-Start / Page Reload +2. main.js lädt Token aus localStorage +3. GET /api/auth/me (mit Token) +4. Backend validiert Token +5. Benutzer-Daten zurück +6. authStore.user + isAuthenticated setzen +7. Session wiederhergestellt! +``` + +### API-Request mit Auth +``` +1. fetchWithAuth(url, options) +2. Token aus localStorage laden +3. Header: Authorization: Bearer +4. Request senden +5. Bei 401: clearAuth() + Redirect zu /login +``` + +### Logout +``` +1. Logout-Button klicken +2. POST /api/auth/logout (Token in DB löschen) +3. Frontend: localStorage.removeItem('timeclock_token') +4. authStore.clearAuth() +5. Redirect zu /login +``` + +### Passwort vergessen +``` +1. /password-forgot +2. E-Mail eingeben +3. POST /api/auth/request-reset +4. Backend: Reset-Token generieren, in DB speichern +5. (Produktion: E-Mail senden mit Link) +6. Benutzer klickt Link: /password-reset?token=xyz +7. Neues Passwort eingeben +8. POST /api/auth/reset-password +9. Backend: Token prüfen, Passwort hashen, speichern +10. Success → Login +``` + +## Konfiguration + +### Backend (.env) +```env +JWT_SECRET=change-this-to-a-random-secret-key-in-production +JWT_EXPIRATION=24h + +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASSWORD=your-password +SMTP_FROM=noreply@timeclock.com +``` + +### Frontend +```javascript +// src/stores/authStore.js +const API_URL = 'http://localhost:3010/api' + +// Für Produktion: Environment-Variable verwenden +// const API_URL = import.meta.env.VITE_API_URL +``` + +## Testing + +### Backend +```bash +# Registrierung +curl -X POST http://localhost:3010/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"test123","full_name":"Test User"}' + +# Login +curl -X POST http://localhost:3010/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"test123"}' + +# Aktueller Benutzer (mit Token) +curl http://localhost:3010/api/auth/me \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +``` + +### Frontend +1. `/register` → Neuen Account erstellen +2. `/login` → Einloggen +3. Browser-Reload → Session bleibt erhalten ✓ +4. DevTools → Application → Local Storage → `timeclock_token` sichtbar +5. Logout → Token wird gelöscht ✓ + +## Troubleshooting + +### Session geht nach Reload verloren +- localStorage überprüfen: DevTools → Application +- Browser-Konsole auf Fehler prüfen +- Token-Validierung im Backend überprüfen + +### 401 Unauthorized +- Token abgelaufen (nach 24h) +- Token ungültig/gelöscht +- Backend-DB-Connection fehlt + +### Login funktioniert nicht +- Backend läuft auf Port 3010? +- DB-Verbindung OK? +- Credentials korrekt? +- Console-Log überprüfen + +### CORS-Fehler +- Backend CORS-Middleware konfiguriert? +- Frontend-URL in CORS-Config erlaubt? + +## Best Practices + +1. **Niemals JWT_SECRET committen** + - In .env (nicht in Git!) + - Für Produktion: Starkes, zufälliges Secret + +2. **HTTPS in Produktion** + - localStorage mit HTTP unsicher! + - SSL-Zertifikat erforderlich + +3. **Token-Refresh** + - Aktuell: 24h Gültigkeit + - Optional: Refresh-Token-Mechanismus + +4. **E-Mail-Versand** + - Aktuell: Nur DEV-Mode + - Produktion: SMTP konfigurieren (nodemailer) + +5. **Rate Limiting** + - Login-Endpunkt limitieren + - express-rate-limit verwenden + +## Zusammenfassung + +Die Auth-Implementierung ist **produktionsbereit** und bietet: + +✅ Sichere Passwort-Speicherung (bcrypt) +✅ JWT-basierte Authentifizierung +✅ Persistente Sessions (localStorage) +✅ Auto-Logout bei ungültigen Tokens +✅ Passwort-Reset-Funktionalität +✅ Router Guards +✅ Moderne, responsive UI + +**Die Session funktioniert auch nach Reload/neuer Seite!** 🎉 + diff --git a/OAUTH_SETUP.md b/OAUTH_SETUP.md new file mode 100644 index 0000000..d284690 --- /dev/null +++ b/OAUTH_SETUP.md @@ -0,0 +1,345 @@ +# Google OAuth Setup für TimeClock + +## Übersicht + +TimeClock v3 unterstützt Login mit Google Account über OAuth 2.0. + +## Google Cloud Console Setup + +### 1. Google Cloud Projekt erstellen + +1. Gehen Sie zu: https://console.cloud.google.com +2. Klicken Sie auf "Projekt auswählen" → "Neues Projekt" +3. Projektname: `TimeClock` +4. Klicken Sie auf "Erstellen" + +### 2. OAuth Consent Screen konfigurieren + +1. Navigieren Sie zu: **APIs & Services** → **OAuth consent screen** +2. Wählen Sie **External** (oder Internal für Google Workspace) +3. Klicken Sie auf "Erstellen" + +**App-Informationen:** +- App name: `TimeClock` +- User support email: Ihre E-Mail +- Developer contact: Ihre E-Mail + +4. Klicken Sie auf "Speichern und fortfahren" + +**Scopes:** +- Fügen Sie hinzu: `email`, `profile` +- Klicken Sie auf "Speichern und fortfahren" + +**Test users** (für Development): +- Fügen Sie Ihre Google-E-Mail hinzu +- Klicken Sie auf "Speichern und fortfahren" + +### 3. OAuth 2.0 Credentials erstellen + +1. Navigieren Sie zu: **APIs & Services** → **Credentials** +2. Klicken Sie auf **+ CREATE CREDENTIALS** → **OAuth client ID** + +**Application type:** Web application + +**Name:** TimeClock Web App + +**Authorized JavaScript origins:** +``` +http://localhost:5010 +http://localhost:3010 +``` + +**Authorized redirect URIs:** +``` +http://localhost:3010/api/auth/google/callback +``` + +4. Klicken Sie auf "Erstellen" +5. **Wichtig:** Kopieren Sie: + - Client ID + - Client Secret + +### 4. Backend konfigurieren + +Bearbeiten Sie `/backend/.env`: + +```env +# Google OAuth 2.0 +GOOGLE_CLIENT_ID=ihre-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=GOCSPX-ihr-client-secret +GOOGLE_CALLBACK_URL=http://localhost:3010/api/auth/google/callback +``` + +### 5. Backend neu starten + +```bash +# Backend stoppt und neu startet automatisch (nodemon) +# Oder manuell: +cd backend +npm run dev +``` + +**Erwartete Ausgabe:** +``` +✅ Google OAuth konfiguriert +``` + +Falls Sie sehen: +``` +⚠️ Google OAuth nicht konfiguriert (GOOGLE_CLIENT_ID/SECRET fehlen) +``` + +Dann fehlen die Credentials in der `.env` Datei. + +## Verwendung + +### Frontend - Login-Button + +Auf der Login-Seite erscheint automatisch: + +``` +┌─────────────────────────────┐ +│ Mit Google anmelden │ +└─────────────────────────────┘ +``` + +**Workflow:** +1. Benutzer klickt auf "Mit Google anmelden" +2. Redirect zu Google OAuth +3. Benutzer wählt Google-Account +4. Google Callback → Backend +5. Backend erstellt/findet Benutzer +6. Redirect zu Frontend mit Token +7. Automatischer Login ✅ + +### Backend - API-Endpunkte + +```bash +# OAuth Flow starten +GET /api/auth/google + +# OAuth Callback (von Google aufgerufen) +GET /api/auth/google/callback + +# OAuth-Identities abrufen (geschützt) +GET /api/auth/identities +Authorization: Bearer + +# OAuth-Provider entfernen (geschützt) +DELETE /api/auth/identity/:provider +Authorization: Bearer +``` + +## Datenbank-Struktur + +### auth_identity Tabelle + +```sql +CREATE TABLE `auth_identity` ( + `id` bigint PRIMARY KEY AUTO_INCREMENT, + `version` int NOT NULL DEFAULT 0, + `auth_info_id` bigint, + `provider` varchar(64) NOT NULL, -- 'google', 'github', etc. + `identity` varchar(512) NOT NULL, -- Google User ID + UNIQUE KEY (`provider`, `identity`) +); +``` + +**Beispiel-Daten:** +``` +id | auth_info_id | provider | identity +1 | 5 | google | 123456789012345678901 +``` + +## Szenarien + +### Szenario 1: Neuer Benutzer mit Google + +1. Benutzer klickt "Mit Google anmelden" +2. Google OAuth Flow +3. **Neuer Benutzer wird erstellt:** + - `user` Tabelle: Name aus Google-Profil + - `auth_info` Tabelle: E-Mail, kein Passwort + - `auth_identity` Tabelle: Google ID +4. JWT Token wird generiert +5. Benutzer ist eingeloggt + +### Szenario 2: Bestehender Benutzer (E-Mail-Match) + +1. Benutzer hat Account mit `test@gmail.com` +2. Login mit Google (`test@gmail.com`) +3. **Accounts werden verknüpft:** + - `auth_identity` wird hinzugefügt + - Benutzer kann jetzt mit Passwort ODER Google einloggen + +### Szenario 3: Mehrere OAuth-Provider + +Ein Benutzer kann mehrere OAuth-Provider verknüpfen: + +``` +auth_identity: +id | auth_info_id | provider | identity +1 | 5 | google | google_id_123 +2 | 5 | github | github_id_456 +``` + +Login möglich mit: +- ✅ E-Mail + Passwort +- ✅ Google Account +- ✅ GitHub Account + +## Sicherheit + +### Token-Handling + +- OAuth Access-Token wird NICHT gespeichert +- Nur Provider-ID in `auth_identity` +- JWT Token wie bei normalem Login +- Gleiche 24h Gültigkeit + +### Account-Verknüpfung + +- E-Mail-Match automatisch +- Verhindert doppelte Accounts +- Sicher durch unique constraint + +### Rückgängig machen + +Benutzer kann OAuth-Verknüpfung entfernen: + +```bash +DELETE /api/auth/identity/google +Authorization: Bearer +``` + +## Produktion + +### 1. Google Cloud Console + +**Authorized JavaScript origins:** +``` +https://ihre-domain.de +``` + +**Authorized redirect URIs:** +``` +https://ihre-domain.de/api/auth/google/callback +``` + +### 2. .env für Produktion + +```env +NODE_ENV=production +FRONTEND_URL=https://ihre-domain.de + +GOOGLE_CLIENT_ID=ihre-prod-client-id +GOOGLE_CLIENT_SECRET=ihr-prod-secret +GOOGLE_CALLBACK_URL=https://ihre-domain.de/api/auth/google/callback +``` + +### 3. HTTPS erforderlich + +⚠️ **Google OAuth erfordert HTTPS in Produktion!** + +## Weitere OAuth-Provider + +### GitHub + +```javascript +// backend/src/config/passport.js +const GitHubStrategy = require('passport-github2').Strategy; + +passport.use(new GitHubStrategy({ + clientID: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + callbackURL: process.env.GITHUB_CALLBACK_URL +}, async (accessToken, refreshToken, profile, done) => { + const result = await oauthService.authenticateWithProvider(profile, 'github'); + done(null, result); +})); +``` + +### Microsoft + +```javascript +const MicrosoftStrategy = require('passport-microsoft').Strategy; +// ... ähnlich wie Google +``` + +## Troubleshooting + +### "Google OAuth nicht konfiguriert" + +- Prüfen Sie `.env` Datei +- `GOOGLE_CLIENT_ID` und `GOOGLE_CLIENT_SECRET` gesetzt? +- Backend neu starten + +### "redirect_uri_mismatch" + +- Callback-URL in Google Console prüfen +- Muss EXAKT übereinstimmen mit Backend-URL +- http vs https beachten + +### "access_denied" + +- Benutzer hat OAuth abgelehnt +- Test-User in Google Console hinzufügen (Development) + +### OAuth-Button erscheint nicht + +- Frontend HMR reload: `Ctrl+R` +- Browser-Cache leeren +- Prüfen Sie Console auf Fehler + +## Testing + +### 1. Lokales Testing + +```bash +# Backend +curl http://localhost:3010/api/auth/google +# → Sollte zu Google redirecten + +# Nach Callback +curl http://localhost:3010/api/auth/me \ + -H "Authorization: Bearer " +``` + +### 2. Manuelle Verknüpfung testen + +```bash +# Als eingeloggter Benutzer +curl http://localhost:3010/api/auth/identities \ + -H "Authorization: Bearer " + +# Response: +{ + "success": true, + "identities": [ + { + "provider": "google", + "identity": "123456789012345678901", + "id": 1 + } + ] +} +``` + +## Zusammenfassung + +✅ **Implementiert:** +- Google OAuth 2.0 Login +- Automatische Account-Verknüpfung +- `auth_identity` Tabelle Nutzung +- Mehrere OAuth-Provider möglich +- Sicheres Token-Handling + +✅ **Ready für:** +- Google Login +- GitHub Login (vorbereitet) +- Microsoft Login (vorbereitet) + +🚀 **Einfach zu erweitern** für weitere Provider! + + + diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..3b00c02 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,181 @@ +# TimeClock - Schnellstart-Anleitung + +## 🚀 In 5 Minuten zur laufenden Anwendung + +### Schritt 1: MySQL Setup (Einmalig) + +```bash +cd /home/torsten/Programs/TimeClock/backend +./setup-mysql.sh +``` + +**Das Script fragt:** +- MySQL Root-Passwort +- Gewünschter DB-Benutzer (Standard: `timeclock_user`) +- Gewünschtes Passwort (Standard: `timeclock_dev_2025`) + +**Das Script erstellt:** +- ✅ Datenbank `stechuhr2` +- ✅ MySQL-Benutzer mit Berechtigungen +- ✅ `.env` Datei mit Credentials + +--- + +### Schritt 2: Server starten + +```bash +cd /home/torsten/Programs/TimeClock +npm run dev +``` + +**Erwartete Ausgabe:** +``` +[0] ✅ Sequelize: MySQL-Datenbankverbindung hergestellt +[0] ✅ Sequelize: Models initialisiert +[0] 🕐 TimeClock Server läuft auf Port 3010 +[1] ➜ Local: http://localhost:5010/ +``` + +--- + +### Schritt 3: Ersten Account erstellen + +1. Browser öffnen: **http://localhost:5010** +2. Klick auf **"Registrieren"** +3. Formular ausfüllen: + - Name: `Ihr Name` + - E-Mail: `ihre@email.de` + - Passwort: `minimum6zeichen` +4. **"Registrieren"** klicken +5. Zum **Login** weitergeleitet +6. Mit den Credentials einloggen + +--- + +### Schritt 4: Timer starten + +1. Im Dashboard: + - Projekt: `Mein erstes Projekt` + - Beschreibung: `Testing TimeClock` +2. **"Starten"** klicken +3. Timer läuft! ⏱️ +4. **"Stoppen"** wenn fertig + +--- + +## 🔧 Manuelle MySQL-Einrichtung + +Falls Sie das Script nicht verwenden möchten: + +### 1. MySQL einloggen +```bash +mysql -u root -p +``` + +### 2. Datenbank und Benutzer erstellen +```sql +CREATE DATABASE stechuhr2 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'timeclock_user'@'localhost' IDENTIFIED BY 'IhrPasswort'; +GRANT ALL PRIVILEGES ON stechuhr2.* TO 'timeclock_user'@'localhost'; +FLUSH PRIVILEGES; +EXIT; +``` + +### 3. .env Datei bearbeiten +```bash +cd /home/torsten/Programs/TimeClock/backend +nano .env +``` + +Setzen Sie: +```env +DB_USER=timeclock_user +DB_PASSWORD=IhrPasswort +DB_NAME=stechuhr2 +``` + +--- + +## 🆘 Troubleshooting + +### "Access denied for user" +```bash +# Berechtigungen prüfen +mysql -u root -p +SHOW GRANTS FOR 'timeclock_user'@'localhost'; +``` + +### "Can't connect to MySQL server" +```bash +# MySQL starten +sudo systemctl start mysql +sudo systemctl status mysql +``` + +### "Unknown database stechuhr2" +```bash +# Datenbank erstellen +mysql -u root -p +CREATE DATABASE stechuhr2; +``` + +### Port bereits belegt +```bash +# Ports prüfen +sudo ss -tlnp | grep -E "3010|5010" + +# Prozesse beenden falls nötig +pkill -f "TimeClock" +``` + +--- + +## 📊 Features testen + +### ✅ Registrierung & Login +- `/register` - Neuen Account erstellen +- `/login` - Einloggen +- Session bleibt nach Reload erhalten + +### ✅ Timer-Funktionen +- Dashboard: Timer starten/stoppen +- Einträge: Alle Zeiteinträge anzeigen +- Statistiken: Auswertungen nach Projekt + +### ✅ Passwort-Reset +- `/password-forgot` - E-Mail eingeben +- In DEV-Mode wird Reset-Token angezeigt +- `/password-reset?token=XXX` - Neues Passwort setzen + +--- + +## 🎯 Nächste Schritte + +1. **Mehrere Projekte** anlegen +2. **Statistiken** ansehen +3. **Passwort ändern** im Profil +4. **Weitere Benutzer** registrieren + +--- + +## 📖 Weitere Dokumentation + +- `README.md` - Vollständige Projektdokumentation +- `SETUP.md` - Detaillierte Setup-Anleitung +- `AUTH.md` - Authentifizierung & Session-Management +- `backend/MYSQL_SETUP.md` - MySQL-Detailanleitung +- `backend/DATABASE.md` - Datenbank-Schema +- `backend/ARCHITECTURE.md` - Backend-Architektur + +--- + +## 🎉 Fertig! + +Ihre TimeClock v3 Anwendung läuft jetzt auf: +- **Frontend:** http://localhost:5010 +- **Backend API:** http://localhost:3010/api + +Viel Erfolg mit der Zeiterfassung! 🕐 + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd89b41 --- /dev/null +++ b/README.md @@ -0,0 +1,218 @@ +# TimeClock v3.0 + +🕐 Eine moderne Full-Stack-Zeiterfassungsanwendung mit Node.js und Vue 3. + +## Features + +- ⏱️ **Echtzeit-Zeiterfassung** - Starten und stoppen Sie Timer mit einem Klick +- 📊 **Detaillierte Statistiken** - Verfolgen Sie Ihre Zeit nach Projekten +- 💼 **Projekt-Management** - Organisieren Sie Ihre Zeiteinträge nach Projekten +- 🎨 **Moderne UI** - Schöne und intuitive Benutzeroberfläche +- 📱 **Responsive Design** - Funktioniert auf Desktop und Mobile + +## Technologie-Stack + +### Backend +- **Node.js** mit Express.js +- **MySQL-Datenbank** (stechuhr2) +- RESTful API mit klassenbasierter Architektur +- Repository-Pattern für saubere Datenbankabstraktion +- Service-Layer für Business-Logik +- CORS & Helmet für Sicherheit +- Morgan für Logging + +### Frontend +- **Vue 3** mit Composition API +- **Vite** als Build-Tool +- **Vue Router** für Navigation +- **Pinia** für State Management +- Moderne CSS mit Gradients + +## Installation + +### Voraussetzungen +- Node.js (v16 oder höher) +- npm oder yarn + +### Backend Setup + +```bash +cd backend +npm install + +# .env Datei erstellen und konfigurieren +cp .env.example .env +# Bearbeiten Sie .env mit Ihren MySQL-Zugangsdaten + +npm run dev +``` + +Der Backend-Server läuft standardmäßig auf `http://localhost:3010` + +**Wichtig:** Siehe `backend/DB_SETUP.md` für detaillierte Datenbank-Konfiguration! + +### Frontend Setup + +```bash +cd frontend +npm install +npm run dev +``` + +Das Frontend läuft standardmäßig auf `http://localhost:5010` + +## Verwendung + +1. Starten Sie zuerst den Backend-Server +2. Starten Sie dann den Frontend-Development-Server +3. Öffnen Sie `http://localhost:5010` in Ihrem Browser + +### Dashboard +- Starten Sie einen neuen Timer mit Projekt und Beschreibung +- Sehen Sie die aktuelle Laufzeit in Echtzeit +- Stoppen Sie den Timer, wenn Sie fertig sind +- Sehen Sie Ihre letzten 5 Einträge + +### Einträge +- Vollständige Liste aller Zeiteinträge +- Filtern nach Status (laufend/beendet) +- Löschen von Einträgen + +### Statistiken +- Gesamtanzahl der Einträge +- Gesamtarbeitsstunden +- Aufschlüsselung nach Projekten +- Detaillierte Projekt-Statistiken + +## API-Endpunkte + +### GET /api/health +Health-Check des Servers + +### GET /api/time-entries +Alle Zeiteinträge abrufen + +### POST /api/time-entries +Neuen Zeiteintrag erstellen (Timer starten) + +```json +{ + "project": "Projektname", + "description": "Beschreibung der Tätigkeit" +} +``` + +### PUT /api/time-entries/:id +Zeiteintrag aktualisieren (Timer stoppen) + +```json +{ + "endTime": "2025-10-15T10:30:00.000Z" +} +``` + +### DELETE /api/time-entries/:id +Zeiteintrag löschen + +### GET /api/time-entries/stats/summary +Statistiken abrufen + +## Projektstruktur + +``` +TimeClock/ +├── backend/ +│ ├── src/ +│ │ ├── config/ +│ │ │ └── database.js +│ │ ├── controllers/ +│ │ │ └── TimeEntryController.js +│ │ ├── services/ +│ │ │ └── TimeEntryService.js +│ │ ├── repositories/ +│ │ │ ├── WorklogRepository.js +│ │ │ └── UserRepository.js +│ │ ├── models/ +│ │ │ └── TimeEntry.js +│ │ ├── routes/ +│ │ │ └── timeEntries.js +│ │ └── index.js +│ ├── ARCHITECTURE.md +│ ├── DATABASE.md +│ ├── DB_SETUP.md +│ └── package.json +│ +└── frontend/ + ├── src/ + │ ├── assets/ + │ │ └── main.css + │ ├── components/ + │ ├── router/ + │ │ └── index.js + │ ├── stores/ + │ │ └── timeStore.js + │ ├── views/ + │ │ ├── Dashboard.vue + │ │ ├── Entries.vue + │ │ └── Stats.vue + │ ├── App.vue + │ └── main.js + ├── index.html + ├── package.json + └── vite.config.js +``` + +## Entwicklung + +### Backend-Entwicklung +```bash +cd backend +npm run dev # Startet mit nodemon für Auto-Reload +``` + +### Frontend-Entwicklung +```bash +cd frontend +npm run dev # Startet Vite Dev-Server mit HMR +``` + +### Build für Produktion + +**Frontend:** +```bash +cd frontend +npm run build +``` + +Die Build-Dateien befinden sich dann im `dist/` Ordner. + +## Implementierte Features + +- ✅ **MySQL-Datenbank** - Integration mit bestehender stechuhr2-Datenbank +- ✅ **Repository-Pattern** - Saubere Datenbankabstraktion +- ✅ **Service-Layer** - Klare Trennung von Business-Logik +- ✅ **Klassenbasierte Architektur** - Controller und Services als Klassen + +## Geplante Erweiterungen + +- 👤 **Benutzer-Authentifizierung** - JWT-basiertes Login-System +- 🔐 **Multi-User Support** - Mehrere Benutzer mit Berechtigungen +- 📧 **E-Mail-Berichte** - Automatische Zeitberichte per E-Mail +- 📱 **Mobile App** - React Native oder Flutter App +- 📈 **Erweiterte Analytics** - Grafiken und Diagramme +- 🌐 **Mehrsprachigkeit** - i18n Integration +- 🔔 **Benachrichtigungen** - Push-Notifications +- 📤 **Export-Funktionen** - CSV, PDF Export + +## Lizenz + +ISC + +## Autor + +TimeClock v3 - 2025 + +## Support + +Bei Fragen oder Problemen öffnen Sie bitte ein Issue im Repository. + diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..ddd530a --- /dev/null +++ b/SETUP.md @@ -0,0 +1,173 @@ +# TimeClock v3 - Schnellstart-Anleitung + +## Schritt-für-Schritt Installation + +### 1. Voraussetzungen prüfen + +Stellen Sie sicher, dass Node.js installiert ist: + +```bash +node --version # sollte v16 oder höher sein +npm --version +``` + +### 2. Abhängigkeiten installieren + +#### Option A: Alles auf einmal (empfohlen) +```bash +npm run install:all +``` + +#### Option B: Manuell +```bash +# Root-Abhängigkeiten +npm install + +# Backend-Abhängigkeiten +cd backend +npm install + +# Frontend-Abhängigkeiten +cd ../frontend +npm install +``` + +### 3. Backend-Konfiguration + +Erstellen Sie eine `.env` Datei im `backend/` Verzeichnis: + +```bash +cd backend +cp .env.example .env +``` + +Standardwerte in `.env`: +```env +PORT=3000 +NODE_ENV=development +``` + +### 4. Anwendung starten + +#### Option A: Beide Server gleichzeitig (empfohlen) +```bash +# Im Root-Verzeichnis +npm run dev +``` + +Dies startet: +- Backend auf `http://localhost:3010` +- Frontend auf `http://localhost:5010` + +#### Option B: Server einzeln starten + +**Terminal 1 - Backend:** +```bash +cd backend +npm run dev +``` + +**Terminal 2 - Frontend:** +```bash +cd frontend +npm run dev +``` + +### 5. Anwendung öffnen + +Öffnen Sie Ihren Browser und navigieren Sie zu: +``` +http://localhost:5010 +``` + +## Erste Schritte + +1. **Timer starten** + - Geben Sie ein Projekt ein (z.B. "Website-Entwicklung") + - Optional: Fügen Sie eine Beschreibung hinzu + - Klicken Sie auf "Starten" + +2. **Timer läuft** + - Die Zeit wird in Echtzeit angezeigt + - Sie können den Timer jederzeit stoppen + +3. **Einträge ansehen** + - Navigieren Sie zu "Einträge" + - Sehen Sie alle Ihre Zeiteinträge + - Löschen Sie Einträge bei Bedarf + +4. **Statistiken** + - Navigieren Sie zu "Statistiken" + - Sehen Sie Ihre Gesamtarbeitszeit + - Überblick nach Projekten + +## Fehlerbehebung + +### Port bereits in Verwendung + +**Backend (Port 3010):** +```bash +# Linux/Mac +lsof -ti:3010 | xargs kill -9 + +# Windows +netstat -ano | findstr :3010 +taskkill /PID /F +``` + +**Frontend (Port 5010):** +```bash +# Linux/Mac +lsof -ti:5010 | xargs kill -9 + +# Windows +netstat -ano | findstr :5010 +taskkill /PID /F +``` + +### API-Verbindungsfehler + +Stellen Sie sicher, dass: +1. Das Backend auf Port 3010 läuft +2. Das Frontend auf Port 5010 läuft +3. Keine Firewall die Verbindung blockiert + +### Module nicht gefunden + +```bash +# Alle node_modules löschen und neu installieren +rm -rf node_modules backend/node_modules frontend/node_modules +npm run install:all +``` + +## Produktion + +### Frontend bauen +```bash +cd frontend +npm run build +``` + +Die fertigen Dateien befinden sich in `frontend/dist/` + +### Backend für Produktion +```bash +cd backend +NODE_ENV=production npm start +``` + +### Frontend für Produktion bereitstellen + +Die gebauten Dateien aus `frontend/dist/` können auf jedem Webserver bereitgestellt werden: +- Nginx +- Apache +- Netlify +- Vercel +- GitHub Pages + +## Nächste Schritte + +- Lesen Sie die vollständige [README.md](README.md) +- Sehen Sie sich die [API-Dokumentation](backend/README.md) an +- Passen Sie die Anwendung nach Ihren Bedürfnissen an + diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..b8920e3 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.env +*.log +.DS_Store +dist/ +coverage/ + + + diff --git a/backend/ARCHITECTURE.md b/backend/ARCHITECTURE.md new file mode 100644 index 0000000..6a2808e --- /dev/null +++ b/backend/ARCHITECTURE.md @@ -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! 🚀 + + + diff --git a/backend/DATABASE.md b/backend/DATABASE.md new file mode 100644 index 0000000..04c5498 --- /dev/null +++ b/backend/DATABASE.md @@ -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 + + + diff --git a/backend/DB_SETUP.md b/backend/DB_SETUP.md new file mode 100644 index 0000000..4b0aa4f --- /dev/null +++ b/backend/DB_SETUP.md @@ -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 + diff --git a/backend/ID_HASHING.md b/backend/ID_HASHING.md new file mode 100644 index 0000000..8ec6e10 --- /dev/null +++ b/backend/ID_HASHING.md @@ -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 + + + diff --git a/backend/MYSQL_SETUP.md b/backend/MYSQL_SETUP.md new file mode 100644 index 0000000..4d35321 --- /dev/null +++ b/backend/MYSQL_SETUP.md @@ -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 +``` + + + diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..651ad2e --- /dev/null +++ b/backend/README.md @@ -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 + diff --git a/backend/SEQUELIZE.md b/backend/SEQUELIZE.md new file mode 100644 index 0000000..6938773 --- /dev/null +++ b/backend/SEQUELIZE.md @@ -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! + + + diff --git a/backend/add-missing-wednesday.sql b/backend/add-missing-wednesday.sql new file mode 100644 index 0000000..661e21e --- /dev/null +++ b/backend/add-missing-wednesday.sql @@ -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; + diff --git a/backend/database-schema.sql b/backend/database-schema.sql new file mode 100644 index 0000000..cad659e --- /dev/null +++ b/backend/database-schema.sql @@ -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; + diff --git a/backend/fix-timezone-correct.js b/backend/fix-timezone-correct.js new file mode 100644 index 0000000..47f0f8d --- /dev/null +++ b/backend/fix-timezone-correct.js @@ -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); +}); + + + diff --git a/backend/fix-timezone.js b/backend/fix-timezone.js new file mode 100644 index 0000000..31a813d --- /dev/null +++ b/backend/fix-timezone.js @@ -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); +}); diff --git a/backend/fix-worklog-timezone.sql b/backend/fix-worklog-timezone.sql new file mode 100644 index 0000000..76bad79 --- /dev/null +++ b/backend/fix-worklog-timezone.sql @@ -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; + + + diff --git a/backend/migrate-timewish-simple.sql b/backend/migrate-timewish-simple.sql new file mode 100644 index 0000000..826adbd --- /dev/null +++ b/backend/migrate-timewish-simple.sql @@ -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; + + + diff --git a/backend/migrate-timewish.sql b/backend/migrate-timewish.sql new file mode 100644 index 0000000..f0f9002 --- /dev/null +++ b/backend/migrate-timewish.sql @@ -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; + + + diff --git a/backend/nodemon.json b/backend/nodemon.json new file mode 100644 index 0000000..09f7545 --- /dev/null +++ b/backend/nodemon.json @@ -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 +} + + + diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..62bd5db --- /dev/null +++ b/backend/package.json @@ -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" + } +} + diff --git a/backend/replace-thursday-data.sql b/backend/replace-thursday-data.sql new file mode 100644 index 0000000..ad9ef6d --- /dev/null +++ b/backend/replace-thursday-data.sql @@ -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; + diff --git a/backend/rollback-timezone.js b/backend/rollback-timezone.js new file mode 100644 index 0000000..5410518 --- /dev/null +++ b/backend/rollback-timezone.js @@ -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); +}); + + + diff --git a/backend/setup-mysql.sh b/backend/setup-mysql.sh new file mode 100755 index 0000000..02d945c --- /dev/null +++ b/backend/setup-mysql.sh @@ -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" < "$ENV_FILE" < /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 "" + + + diff --git a/backend/setup-timewish-complete.sql b/backend/setup-timewish-complete.sql new file mode 100644 index 0000000..884c358 --- /dev/null +++ b/backend/setup-timewish-complete.sql @@ -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; + + + diff --git a/backend/src/config/database.js b/backend/src/config/database.js new file mode 100644 index 0000000..a154a39 --- /dev/null +++ b/backend/src/config/database.js @@ -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(); diff --git a/backend/src/config/passport.js b/backend/src/config/passport.js new file mode 100644 index 0000000..e71d105 --- /dev/null +++ b/backend/src/config/passport.js @@ -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; + + + diff --git a/backend/src/controllers/AuthController.js b/backend/src/controllers/AuthController.js new file mode 100644 index 0000000..ff48451 --- /dev/null +++ b/backend/src/controllers/AuthController.js @@ -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(); + diff --git a/backend/src/controllers/OAuthController.js b/backend/src/controllers/OAuthController.js new file mode 100644 index 0000000..f209fdc --- /dev/null +++ b/backend/src/controllers/OAuthController.js @@ -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(); + + + diff --git a/backend/src/controllers/TimeEntryController.js b/backend/src/controllers/TimeEntryController.js new file mode 100644 index 0000000..4fb97a0 --- /dev/null +++ b/backend/src/controllers/TimeEntryController.js @@ -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(); + diff --git a/backend/src/controllers/WeekOverviewController.js b/backend/src/controllers/WeekOverviewController.js new file mode 100644 index 0000000..aa49a31 --- /dev/null +++ b/backend/src/controllers/WeekOverviewController.js @@ -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(); diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..20c51de --- /dev/null +++ b/backend/src/index.js @@ -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; + diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..ca2cd43 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -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 +}; + + + diff --git a/backend/src/middleware/hashResponse.js b/backend/src/middleware/hashResponse.js new file mode 100644 index 0000000..b3aacda --- /dev/null +++ b/backend/src/middleware/hashResponse.js @@ -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; + + + diff --git a/backend/src/middleware/unhashRequest.js b/backend/src/middleware/unhashRequest.js new file mode 100644 index 0000000..fc3ab8f --- /dev/null +++ b/backend/src/middleware/unhashRequest.js @@ -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; + + + diff --git a/backend/src/models/AuthIdentity.js b/backend/src/models/AuthIdentity.js new file mode 100644 index 0000000..679fee9 --- /dev/null +++ b/backend/src/models/AuthIdentity.js @@ -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; + + + diff --git a/backend/src/models/AuthInfo.js b/backend/src/models/AuthInfo.js new file mode 100644 index 0000000..78f193c --- /dev/null +++ b/backend/src/models/AuthInfo.js @@ -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; + diff --git a/backend/src/models/AuthToken.js b/backend/src/models/AuthToken.js new file mode 100644 index 0000000..4e54a72 --- /dev/null +++ b/backend/src/models/AuthToken.js @@ -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; + + + diff --git a/backend/src/models/Holiday.js b/backend/src/models/Holiday.js new file mode 100644 index 0000000..71d6946 --- /dev/null +++ b/backend/src/models/Holiday.js @@ -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; + + + diff --git a/backend/src/models/Sick.js b/backend/src/models/Sick.js new file mode 100644 index 0000000..77dbd1d --- /dev/null +++ b/backend/src/models/Sick.js @@ -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; + + + diff --git a/backend/src/models/SickType.js b/backend/src/models/SickType.js new file mode 100644 index 0000000..c2f8958 --- /dev/null +++ b/backend/src/models/SickType.js @@ -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; + + + diff --git a/backend/src/models/State.js b/backend/src/models/State.js new file mode 100644 index 0000000..6070f99 --- /dev/null +++ b/backend/src/models/State.js @@ -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; + + + diff --git a/backend/src/models/Timefix.js b/backend/src/models/Timefix.js new file mode 100644 index 0000000..879a20e --- /dev/null +++ b/backend/src/models/Timefix.js @@ -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; + + + diff --git a/backend/src/models/Timewish.js b/backend/src/models/Timewish.js new file mode 100644 index 0000000..367658e --- /dev/null +++ b/backend/src/models/Timewish.js @@ -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; + diff --git a/backend/src/models/User.js b/backend/src/models/User.js new file mode 100644 index 0000000..d30a1c8 --- /dev/null +++ b/backend/src/models/User.js @@ -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; + diff --git a/backend/src/models/Vacation.js b/backend/src/models/Vacation.js new file mode 100644 index 0000000..6d49cd5 --- /dev/null +++ b/backend/src/models/Vacation.js @@ -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; + + + diff --git a/backend/src/models/WeeklyWorktime.js b/backend/src/models/WeeklyWorktime.js new file mode 100644 index 0000000..694fbd7 --- /dev/null +++ b/backend/src/models/WeeklyWorktime.js @@ -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; + + + diff --git a/backend/src/models/Worklog.js b/backend/src/models/Worklog.js new file mode 100644 index 0000000..b132f45 --- /dev/null +++ b/backend/src/models/Worklog.js @@ -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; + diff --git a/backend/src/models/index.js b/backend/src/models/index.js new file mode 100644 index 0000000..c491ed8 --- /dev/null +++ b/backend/src/models/index.js @@ -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 +}; + diff --git a/backend/src/repositories/UserRepository.js b/backend/src/repositories/UserRepository.js new file mode 100644 index 0000000..f657093 --- /dev/null +++ b/backend/src/repositories/UserRepository.js @@ -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} 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} 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} 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} 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} 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} 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} 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(); diff --git a/backend/src/repositories/WorklogRepository.js b/backend/src/repositories/WorklogRepository.js new file mode 100644 index 0000000..f567bbc --- /dev/null +++ b/backend/src/repositories/WorklogRepository.js @@ -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} 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} 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} 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} 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} 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} 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 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} 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 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 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} worklogIds - Array von Worklog-IDs + * @returns {Promise} 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 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} 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} 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} 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} 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 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(); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..6b5ad8c --- /dev/null +++ b/backend/src/routes/auth.js @@ -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; + diff --git a/backend/src/routes/timeEntries.js b/backend/src/routes/timeEntries.js new file mode 100644 index 0000000..ce8f4a0 --- /dev/null +++ b/backend/src/routes/timeEntries.js @@ -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; + diff --git a/backend/src/routes/weekOverview.js b/backend/src/routes/weekOverview.js new file mode 100644 index 0000000..ec296cb --- /dev/null +++ b/backend/src/routes/weekOverview.js @@ -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; + + diff --git a/backend/src/services/AuthService.js b/backend/src/services/AuthService.js new file mode 100644 index 0000000..9568abf --- /dev/null +++ b/backend/src/services/AuthService.js @@ -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} 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} 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} 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} 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} 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} 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} 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} 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(); + diff --git a/backend/src/services/OAuthService.js b/backend/src/services/OAuthService.js new file mode 100644 index 0000000..dc478e6 --- /dev/null +++ b/backend/src/services/OAuthService.js @@ -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} 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} 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} 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(); + + + diff --git a/backend/src/services/TimeEntryService.js b/backend/src/services/TimeEntryService.js new file mode 100644 index 0000000..a1b59be --- /dev/null +++ b/backend/src/services/TimeEntryService.js @@ -0,0 +1,2380 @@ +const worklogRepository = require('../repositories/WorklogRepository'); +const userRepository = require('../repositories/UserRepository'); +const database = require('../config/database'); + +/** + * Service-Klasse für Zeiteinträge + * Enthält die gesamte Business-Logik + * Verwendet das Repository-Pattern für Datenbankzugriff + */ +class TimeEntryService { + constructor() { + // Default-Benutzer-ID (kann später durch Auth ersetzt werden) + this.defaultUserId = 1; + } + + /** + * Benutzer-ID setzen (für zukünftige Auth-Integration) + * @param {number} userId - ID des aktuellen Benutzers + */ + setCurrentUser(userId) { + this.defaultUserId = userId; + } + + /** + * Alle Zeiteinträge abrufen + * @param {number} userId - Benutzer-ID (optional) + * @returns {Promise} Liste der Zeiteinträge + */ + async getAllEntries(userId = null) { + const uid = userId || this.defaultUserId; + const pairs = await worklogRepository.findPairsByUser(uid); + const safePairs = Array.isArray(pairs) ? pairs : []; + + return safePairs.map(pair => this._mapWorklogPairToTimeEntry(pair)); + } + + /** + * Einzelnen Zeiteintrag anhand der ID abrufen + * @param {number} id - Die ID des Zeiteintrags (start_id) + * @returns {Promise} Der gefundene Zeiteintrag oder null + */ + async getEntryById(id) { + const worklog = await worklogRepository.findById(id); + if (!worklog) return null; + + // Wenn es ein Start-Eintrag ist, zugehörigen End-Eintrag suchen + if (worklog.relatedTo_id === null) { + const pairs = await worklogRepository.findPairsByUser(worklog.user_id); + const safePairs = Array.isArray(pairs) ? pairs : []; + const pair = safePairs.find(p => p.start_id === parseInt(id)); + + if (pair) { + return this._mapWorklogPairToTimeEntry(pair); + } + } + + return null; + } + + /** + * Neuen Zeiteintrag erstellen (Timer starten) + * @param {Object} entryData - Daten für den neuen Eintrag + * @param {string} entryData.description - Beschreibung der Tätigkeit + * @param {string} entryData.project - Projektname + * @param {number} entryData.userId - Benutzer-ID (optional) + * @returns {Promise} Der erstellte Zeiteintrag + * @throws {Error} Bei ungültigen Eingabedaten + */ + async createEntry(entryData) { + const userId = entryData.userId || this.defaultUserId; + const { description = '', project = 'Allgemein' } = entryData; + + // Validierung: Prüfen ob bereits ein Timer läuft + const runningEntry = await worklogRepository.findRunningByUser(userId); + if (runningEntry) { + throw new Error('Es läuft bereits ein Timer. Bitte stoppen Sie diesen zuerst.'); + } + + // State-String mit zusätzlichen Informationen + const state = JSON.stringify({ + action: 'Clock In', + project, + description + }); + + // Worklog-Eintrag erstellen + const worklog = await worklogRepository.create({ + user_id: userId, + state, + tstamp: new Date(), + relatedTo_id: null + }); + + // Als TimeEntry-Format zurückgeben + return { + id: worklog.id, + startTime: worklog.tstamp, + endTime: null, + description, + project, + duration: null, + isRunning: true, + userId: worklog.user_id + }; + } + + /** + * Zeiteintrag aktualisieren (Timer stoppen oder Daten ändern) + * @param {number} id - Die ID des zu aktualisierenden Eintrags + * @param {Object} updateData - Zu aktualisierende Daten + * @param {string} updateData.endTime - Endzeit (ISO 8601) + * @param {string} updateData.description - Neue Beschreibung + * @param {string} updateData.project - Neuer Projektname + * @returns {Promise} Der aktualisierte Zeiteintrag + * @throws {Error} Wenn der Eintrag nicht gefunden wurde + */ + async updateEntry(id, updateData) { + const startWorklog = await worklogRepository.findById(id); + + if (!startWorklog) { + throw new Error(`Eintrag mit ID ${id} nicht gefunden`); + } + + // State-Daten extrahieren + let stateData; + try { + stateData = JSON.parse(startWorklog.state); + } catch { + stateData = { action: 'Clock In', project: 'Allgemein', description: '' }; + } + + const { endTime, description, project } = updateData; + + // Timer stoppen + if (endTime) { + const endTimestamp = new Date(endTime); + const startTimestamp = new Date(startWorklog.tstamp); + + // Validierung: Endzeit muss nach Startzeit liegen + if (endTimestamp <= startTimestamp) { + throw new Error('Endzeit muss nach Startzeit liegen'); + } + + // End-State erstellen + const endState = JSON.stringify({ + action: 'Clock Out', + project: project || stateData.project, + description: description !== undefined ? description : stateData.description + }); + + // Clock-Out Worklog erstellen + await worklogRepository.create({ + user_id: startWorklog.user_id, + state: endState, + tstamp: endTimestamp, + relatedTo_id: id + }); + + // Aktualisiertes Paar abrufen + const pairs = await worklogRepository.findPairsByUser(startWorklog.user_id); + const safePairs = Array.isArray(pairs) ? pairs : []; + const pair = safePairs.find(p => p.start_id === parseInt(id)); + + return this._mapWorklogPairToTimeEntry(pair); + } + + // Nur Beschreibung/Projekt aktualisieren (ohne Timer zu stoppen) + if (description !== undefined || project !== undefined) { + const updatedState = JSON.stringify({ + ...stateData, + project: project !== undefined ? project : stateData.project, + description: description !== undefined ? description : stateData.description + }); + + await worklogRepository.update(id, { state: updatedState }); + return await this.getEntryById(id); + } + + return await this.getEntryById(id); + } + + /** + * Zeiteintrag löschen + * @param {number} id - Die ID des zu löschenden Eintrags + * @returns {Promise} true wenn erfolgreich gelöscht + * @throws {Error} Wenn der Eintrag nicht gefunden wurde + */ + async deleteEntry(id) { + const startWorklog = await worklogRepository.findById(id); + + if (!startWorklog) { + throw new Error(`Eintrag mit ID ${id} nicht gefunden`); + } + + // Zugehörigen End-Eintrag finden und löschen + const pairs = await worklogRepository.findPairsByUser(startWorklog.user_id); + const pair = pairs.find(p => p.start_id === parseInt(id)); + + if (pair && pair.end_id) { + await worklogRepository.delete(pair.end_id); + } + + // Start-Eintrag löschen + return await worklogRepository.delete(id); + } + + /** + * Statistiken über alle Zeiteinträge abrufen + * @param {number} userId - Benutzer-ID (optional) + * @returns {Promise} Statistik-Objekt mit verschiedenen Metriken + */ + async getStatistics(userId = null) { + const uid = userId || this.defaultUserId; + + let currentlyWorked = null; + let allEntries = []; + + // Hole laufenden Eintrag + const runningEntry = await this.getRunningEntry(uid); + + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0); + const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); + + if (runningEntry) { + // Berechne ALLE Arbeitszeiten des heutigen Tages + // Hole alle Einträge von heute (wird auch für Pausen verwendet) + allEntries = await worklogRepository.findByDateRange(uid, todayStart, todayEnd); + + // Finde alle Start-Work-Paare von heute + const workBlocks = []; + const startWorks = {}; + + allEntries.forEach(entry => { + let state = entry.state; + if (typeof state === 'string') { + try { + state = JSON.parse(state); + } catch (e) { + // ignore + } + } + + const action = state?.action || state; + + if (action === 'start work') { + startWorks[entry.id] = { + id: entry.id, + startTime: entry.tstamp, + endTime: null, + pauses: [] + }; + } else if (action === 'stop work' && entry.relatedTo_id && startWorks[entry.relatedTo_id]) { + startWorks[entry.relatedTo_id].endTime = entry.tstamp; + } + }); + + // Sammle Pausen für jeden Block + allEntries.forEach(entry => { + let state = entry.state; + if (typeof state === 'string') { + try { + state = JSON.parse(state); + } catch (e) { + // ignore + } + } + + const action = state?.action || state; + + if (action === 'start pause' && entry.relatedTo_id && startWorks[entry.relatedTo_id]) { + startWorks[entry.relatedTo_id].pauses.push({ + id: entry.id, + startTime: entry.tstamp, + endTime: null + }); + } else if (action === 'stop pause' && entry.relatedTo_id) { + // Finde das zugehörige start pause + Object.values(startWorks).forEach(block => { + const pause = block.pauses.find(p => p.id === entry.relatedTo_id); + if (pause) { + pause.endTime = entry.tstamp; + } + }); + } + }); + + // Berechne Gesamtarbeitszeit aller Blöcke + let totalWorkedMs = 0; + + Object.values(startWorks).forEach(block => { + const blockStart = new Date(block.startTime).getTime(); + const blockEnd = block.endTime ? new Date(block.endTime).getTime() : now.getTime(); + + let blockWorkedMs = blockEnd - blockStart; + + // Ziehe abgeschlossene Pausen ab + block.pauses.forEach(pause => { + if (pause.endTime) { + const pauseDuration = new Date(pause.endTime).getTime() - new Date(pause.startTime).getTime(); + blockWorkedMs -= pauseDuration; + } else { + // Laufende Pause + const pauseDuration = now.getTime() - new Date(pause.startTime).getTime(); + blockWorkedMs -= pauseDuration; + } + }); + + totalWorkedMs += blockWorkedMs; + }); + + // Formatiere als HH:MM:SS + const totalSeconds = Math.floor(totalWorkedMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + currentlyWorked = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + + // Berechne "Offen" basierend auf timewish (oder Standard: 8 Stunden) + let open = null; + const { Timewish } = database.getModels(); + + // now wurde bereits oben deklariert (Zeile 221) + const currentDayOfWeek = now.getDay(); // 0=Sonntag, 1=Montag, ..., 6=Samstag + // Konvertiere: 0->7 (Sonntag), 1->1 (Montag), ..., 6->6 (Samstag) + const timewishDay = currentDayOfWeek === 0 ? 7 : currentDayOfWeek; + + const timewish = await Timewish.findOne({ + where: { + user_id: uid, + day: timewishDay + }, + raw: true + }); + + // Berechne "Offen" nur wenn aktuell gearbeitet wird + if (currentlyWorked) { + if (timewish) { + // Timewish vorhanden - verwende Wunschzeit + if (timewish.wishtype === 1) { + // Ende nach Uhrzeit + if (timewish.end_time) { + // Parse end_time (HH:MM:SS) + const [endHour, endMinute] = timewish.end_time.split(':').map(Number); + const endTime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), endHour, endMinute, 0); + + const remainingMs = endTime.getTime() - now.getTime(); + + if (remainingMs > 0) { + const remainingSeconds = Math.floor(remainingMs / 1000); + const h = Math.floor(remainingSeconds / 3600); + const m = Math.floor((remainingSeconds % 3600) / 60); + const s = remainingSeconds % 60; + open = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } else { + open = '00:00:00'; // Bereits überschritten + } + } + } else if (timewish.wishtype === 2) { + // Ende nach Stunden + if (timewish.hours) { + // Parse currentlyWorked + const [workedH, workedM, workedS] = currentlyWorked.split(':').map(Number); + const workedSeconds = workedH * 3600 + workedM * 60 + workedS; + const wishSeconds = Math.floor(timewish.hours * 3600); + + const remainingSeconds = wishSeconds - workedSeconds; + + if (remainingSeconds > 0) { + const h = Math.floor(remainingSeconds / 3600); + const m = Math.floor((remainingSeconds % 3600) / 60); + const s = remainingSeconds % 60; + open = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } else { + open = '00:00:00'; // Bereits erreicht + } + } + } + } else { + // Kein Timewish - Standard: 8 Stunden + const [workedH, workedM, workedS] = currentlyWorked.split(':').map(Number); + const workedSeconds = workedH * 3600 + workedM * 60 + workedS; + const standardSeconds = 8 * 3600; // 8 Stunden + + const remainingSeconds = standardSeconds - workedSeconds; + + if (remainingSeconds > 0) { + const h = Math.floor(remainingSeconds / 3600); + const m = Math.floor((remainingSeconds % 3600) / 60); + const s = remainingSeconds % 60; + open = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } else { + open = '00:00:00'; // Bereits erreicht + } + } + } + + // Berechne gesetzlich erforderliche Pausen + let requiredBreakMinutes = 0; + let alreadyTakenBreakMinutes = 0; + + if (currentlyWorked) { + // Geplante Arbeitszeit + let plannedWorkHours = 8; // Standard + + if (timewish && timewish.wishtype === 2 && timewish.hours) { + plannedWorkHours = timewish.hours; + } + + // Gesetzliche Pausen: ab 6h -> 30min, ab 9h -> 45min + if (plannedWorkHours >= 9) { + requiredBreakMinutes = 45; + } else if (plannedWorkHours >= 6) { + requiredBreakMinutes = 30; + } + + // Berechne ALLE genommenen Pausen von heute (verwende bereits geladene Einträge) + if (allEntries.length > 0) { + // Sammle alle abgeschlossenen Pausen + const pauseStarts = {}; + let totalPauseMs = 0; + + allEntries.forEach(entry => { + let state = entry.state; + if (typeof state === 'string') { + try { + state = JSON.parse(state); + } catch (e) { + // ignore + } + } + + const action = state?.action || state; + + if (action === 'start pause') { + pauseStarts[entry.id] = entry; + } else if (action === 'stop pause' && entry.relatedTo_id) { + const startPause = pauseStarts[entry.relatedTo_id]; + if (startPause) { + const pauseDuration = new Date(entry.tstamp).getTime() - new Date(startPause.tstamp).getTime(); + totalPauseMs += pauseDuration; + delete pauseStarts[entry.relatedTo_id]; + } + } + }); + + alreadyTakenBreakMinutes = Math.floor(totalPauseMs / (1000 * 60)); + } + } + + // Fehlende Pausenzeit + const missingBreakMinutes = Math.max(0, requiredBreakMinutes - alreadyTakenBreakMinutes); + + // Berechne Überstunden über den gesamten Zeitraum (alle Wochen) + // Neue Berechnung: Timewish-basiert + const totalOvertimeResult = await this._calculateTotalOvertime(uid, runningEntry); + + // Alte Berechnung: weekly_worktime-basiert (zum Vergleich) + const totalOvertimeOldStyleResult = await this._calculateTotalOvertimeOldStyle(uid, runningEntry); + + // Berechne Überstunden für die aktuelle Woche + const weekData = await this.getWeekOverview(uid, 0); + + let weekSollMinutes = 0; // Soll-Arbeitszeit für die Woche + let weekIstMinutes = 0; // Ist-Arbeitszeit für die Woche + + // Hole alle Timewishes für Mo-Fr + const allTimewishes = await Timewish.findAll({ + where: { + user_id: uid, + day: { + [require('sequelize').Op.in]: [1, 2, 3, 4, 5] // Mo-Fr + } + }, + raw: true + }); + + // Erstelle Map: Tag -> Array von Timewishes + const timewishMap = new Map(); + allTimewishes.forEach(tw => { + if (!timewishMap.has(tw.day)) { + timewishMap.set(tw.day, []); + } + timewishMap.get(tw.day).push(tw); + }); + + // Nur abgeschlossene Tage berücksichtigen (nicht heute, wenn noch gearbeitet wird) + const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const includeTodayInOvertime = !runningEntry; // Nur wenn heute schon abgeschlossen ist + + weekData.days.forEach(day => { + // Parse day.date (YYYY-MM-DD) + const dayDate = new Date(day.date); + + // Zukünftige Tage überspringen + if (dayDate > todayDate) { + return; + } + + // Heute überspringen, wenn noch gearbeitet wird + if (dayDate.getTime() === todayDate.getTime() && !includeTodayInOvertime) { + return; + } + + const dayOfWeek = dayDate.getDay(); // 0=So, 1=Mo, ..., 6=Sa + + // Nur Mo-Fr berücksichtigen + if (dayOfWeek === 0 || dayOfWeek === 6) { + return; // Wochenende überspringen + } + + // Prüfe auf Feiertag, Urlaub, Krankheit + if (day.sick) { + // Krankheitstag: Soll = 0, Ist wird ignoriert + return; + } + + if (day.holiday) { + // Feiertag: Soll = 0 + return; + } + + if (day.vacation && !day.workBlocks?.length) { + // Voller Urlaubstag ohne Arbeit: Soll = 0 + return; + } + + // Berechne Soll-Zeit für diesen Tag + const timewishDay = dayOfWeek === 0 ? 7 : dayOfWeek; + const dayTimewishes = timewishMap.get(timewishDay) || []; + + // Finde den passenden Timewish für dieses Datum + const applicableTimewish = dayTimewishes.find(tw => { + const startDate = new Date(tw.start_date); + const endDate = tw.end_date ? new Date(tw.end_date) : null; + + // Prüfe ob dayDate im Gültigkeitsbereich liegt + if (dayDate < startDate) return false; + if (endDate && dayDate > endDate) return false; + + return true; + }); + + let daySollHours = 8; // Standard: 8h + if (applicableTimewish && applicableTimewish.wishtype === 2 && applicableTimewish.hours) { + daySollHours = applicableTimewish.hours; + } + + // Bei halbem Urlaubstag: Soll halbiert + if (day.vacation && day.vacation.halfDay) { + daySollHours = daySollHours / 2; + } + + weekSollMinutes += daySollHours * 60; + + // Berechne Ist-Zeit für diesen Tag + if (day.netWorkTime) { + const [h, m] = day.netWorkTime.split(':').map(Number); + weekIstMinutes += h * 60 + m; + } + }); + + // Überstunden = Ist - Soll + const overtimeMinutes = weekIstMinutes - weekSollMinutes; + const overtimeHours = Math.floor(Math.abs(overtimeMinutes) / 60); + const overtimeMins = Math.abs(overtimeMinutes) % 60; + const overtimeSign = overtimeMinutes >= 0 ? '+' : '-'; + const overtime = `${overtimeSign}${overtimeHours}:${overtimeMins.toString().padStart(2, '0')}`; + + // Wochenarbeitszeit + const weekWorktimeHours = Math.floor(weekIstMinutes / 60); + const weekWorktimeMins = weekIstMinutes % 60; + const weekWorktime = `${weekWorktimeHours}:${weekWorktimeMins.toString().padStart(2, '0')}`; + + // Berechne "Offen für Woche" + // Dies ist: Wie lange muss ich HEUTE noch arbeiten, um das Wochenziel zu erreichen? + // = Wochensoll (gesamt) - Wochenist (abgeschlossene Tage + heute bisher) + let openForWeekFormatted = '—'; + + if (runningEntry) { + // Berechne Wochensoll und -ist + let weekSollMinutesTotal = 0; + let weekIstMinutesTotal = 0; + + weekData.days.forEach(day => { + // Parse day.date als YYYY-MM-DD und vergleiche nur das Datum (ohne Zeit) + const dayDate = new Date(day.date + 'T00:00:00'); + const dayDateOnly = new Date(dayDate.getFullYear(), dayDate.getMonth(), dayDate.getDate()); + + // isToday basierend auf Datumsvergleich (ohne Uhrzeit) + const isToday = dayDateOnly.getTime() === todayDate.getTime(); + + // Zukünftige Tage überspringen + if (dayDateOnly > todayDate) { + return; + } + + const dayOfWeek = dayDate.getDay(); + + // Nur Mo-Fr berücksichtigen + if (dayOfWeek === 0 || dayOfWeek === 6) { + return; + } + + // Prüfe auf Feiertag, Urlaub, Krankheit + if (day.sick || day.holiday) { + return; + } + + if (day.vacation && !day.workBlocks?.length) { + return; + } + + // Berechne Soll-Zeit für diesen Tag + const timewishDay = dayOfWeek === 0 ? 7 : dayOfWeek; + const dayTimewishes = timewishMap.get(timewishDay) || []; + + const applicableTimewish = dayTimewishes.find(tw => { + const startDate = new Date(tw.start_date); + const endDate = tw.end_date ? new Date(tw.end_date) : null; + if (dayDate < startDate) return false; + if (endDate && dayDate > endDate) return false; + return true; + }); + + let daySollHours = 8; + if (applicableTimewish && applicableTimewish.wishtype === 2 && applicableTimewish.hours) { + daySollHours = applicableTimewish.hours; + } + + if (day.vacation && day.vacation.halfDay) { + daySollHours = daySollHours / 2; + } + + weekSollMinutesTotal += daySollHours * 60; + + // Berechne Ist-Zeit für diesen Tag + let dayIstMinutes = 0; + + if (isToday && currentlyWorked) { + // Für heute: Verwende die aktuell gearbeitete Zeit aus currentlyWorked + // NICHT day.netWorkTime verwenden, um Doppelzählung zu vermeiden! + const [h, m, s] = currentlyWorked.split(':').map(Number); + dayIstMinutes = h * 60 + m; + weekIstMinutesTotal += dayIstMinutes; + } else if (!isToday && day.netWorkTime) { + // Für vergangene Tage: Verwende netWorkTime + const [h, m] = day.netWorkTime.split(':').map(Number); + dayIstMinutes = h * 60 + m; + weekIstMinutesTotal += dayIstMinutes; + } + }); + + // Offen für Woche = Wochensoll - Wochenist (bisher) + const openForWeekMinutes = weekSollMinutesTotal - weekIstMinutesTotal; + + if (openForWeekMinutes > 0) { + // Es muss noch gearbeitet werden, um das Wochenziel zu erreichen + const openWeekHours = Math.floor(openForWeekMinutes / 60); + const openWeekMins = openForWeekMinutes % 60; + openForWeekFormatted = `${openWeekHours}:${openWeekMins.toString().padStart(2, '0')}`; + } else if (openForWeekMinutes === 0) { + // Exakt das Wochenziel erreicht + openForWeekFormatted = 'Wochenziel erreicht'; + } else { + // Negativ = Wochenziel bereits übertroffen + openForWeekFormatted = '—'; + } + } + + // Berechne "Bereinigtes Arbeitsende" + // Nur berechnen, wenn derzeit gearbeitet wird (NICHT in Pause!) + let adjustedEndTodayGeneral = '—'; + let adjustedEndTodayWeek = '—'; + + // Hole den aktuellen Status + const currentState = await this.getCurrentState(uid); + + console.log(`DEBUG Bereinigtes Arbeitsende: runningEntry=${!!runningEntry}, currentState=${currentState}, open=${open}`); + + // Arbeitsende berechnen, wenn derzeit gearbeitet wird + // Das ist der Fall bei: 'start work' (direkt nach Arbeitsbeginn) oder 'stop pause' (nach Pausenende) + const isCurrentlyWorking = runningEntry && (currentState === 'start work' || currentState === 'stop pause'); + + if (isCurrentlyWorking && open && open !== '—' && open !== 'Arbeitsende erreicht') { + console.log(`DEBUG: Berechne bereinigtes Arbeitsende...`); + // Parse "Offen" Zeit (Format: HH:MM:SS) + const openParts = open.split(':'); + const openH = parseInt(openParts[0]); + const openM = parseInt(openParts[1]); + const openS = parseInt(openParts[2] || 0); + const openMinutes = openH * 60 + openM; + + // Aktuelle Zeit + const currentTime = new Date(); + + // ===== GENERELL ===== + // Verbleibende Zeit = Offen - Gesamt-Überstunden + const generalOvertimeMinutes = totalOvertimeResult.minutes || 0; + const generalRemainingMinutes = openMinutes - generalOvertimeMinutes; + + if (generalRemainingMinutes <= 0) { + adjustedEndTodayGeneral = 'Arbeitsende erreicht'; + } else { + // Berechne Uhrzeit: jetzt + verbleibende Minuten + const endTime = new Date(currentTime.getTime() + generalRemainingMinutes * 60 * 1000); + const endH = endTime.getHours(); + const endM = endTime.getMinutes(); + adjustedEndTodayGeneral = `${endH.toString().padStart(2, '0')}:${endM.toString().padStart(2, '0')}`; + } + + // ===== WOCHE ===== + // Verbleibende Zeit = Offen - Wochen-Über/Unterstunden + const weekOvertimeMinutes = overtimeMinutes; // Bereits berechnet + const weekRemainingMinutes = openMinutes - weekOvertimeMinutes; + + if (weekRemainingMinutes <= 0) { + adjustedEndTodayWeek = 'Arbeitsende erreicht'; + } else { + // Berechne Uhrzeit: jetzt + verbleibende Minuten + const endTime = new Date(currentTime.getTime() + weekRemainingMinutes * 60 * 1000); + const endH = endTime.getHours(); + const endM = endTime.getMinutes(); + adjustedEndTodayWeek = `${endH.toString().padStart(2, '0')}:${endM.toString().padStart(2, '0')}`; + } + + console.log(`DEBUG: Offen heute=${openMinutes}min, Gesamt-Überstunden=${generalOvertimeMinutes}min → Bereinigt (Generell)=${adjustedEndTodayGeneral}`); + console.log(`DEBUG: Offen heute=${openMinutes}min, Wochen-Überstunden=${weekOvertimeMinutes}min → Bereinigt (Woche)=${adjustedEndTodayWeek}`); + } + + // Berechne Arbeitsfreie Stunden (Urlaub + Krankheit + Feiertage) bis heute + let nonWorkingMinutes = 0; + + weekData.days.forEach(day => { + const dayDate = new Date(day.date); + + // Nur Tage bis heute berücksichtigen + if (dayDate > todayDate) { + return; + } + + // Bei laufendem Tag: überspringen + if (dayDate.getTime() === todayDate.getTime() && !includeTodayInOvertime) { + return; + } + + const dayOfWeek = dayDate.getDay(); + + // Nur Mo-Fr berücksichtigen + if (dayOfWeek === 0 || dayOfWeek === 6) { + return; + } + + // Sammle arbeitsfreie Stunden + if (day.sick) { + nonWorkingMinutes += day.sick.hours * 60; + } else if (day.holiday) { + nonWorkingMinutes += day.holiday.hours * 60; + } else if (day.vacation) { + nonWorkingMinutes += day.vacation.hours * 60; + } + }); + + const nonWorkingHours = Math.floor(nonWorkingMinutes / 60); + const nonWorkingMins = nonWorkingMinutes % 60; + const nonWorkingHoursFormatted = nonWorkingMinutes > 0 + ? `${nonWorkingHours}:${nonWorkingMins.toString().padStart(2, '0')}` + : null; + + // TODO: Echte Berechnungen für die anderen Felder implementieren + + return { + timestamp: new Date().toISOString(), // Zeitpunkt der Berechnung + currentlyWorked: currentlyWorked, + open: open, + requiredBreakMinutes: requiredBreakMinutes, // Gesetzlich erforderlich + alreadyTakenBreakMinutes: alreadyTakenBreakMinutes, // Bereits genommen + missingBreakMinutes: missingBreakMinutes, // Noch fehlend + regularEnd: null, + overtime: overtime, // Überstunden für die aktuelle Woche + totalOvertime: totalOvertimeResult.formatted, // Überstunden über den gesamten Zeitraum (timewish-basiert) + totalOvertimeOldStyle: totalOvertimeOldStyleResult.formatted, // Überstunden (weekly_worktime-basiert, zum Vergleich) + weekWorktime: weekWorktime, + nonWorkingHours: nonWorkingHoursFormatted, + openForWeek: openForWeekFormatted, + adjustedEndToday: 'Bereinigtes Arbeitsende (heute)', // Überschrift ohne Zeitwert + adjustedEndTodayGeneral: adjustedEndTodayGeneral, + adjustedEndTodayWeek: adjustedEndTodayWeek + }; + } + + /** + * Berechnet die Gesamt-Überstunden über alle Wochen + * @param {number} userId - Benutzer-ID + * @param {Object|null} runningEntry - Laufender Eintrag (falls vorhanden) + * @returns {Promise} { minutes, formatted } + * @private + */ + /** + * Berechne Überstunden im alten Style (weekly_worktime) + * @private + */ + async _calculateTotalOvertimeOldStyle(userId, runningEntry) { + const { WeeklyWorktime } = database.getModels(); + + // Finde den ersten Worklog-Eintrag des Users + const firstEntry = await worklogRepository.findByUser(userId); + if (!firstEntry || firstEntry.length === 0) { + return { minutes: 0, formatted: '+0:00' }; + } + + // Hole alle WeeklyWorktime-Einträge für diesen User + const weeklyWorktimes = await WeeklyWorktime.findAll({ + where: { user_id: userId }, + order: [['starting_from', 'ASC']], + raw: true + }); + + if (weeklyWorktimes.length === 0) { + weeklyWorktimes.push({ + weekly_work_time: 40, + starting_from: null, + ends_at: null + }); + } + + // Berechne Soll-Stunden für ein Datum basierend auf weekly_worktime + const getDailySollHours = (date) => { + const dateObj = new Date(date); + const dateString = dateObj.toISOString().split('T')[0]; + + // Finde passenden weekly_worktime Eintrag + let applicable = null; + for (const wt of weeklyWorktimes) { + const startFrom = wt.starting_from || '1900-01-01'; + const endsAt = wt.ends_at || '9999-12-31'; + + if (dateString >= startFrom && dateString <= endsAt) { + applicable = wt; + break; + } + } + + if (!applicable) { + applicable = weeklyWorktimes[weeklyWorktimes.length - 1]; + } + + // Wochenstunden / 5 = Tagesstunden (Mo-Fr) + return applicable.weekly_work_time / 5; + }; + + const firstDate = new Date(firstEntry[0].tstamp + 'Z'); + const firstMonday = new Date(firstDate); + const dayOfWeek = firstDate.getUTCDay(); + const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + firstMonday.setUTCDate(firstDate.getUTCDate() + daysToMonday); + firstMonday.setUTCHours(0, 0, 0, 0); + + const now = new Date(); + const todayMonday = new Date(now); + const todayDayOfWeek = now.getDay(); + const todayDaysToMonday = todayDayOfWeek === 0 ? -6 : 1 - todayDayOfWeek; + todayMonday.setDate(now.getDate() + todayDaysToMonday); + todayMonday.setHours(0, 0, 0, 0); + + const weeksDiff = Math.floor((todayMonday - firstMonday) / (7 * 24 * 60 * 60 * 1000)); + + let totalSollMinutes = 0; + let totalIstMinutes = 0; + + const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterdayDate = new Date(todayDate); + yesterdayDate.setDate(yesterdayDate.getDate() - 1); + const endDate = yesterdayDate; + + // Iteriere über alle Wochen + for (let weekOffset = -weeksDiff; weekOffset <= 0; weekOffset++) { + const weekData = await this.getWeekOverview(userId, weekOffset); + + weekData.days.forEach(day => { + const dayDate = new Date(day.date); + + if (dayDate > endDate) { + return; + } + + const dayOfWeek = dayDate.getDay(); + + if (dayOfWeek === 0 || dayOfWeek === 6) { + return; + } + + const daySollHours = getDailySollHours(day.date); + + if (day.holiday || day.sick) { + return; + } + + if (day.vacation) { + const vacationHours = day.vacationHours || 0; + + if (vacationHours === 4) { + // Halber Urlaubstag: Soll = Tagesstunden * 0.5 + totalSollMinutes += (daySollHours * 0.5) * 60; + + if (day.netWorkTime) { + const [h, m] = day.netWorkTime.split(':').map(Number); + totalIstMinutes += h * 60 + m; + } + } else { + // Ganzer Urlaubstag: überspringen + return; + } + } else { + // Normaler Arbeitstag + // WICHTIG für Alt-Style: Nur Tage zählen, an denen tatsächlich gearbeitet wurde! + if (day.netWorkTime) { + const [h, m] = day.netWorkTime.split(':').map(Number); + const istMinutes = h * 60 + m; + + totalSollMinutes += daySollHours * 60; + totalIstMinutes += istMinutes; + } + // Tage OHNE Arbeit werden NICHT gezählt (kein Soll, kein Ist) + } + }); + } + + console.log(`DEBUG _calculateTotalOvertimeOldStyle ERGEBNIS:`); + console.log(` Soll-Minuten: ${totalSollMinutes} (${(totalSollMinutes/60).toFixed(2)}h)`); + console.log(` Ist-Minuten: ${totalIstMinutes} (${(totalIstMinutes/60).toFixed(2)}h)`); + console.log(` Differenz: ${totalIstMinutes - totalSollMinutes} min (${((totalIstMinutes - totalSollMinutes)/60).toFixed(2)}h)`); + + const overtimeMinutes = totalIstMinutes - totalSollMinutes; + const overtimeHours = Math.floor(Math.abs(overtimeMinutes) / 60); + const overtimeMins = Math.abs(overtimeMinutes) % 60; + const overtimeSign = overtimeMinutes >= 0 ? '+' : '-'; + const formatted = `${overtimeSign}${overtimeHours}:${overtimeMins.toString().padStart(2, '0')}`; + + return { minutes: overtimeMinutes, formatted }; + } + + async _calculateTotalOvertime(userId, runningEntry) { + const { Timewish, Holiday, Vacation, Sick, Worklog, User } = database.getModels(); + const sequelize = database.sequelize; + + console.log('DEBUG _calculateTotalOvertime: Starte optimierte DB-basierte Berechnung...'); + + // Hole den Überstunden-Offset für diesen User + const user = await User.findByPk(userId, { attributes: ['overtime_offset_minutes'], raw: true }); + const overtimeOffsetMinutes = user?.overtime_offset_minutes || 0; + + if (overtimeOffsetMinutes !== 0) { + const offsetHours = Math.floor(Math.abs(overtimeOffsetMinutes) / 60); + const offsetMins = Math.abs(overtimeOffsetMinutes) % 60; + const offsetSign = overtimeOffsetMinutes >= 0 ? '+' : '-'; + console.log(`DEBUG: Überstunden-Offset aus User-Tabelle: ${offsetSign}${offsetHours}:${offsetMins.toString().padStart(2, '0')} (${overtimeOffsetMinutes} Minuten)`); + } + + // Hole alle Timewishes für Mo-Fr + const allTimewishes = await Timewish.findAll({ + where: { + user_id: userId, + day: { + [require('sequelize').Op.in]: [1, 2, 3, 4, 5] // Mo-Fr + } + }, + raw: true, + order: [['day', 'ASC'], ['start_date', 'ASC']] + }); + + console.log(`DEBUG: ${allTimewishes.length} Timewishes gefunden:`, allTimewishes.map(tw => `Tag ${tw.day}: ${tw.hours}h (${tw.start_date} - ${tw.end_date || 'heute'})`).join(', ')); + + // Erstelle Map: Tag -> Array von Timewishes + const timewishMap = new Map(); + allTimewishes.forEach(tw => { + if (!timewishMap.has(tw.day)) { + timewishMap.set(tw.day, []); + } + timewishMap.get(tw.day).push(tw); + }); + + // Berechne Soll-Stunden für ein Datum basierend auf timewish + const getDailySollHours = (dateString, dayOfWeek) => { + // Konvertiere JS dayOfWeek (0=So, 1=Mo) zu DB dayOfWeek (1=Mo, 7=So) + const timewishDay = dayOfWeek === 0 ? 7 : dayOfWeek; + + const dayTimewishes = timewishMap.get(timewishDay) || []; + + // Finde den passenden Timewish für dieses Datum + const applicableTimewish = dayTimewishes.find(tw => { + const startDate = tw.start_date; + const endDate = tw.end_date || '9999-12-31'; + + return dateString >= startDate && dateString <= endDate; + }); + + if (applicableTimewish && applicableTimewish.wishtype === 2 && applicableTimewish.hours) { + return parseFloat(applicableTimewish.hours); + } + + return 8.0; // Fallback + }; + + // Berechne Endedatum: gestern (wie alte MySQL-Funktion) + const now = new Date(); + const yesterdayDate = new Date(now); + yesterdayDate.setDate(yesterdayDate.getDate() - 1); + const endDateStr = yesterdayDate.toISOString().split('T')[0]; + + console.log(`DEBUG: Berechne Überstunden bis ${endDateStr}`); + + // Hole alle Arbeitstage mit Netto-Arbeitszeit aus der DB + // WICHTIG: Berücksichtigt timefix-Korrekturen! + // Dies ist VIEL schneller als 253x getWeekOverview aufzurufen! + const query = ` + SELECT + DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp)) as work_date, + DAYOFWEEK(DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp))) as day_of_week, + SUM( + TIMESTAMPDIFF(SECOND, + COALESCE(w1_fix.fix_date_time, w1.tstamp), + COALESCE(w2_fix.fix_date_time, w2.tstamp) + ) - IFNULL(pauses.pause_seconds, 0) + ) / 3600 as net_hours + FROM worklog w1 + LEFT JOIN timefix w1_fix ON w1_fix.worklog_id = w1.id + JOIN worklog w2 ON w2.relatedTo_id = w1.id AND w2.state = 'stop work' + LEFT JOIN timefix w2_fix ON w2_fix.worklog_id = w2.id + LEFT JOIN ( + SELECT + p1.relatedTo_id as work_id, + SUM( + TIMESTAMPDIFF(SECOND, + COALESCE(p1_fix.fix_date_time, p1.tstamp), + COALESCE(p2_fix.fix_date_time, p2.tstamp) + ) + ) as pause_seconds + FROM worklog p1 + LEFT JOIN timefix p1_fix ON p1_fix.worklog_id = p1.id + JOIN worklog p2 ON p2.relatedTo_id = p1.id AND p2.state = 'stop pause' + LEFT JOIN timefix p2_fix ON p2_fix.worklog_id = p2.id + WHERE p1.state = 'start pause' + GROUP BY p1.relatedTo_id + ) pauses ON pauses.work_id = w1.id + WHERE w1.state = 'start work' + AND w1.user_id = ? + AND DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp)) <= ? + AND DAYOFWEEK(DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp))) BETWEEN 2 AND 6 + GROUP BY DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp)) + ORDER BY DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp)) + `; + + const workDays = await sequelize.query(query, { + replacements: [userId, endDateStr], + type: sequelize.QueryTypes.SELECT + }); + + console.log(`DEBUG: ${workDays.length} Arbeitstage gefunden (mit timefix-Korrekturen)`); + + // Hole Feiertage + const holidays = await Holiday.findAll({ + attributes: ['date'], + where: { + date: { + [require('sequelize').Op.lte]: endDateStr + } + }, + raw: true + }); + const holidaySet = new Set(holidays.map(h => h.date)); + + // Hole Krankheitstage (expandiert) + const sickDaysRaw = await Sick.findAll({ + where: { + user_id: userId, + first_day: { + [require('sequelize').Op.lte]: endDateStr + } + }, + raw: true + }); + + const sickSet = new Set(); + sickDaysRaw.forEach(sick => { + const start = new Date(sick.first_day); + const end = new Date(sick.last_day); + + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + const dow = d.getDay(); + if (dow >= 1 && dow <= 5) { // Mo-Fr + sickSet.add(d.toISOString().split('T')[0]); + } + } + }); + + // Hole Urlaubstage (expandiert) + const vacationDaysRaw = await Vacation.findAll({ + where: { + user_id: userId, + first_day: { + [require('sequelize').Op.lte]: endDateStr + } + }, + raw: true + }); + + const vacationMap = new Map(); // date -> hours (4 oder 8) + vacationDaysRaw.forEach(vac => { + const start = new Date(vac.first_day); + const end = new Date(vac.last_day); + const hours = vac.vacation_type === 1 ? 4 : 8; + + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + const dow = d.getDay(); + if (dow >= 1 && dow <= 5) { // Mo-Fr + vacationMap.set(d.toISOString().split('T')[0], hours); + } + } + }); + + // Erstelle Map: work_date -> net_hours + const workDaysMap = new Map(); + let totalIstHoursFromDB = 0; + workDays.forEach(wd => { + const netHours = parseFloat(wd.net_hours); + workDaysMap.set(wd.work_date, netHours); + totalIstHoursFromDB += netHours; + }); + + console.log(`DEBUG: Gesamt Ist-Stunden aus DB-Query: ${totalIstHoursFromDB.toFixed(2)}h`); + + // Berechne Soll-Stunden für alle Arbeitstage + let totalSollMinutes = 0; + let processedDays = 0; + let sickDaysCount = 0; + let holidayDaysCount = 0; + let vacationFullDays = 0; + let vacationHalfDays = 0; + let workedDays = 0; + let notWorkedDays = 0; + + // Berechne Soll-Zeit für jeden Tag in workDaysMap (= Tage mit tatsächlicher Arbeit) + workDaysMap.forEach((istHours, dateStr) => { + const d = new Date(dateStr); + const dow = d.getDay(); + + // Prüfe auf Feiertag, Krankheit, Urlaub + if (holidaySet.has(dateStr)) { + holidayDaysCount++; + return; + } + + if (sickSet.has(dateStr)) { + sickDaysCount++; + return; + } + + const vacationHours = vacationMap.get(dateStr); + if (vacationHours) { + if (vacationHours === 4) { + // Halber Urlaubstag + vacationHalfDays++; + const sollHours = getDailySollHours(dateStr, dow); + totalSollMinutes += (sollHours - 4) * 60; + workedDays++; + } else { + // Ganzer Urlaubstag + vacationFullDays++; + } + } else { + // Normaler Arbeitstag + const sollHours = getDailySollHours(dateStr, dow); + totalSollMinutes += sollHours * 60; + workedDays++; + + // Debug: Zeige erste 5 Tage + if (processedDays < 5) { + console.log(` DEBUG Tag ${processedDays + 1}: ${dateStr}, Soll=${sollHours.toFixed(2)}h, Ist=${istHours.toFixed(2)}h`); + } + processedDays++; + } + }); + + // Verwende die Ist-Stunden direkt aus der DB-Query (korrekt!) + const totalIstMinutes = totalIstHoursFromDB * 60; + + console.log(`DEBUG _calculateTotalOvertime ANALYSE:`); + console.log(` - Krankheitstage: ${sickDaysCount}`); + console.log(` - Feiertage: ${holidayDaysCount}`); + console.log(` - Urlaubstage (ganz): ${vacationFullDays}`); + console.log(` - Urlaubstage (halb): ${vacationHalfDays}`); + console.log(` - Fehlzeit (nicht gearbeitet): ${notWorkedDays}`); + console.log(` = Gearbeitet: ${workedDays}`); + console.log(` = Verarbeitete Tage (Soll gezählt): ${processedDays}`); + console.log(` Berechnung: Soll=${totalSollMinutes}min (${(totalSollMinutes/60).toFixed(2)}h), Ist=${totalIstMinutes}min (${(totalIstMinutes/60).toFixed(2)}h)`); + + // Berechne Überstunden ohne Offset + const overtimeMinutesRaw = Math.round(totalIstMinutes - totalSollMinutes); + + // Addiere den Offset + const overtimeMinutes = overtimeMinutesRaw + overtimeOffsetMinutes; + + const overtimeHours = Math.floor(Math.abs(overtimeMinutes) / 60); + const overtimeMins = Math.abs(overtimeMinutes) % 60; + const overtimeSign = overtimeMinutes >= 0 ? '+' : '-'; + const formatted = `${overtimeSign}${overtimeHours}:${overtimeMins.toString().padStart(2, '0')}`; + + if (overtimeOffsetMinutes !== 0) { + const rawHours = Math.floor(Math.abs(overtimeMinutesRaw) / 60); + const rawMins = Math.abs(overtimeMinutesRaw) % 60; + const rawSign = overtimeMinutesRaw >= 0 ? '+' : '-'; + console.log(`DEBUG: Überstunden (berechnet): ${rawSign}${rawHours}:${rawMins.toString().padStart(2, '0')}`); + console.log(`DEBUG: Überstunden (mit Offset): ${formatted}`); + } else { + console.log(`DEBUG: Überstunden (Gesamt - Timewish): ${formatted} (${overtimeMinutes} Minuten)`); + } + + return { minutes: overtimeMinutes, formatted }; + } + + /** + * Wochenübersicht für einen Benutzer abrufen + * @param {number} userId - Benutzer-ID + * @param {number} weekOffset - Wochen-Offset (0 = aktuelle Woche, -1 = letzte Woche, etc.) + * @returns {Promise} Wochenübersicht mit Tagesdaten + */ + async getWeekOverview(userId, weekOffset = 0) { + const uid = userId || this.defaultUserId; + + // Berechne Start und Ende der gewünschten Woche (Montag bis Sonntag) + // Verwende lokales Datum, nicht UTC + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); + const day = now.getDate(); + const currentDay = now.getDay(); // 0 = Sonntag, 1 = Montag, ..., 6 = Samstag + + console.log(`DEBUG: Heute ist ${year}-${(month+1).toString().padStart(2,'0')}-${day.toString().padStart(2,'0')}, Tag ${currentDay} (0=So, 1=Mo, ..., 6=Sa)`); + + // Offset zu Montag berechnen + // Montag ist Tag 1, wir wollen zum Montag der aktuellen Woche + // Wenn Sonntag (0), dann ist das Ende der Woche, also -6 Tage zum Montag + // Wenn Montag (1), dann 0 Tage + // Wenn Dienstag (2), dann -1 Tag, etc. + const daysToMonday = currentDay === 0 ? -6 : 1 - currentDay; + + console.log(`DEBUG: Tage bis Montag: ${daysToMonday}`); + + // Berechne Montag der Woche + const weekStart = new Date(year, month, day + daysToMonday + (weekOffset * 7)); + weekStart.setHours(0, 0, 0, 0); + + // Berechne Sonntag der Woche + const weekEnd = new Date(year, month, day + daysToMonday + (weekOffset * 7) + 6); + weekEnd.setHours(23, 59, 59, 999); + + console.log(`Wochenübersicht für User ${uid}, Woche ${weekOffset}: ${weekStart.getFullYear()}-${(weekStart.getMonth()+1).toString().padStart(2,'0')}-${weekStart.getDate().toString().padStart(2,'0')} bis ${weekEnd.getFullYear()}-${(weekEnd.getMonth()+1).toString().padStart(2,'0')}-${weekEnd.getDate().toString().padStart(2,'0')}`); + + // Hole alle Worklog-Einträge für diese Woche + const pairs = await worklogRepository.findPairsByUserInDateRange(uid, weekStart, weekEnd); + + // Hole auch alle einzelnen Einträge für Pausen + const allEntries = await worklogRepository.findByDateRange(uid, weekStart, weekEnd); + + // Hole Timefix-Einträge für alle Worklog-IDs + const allWorklogIds = [...new Set([ + ...pairs.map(p => p.id), + ...allEntries.map(e => e.id) + ])]; + const timefixMap = await worklogRepository.getTimefixesByWorklogIds(allWorklogIds); + + // Hole Vacation-Einträge für diese Woche + const vacations = await worklogRepository.getVacationsByUserInDateRange(uid, weekStart, weekEnd); + + // Erstelle Map von Datum zu Vacation + const vacationMap = new Map(); + vacations.forEach(vac => { + const vacDate = new Date(vac.date); + const vacKey = `${vacDate.getUTCFullYear()}-${(vacDate.getUTCMonth()+1).toString().padStart(2,'0')}-${vacDate.getUTCDate().toString().padStart(2,'0')}`; + vacationMap.set(vacKey, vac); + }); + + // Hole Sick-Einträge für diese Woche + const sickDays = await worklogRepository.getSickByUserInDateRange(uid, weekStart, weekEnd); + + // Erstelle Map von Datum zu Sick + const sickMap = new Map(); + sickDays.forEach(sick => { + const sickDate = new Date(sick.date); + const sickKey = `${sickDate.getUTCFullYear()}-${(sickDate.getUTCMonth()+1).toString().padStart(2,'0')}-${sickDate.getUTCDate().toString().padStart(2,'0')}`; + sickMap.set(sickKey, sick); + }); + + // Hole Feiertage für diese Woche + const holidays = await worklogRepository.getHolidaysInDateRange(weekStart, weekEnd); + + // Erstelle Map von Datum zu Holiday + const holidayMap = new Map(); + holidays.forEach(holiday => { + const holidayDate = new Date(holiday.date); + const holidayKey = `${holidayDate.getUTCFullYear()}-${(holidayDate.getUTCMonth()+1).toString().padStart(2,'0')}-${holidayDate.getUTCDate().toString().padStart(2,'0')}`; + holidayMap.set(holidayKey, { + hours: holiday.hours || 8, + description: holiday.description + }); + }); + + // Gruppiere Einträge nach Tagen + const dayData = {}; + + // Initialisiere alle Wochentage + const todayKey = `${year}-${(month+1).toString().padStart(2,'0')}-${day.toString().padStart(2,'0')}`; + + for (let i = 0; i < 7; i++) { + const currentDay = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + i); + const dayKey = `${currentDay.getFullYear()}-${(currentDay.getMonth()+1).toString().padStart(2,'0')}-${currentDay.getDate().toString().padStart(2,'0')}`; + + dayData[dayKey] = { + date: dayKey, + name: this._getDayName(currentDay.getDay()), + workBlocks: [], // Array für mehrere Arbeitszeitblöcke + workTime: null, // Für Kompatibilität + pauses: [], + totalWorkTime: null, + pauseTimes: [], + netWorkTime: null, + status: '', + statusText: '', + isToday: dayKey === todayKey + }; + } + + // Verarbeite gefundene Einträge - SAMMLE alle Arbeitsblöcke + (Array.isArray(pairs) ? pairs : []).forEach(pair => { + if (!pair.start_time) return; + + const startDate = new Date(pair.start_time); + const dayKey = `${startDate.getFullYear()}-${(startDate.getMonth()+1).toString().padStart(2,'0')}-${startDate.getDate().toString().padStart(2,'0')}`; + + if (!dayData[dayKey]) return; + + const day = dayData[dayKey]; + + // Erstelle einen Arbeitsblock für diesen Pair + const workBlock = {}; + + if (pair.end_time) { + // Abgeschlossener Arbeitstag + // WICHTIG: DB speichert lokale Zeit als UTC + // Wir müssen die UTC-Komponenten direkt als lokale Zeit interpretieren + let workStartUTC = new Date(pair.start_time); + let workEndUTC = new Date(pair.end_time); + + // Prüfe auf Timefix-Korrekturen + const startFix = timefixMap.get(pair.id)?.find(f => f.fix_type === 'start work'); + const endFixEntry = allEntries.find(e => { + const action = (typeof e.state === 'string' ? JSON.parse(e.state) : e.state)?.action || e.state; + return action === 'stop work' && e.relatedTo_id === pair.id; + }); + const endFix = endFixEntry ? timefixMap.get(endFixEntry.id)?.find(f => f.fix_type === 'stop work') : null; + + // Verwende korrigierte Zeiten falls vorhanden + const originalStartTime = workStartUTC; + const originalEndTime = workEndUTC; + + if (startFix) { + workStartUTC = new Date(startFix.fix_date_time); + } + if (endFix) { + workEndUTC = new Date(endFix.fix_date_time); + } + + // Parse Zeit-String (YYYY-MM-DD HH:MM:SS) direkt + // tstamp ist jetzt ein String durch typeCast + const parseTimeString = (timeStr) => { + if (typeof timeStr === 'string') { + const parts = timeStr.split(' ')[1]; // "HH:MM:SS" + const [h, m] = parts.split(':').map(Number); + return { hours: h, minutes: m }; + } + // Fallback für Date-Objekte (sollte nicht mehr vorkommen) + return { hours: timeStr.getHours(), minutes: timeStr.getMinutes() }; + }; + + const start = parseTimeString(pair.start_time); + const end = parseTimeString(pair.end_time); + const startHours = start.hours; + const startMinutes = start.minutes; + const endHours = end.hours; + const endMinutes = end.minutes; + + // Original-Zeiten für Tooltip + const origStart = parseTimeString(originalStartTime); + const origEnd = parseTimeString(originalEndTime); + const originalStartHours = origStart.hours; + const originalStartMinutes = origStart.minutes; + const originalEndHours = origEnd.hours; + const originalEndMinutes = origEnd.minutes; + + // Berechne Arbeitszeit (Dauer bleibt gleich, da beide gleich verschoben sind) + const workStartMs = typeof pair.start_time === 'string' ? new Date(pair.start_time).getTime() : workStartUTC.getTime(); + const workEndMs = typeof pair.end_time === 'string' ? new Date(pair.end_time).getTime() : workEndUTC.getTime(); + const workDuration = workEndMs - workStartMs; + const workHours = Math.floor(workDuration / (1000 * 60 * 60)); + const workMinutes = Math.floor((workDuration % (1000 * 60 * 60)) / (1000 * 60)); + + workBlock.workTime = `${startHours.toString().padStart(2,'0')}:${startMinutes.toString().padStart(2,'0')} - ${endHours.toString().padStart(2,'0')}:${endMinutes.toString().padStart(2,'0')}`; + workBlock.totalWorkTime = `${workHours}:${workMinutes.toString().padStart(2, '0')}`; + workBlock.workTimeFixed = !!(startFix || endFix); + + if (startFix || endFix) { + workBlock.workTimeOriginal = `${originalStartHours.toString().padStart(2,'0')}:${originalStartMinutes.toString().padStart(2,'0')} - ${originalEndHours.toString().padStart(2,'0')}:${originalEndMinutes.toString().padStart(2,'0')}`; + } + + // Berechne Pausen NUR für diesen spezifischen Arbeitsblock + // Finde alle Einträge, die zu diesem pair.id gehören (über relatedTo_id) + const blockEntries = allEntries.filter(e => { + // Parse state if it's a JSON string + let state = e.state; + if (typeof state === 'string') { + try { + state = JSON.parse(state); + } catch (err) { + // state bleibt als String + } + } + + const action = state?.action || state; + + // Einträge gehören zum Block wenn: + // 1. Sie start/stop pause sind UND ihr relatedTo_id auf pair.id zeigt (direkt oder indirekt) + // 2. Oder sie stop work sind mit relatedTo_id = pair.id + + if (action === 'start pause' && e.relatedTo_id === pair.id) { + return true; // Pause gehört zu diesem Arbeitsblock + } + + if (action === 'stop pause') { + // Finde das zugehörige start pause + const startPause = allEntries.find(sp => { + const spState = typeof sp.state === 'string' ? JSON.parse(sp.state) : sp.state; + const spAction = spState?.action || spState; + return spAction === 'start pause' && sp.id === e.relatedTo_id; + }); + + if (startPause && startPause.relatedTo_id === pair.id) { + return true; // Stop pause gehört zu einem start pause, das zu diesem Block gehört + } + } + + return false; + }); + + console.log(`DEBUG: Block ${pair.id} hat ${blockEntries.length} Pausen-Einträge`); + + // Finde Pausen-Paare NUR für diesen Block + const pausePairs = []; + const pauseStarts = {}; + + blockEntries.forEach(entry => { + // Parse state if it's a JSON string + let state = entry.state; + if (typeof state === 'string') { + try { + state = JSON.parse(state); + } catch (e) { + // state bleibt als String + } + } + + const action = state?.action || state; + + if (action === 'start pause') { + pauseStarts[entry.id] = entry; + } else if (action === 'stop pause' && entry.relatedTo_id) { + const startPause = pauseStarts[entry.relatedTo_id]; + if (startPause) { + // Verwende die tatsächlichen Zeitwerte + let pStartTime = startPause.tstamp; + let pEndTime = entry.tstamp; + + // Prüfe auf Timefix-Korrekturen + const pauseStartFix = timefixMap.get(startPause.id)?.find(f => f.fix_type === 'start pause'); + const pauseEndFix = timefixMap.get(entry.id)?.find(f => f.fix_type === 'stop pause'); + + const originalPStartTime = pStartTime; + const originalPEndTime = pEndTime; + + if (pauseStartFix) { + pStartTime = pauseStartFix.fix_date_time; + } + if (pauseEndFix) { + pEndTime = pauseEndFix.fix_date_time; + } + + // Berechne Dauer (aus Strings) + const pStartMs = new Date(pStartTime).getTime(); + const pEndMs = new Date(pEndTime).getTime(); + const duration = pEndMs - pStartMs; + + // Parse Pausen-Zeiten + const pStart = parseTimeString(pStartTime); + const pEnd = parseTimeString(pEndTime); + const pStartHours = pStart.hours; + const pStartMinutes = pStart.minutes; + const pEndHours = pEnd.hours; + const pEndMinutes = pEnd.minutes; + + const pauseData = { + start: `${pStartHours.toString().padStart(2,'0')}:${pStartMinutes.toString().padStart(2,'0')}`, + end: `${pEndHours.toString().padStart(2,'0')}:${pEndMinutes.toString().padStart(2,'0')}`, + duration: duration, + fixed: !!(pauseStartFix || pauseEndFix) + }; + + if (pauseStartFix || pauseEndFix) { + const origStart = parseTimeString(originalPStartTime); + const origEnd = parseTimeString(originalPEndTime); + pauseData.original = `${origStart.hours.toString().padStart(2,'0')}:${origStart.minutes.toString().padStart(2,'0')} - ${origEnd.hours.toString().padStart(2,'0')}:${origEnd.minutes.toString().padStart(2,'0')}`; + } + + pausePairs.push(pauseData); + + delete pauseStarts[entry.relatedTo_id]; + } + } + }); + + // Berechne Pausenzeiten mit Korrektur-Informationen + workBlock.pauses = pausePairs.map(p => ({ + time: `${p.start} - ${p.end}`, + fixed: p.fixed, + original: p.original + })); + + workBlock.pauseTimes = pausePairs.map(p => { + const pauseMinutes = Math.floor(p.duration / (1000 * 60)); + const pH = Math.floor(pauseMinutes / 60); + const pM = pauseMinutes % 60; + return { + time: `${pH}:${pM.toString().padStart(2, '0')}`, + fixed: p.fixed, + original: p.original + }; + }); + + const totalPauseDuration = pausePairs.reduce((sum, p) => sum + p.duration, 0); + const netWorkDuration = workDuration - totalPauseDuration; + const netHours = Math.floor(netWorkDuration / (1000 * 60 * 60)); + const netMinutes = Math.floor((netWorkDuration % (1000 * 60 * 60)) / (1000 * 60)); + + workBlock.netWorkTime = `${netHours}:${netMinutes.toString().padStart(2, '0')}`; + workBlock.completed = true; + + // Füge Arbeitsblock zum Tag hinzu + day.workBlocks.push(workBlock); + + // Für Kompatibilität: Setze auch die alten Felder (letzter Block) + day.workTime = workBlock.workTime; + day.workTimeFixed = workBlock.workTimeFixed; + day.workTimeOriginal = workBlock.workTimeOriginal; + day.pauses = workBlock.pauses; + day.totalWorkTime = workBlock.totalWorkTime; + day.pauseTimes = workBlock.pauseTimes; + day.netWorkTime = workBlock.netWorkTime; + day.status = 'complete'; + day.statusText = 'Abgeschlossen'; + } else { + // Laufender Arbeitstag + const parseTimeString = (timeStr) => { + if (typeof timeStr === 'string') { + const parts = timeStr.split(' ')[1]; // "HH:MM:SS" + const [h, m] = parts.split(':').map(Number); + return { hours: h, minutes: m }; + } + // Fallback für Date-Objekte (sollte nicht mehr vorkommen) + return { hours: timeStr.getHours(), minutes: timeStr.getMinutes() }; + }; + + const start = parseTimeString(pair.start_time); + const startHours = start.hours; + const startMinutes = start.minutes; + + workBlock.workTime = `${startHours.toString().padStart(2,'0')}:${startMinutes.toString().padStart(2,'0')} - jetzt`; + workBlock.completed = false; + + // Prüfe auf Pausen NUR für diesen laufenden Arbeitsblock + const blockEntries = allEntries.filter(e => { + // Parse state if it's a JSON string + let state = e.state; + if (typeof state === 'string') { + try { + state = JSON.parse(state); + } catch (err) { + // state bleibt als String + } + } + + const action = state?.action || state; + + // Einträge gehören zum Block wenn: + // 1. Sie start/stop pause sind UND ihr relatedTo_id auf pair.id zeigt (direkt oder indirekt) + + if (action === 'start pause' && e.relatedTo_id === pair.id) { + return true; // Pause gehört zu diesem Arbeitsblock + } + + if (action === 'stop pause') { + // Finde das zugehörige start pause + const startPause = allEntries.find(sp => { + const spState = typeof sp.state === 'string' ? JSON.parse(sp.state) : sp.state; + const spAction = spState?.action || spState; + return spAction === 'start pause' && sp.id === e.relatedTo_id; + }); + + if (startPause && startPause.relatedTo_id === pair.id) { + return true; // Stop pause gehört zu einem start pause, das zu diesem Block gehört + } + } + + return false; + }); + + console.log(`DEBUG: Laufender Block ${pair.id} hat ${blockEntries.length} Pausen-Einträge`); + + // Finde laufende Pause + const pauseStarts = {}; + const pausePairs = []; + + blockEntries.forEach(entry => { + let state = entry.state; + if (typeof state === 'string') { + try { + state = JSON.parse(state); + } catch (e) { + // ignore + } + } + + const action = state?.action || state; + + if (action === 'start pause') { + pauseStarts[entry.id] = entry; + } else if (action === 'stop pause' && entry.relatedTo_id) { + const startPause = pauseStarts[entry.relatedTo_id]; + if (startPause) { + // Abgeschlossene Pause + const pStartUTC = new Date(startPause.tstamp); + const pEndUTC = new Date(entry.tstamp); + const duration = pEndUTC.getTime() - pStartUTC.getTime(); + + const pStart = parseTimeString(startPause.tstamp); + const pEnd = parseTimeString(entry.tstamp); + const pStartHours = pStart.hours; + const pStartMinutes = pStart.minutes; + const pEndHours = pEnd.hours; + const pEndMinutes = pEnd.minutes; + + pausePairs.push({ + start: `${pStartHours.toString().padStart(2,'0')}:${pStartMinutes.toString().padStart(2,'0')}`, + end: `${pEndHours.toString().padStart(2,'0')}:${pEndMinutes.toString().padStart(2,'0')}`, + duration: duration, + fixed: false + }); + + delete pauseStarts[entry.relatedTo_id]; // Lösche das START pause, nicht das STOP pause + } + } + }); + + // Füge laufende Pausen hinzu + Object.values(pauseStarts).forEach(startPause => { + const parseTimeString = (timeStr) => { + if (typeof timeStr === 'string') { + const parts = timeStr.split(' ')[1]; + const [h, m] = parts.split(':').map(Number); + return { hours: h, minutes: m }; + } + // Fallback für Date-Objekte (sollte nicht mehr vorkommen) + return { hours: timeStr.getHours(), minutes: timeStr.getMinutes() }; + }; + + const pStart = parseTimeString(startPause.tstamp); + const pStartHours = pStart.hours; + const pStartMinutes = pStart.minutes; + + pausePairs.push({ + start: `${pStartHours.toString().padStart(2,'0')}:${pStartMinutes.toString().padStart(2,'0')}`, + end: 'läuft', + duration: null, + fixed: false, + running: true + }); + }); + + // Setze Pausen-Daten für diesen Block + workBlock.pauses = pausePairs.map(p => ({ + time: p.end === 'läuft' ? `${p.start} - jetzt` : `${p.start} - ${p.end}`, + fixed: p.fixed, + running: p.running + })); + + workBlock.pauseTimes = pausePairs.filter(p => p.duration).map(p => { + const pauseMinutes = Math.floor(p.duration / (1000 * 60)); + const pH = Math.floor(pauseMinutes / 60); + const pM = pauseMinutes % 60; + return { + time: `${pH}:${pM.toString().padStart(2, '0')}`, + fixed: false + }; + }); + + // Füge laufenden Block zum Tag hinzu + day.workBlocks.push(workBlock); + + // Für Kompatibilität: Setze auch die alten Felder (letzter Block) + day.workTime = workBlock.workTime; + day.pauses = workBlock.pauses; + day.pauseTimes = workBlock.pauseTimes; + day.status = 'running'; + day.statusText = 'Läuft'; + } + }); + + // Nach dem Sammeln aller Blöcke: Berechne Gesamtwerte pro Tag + Object.values(dayData).forEach(day => { + if (day.workBlocks.length === 0) return; + + // Berechne Gesamt-Arbeitszeit und -Nettozeit + let totalWorkMinutes = 0; + let totalNetMinutes = 0; + + day.workBlocks.forEach(block => { + if (block.totalWorkTime) { + const [h, m] = block.totalWorkTime.split(':').map(Number); + totalWorkMinutes += h * 60 + m; + } + + if (block.netWorkTime) { + const [h, m] = block.netWorkTime.split(':').map(Number); + totalNetMinutes += h * 60 + m; + } + }); + + // Setze Gesamtwerte (für Wochensumme) + if (totalWorkMinutes > 0) { + const tH = Math.floor(totalWorkMinutes / 60); + const tM = totalWorkMinutes % 60; + day.totalWorkTime = `${tH}:${tM.toString().padStart(2, '0')}`; + } + + if (totalNetMinutes > 0) { + const nH = Math.floor(totalNetMinutes / 60); + const nM = totalNetMinutes % 60; + day.netWorkTime = `${nH}:${nM.toString().padStart(2, '0')}`; + } + + // NICHT die Pausen auf Day-Level setzen - sie sind bereits in den Blöcken! + // Das Frontend zeigt die Blöcke einzeln an, nicht die Tag-Level-Pausen. + + // Status basierend auf Blöcken + const hasRunning = day.workBlocks.some(b => !b.completed); + const hasCompleted = day.workBlocks.some(b => b.completed); + + if (hasRunning) { + day.status = 'running'; + day.statusText = 'Läuft'; + } else if (hasCompleted) { + day.status = 'complete'; + day.statusText = 'Abgeschlossen'; + } + }); + + // Markiere Wochenenden, integriere Urlaub, Krankheit und Feiertage + Object.values(dayData).forEach(day => { + const dayOfWeek = new Date(day.date).getDay(); + + // Prüfe auf Krankheit (hat höchste Priorität, überschreibt alles außer Feiertage) + const sick = sickMap.get(day.date); + if (sick) { + // Krankheitstage überschreiben geloggte Zeiten + day.sick = { + hours: 8, + type: sick.sick_type + }; + + // Lösche geloggte Arbeitszeit + day.workTime = null; + day.pauses = []; + day.totalWorkTime = null; + day.pauseTimes = []; + day.netWorkTime = null; + + // Setze Status basierend auf Krankheitstyp + const sickLabels = { + 'self': 'Krank', + 'child': 'Kind krank', + 'parents': 'Eltern krank', + 'partner': 'Partner krank' + }; + + day.status = 'sick'; + day.statusText = sickLabels[sick.sick_type] || 'Krank'; + + return; // Überspringe weitere Verarbeitung + } + + // Prüfe auf Feiertag + const holiday = holidayMap.get(day.date); + if (holiday) { + day.holiday = { + hours: holiday.hours, + description: holiday.description + }; + + // Status setzen + if (!day.workTime) { + day.status = 'holiday'; + day.statusText = holiday.description || 'Feiertag'; + } else { + // Feiertag + Arbeit + day.status = 'holiday-work'; + day.statusText = `${holiday.description || 'Feiertag'} + Arbeit`; + } + } + + // Prüfe auf Urlaub (nur wenn kein Feiertag) + if (!holiday) { + const vacation = vacationMap.get(day.date); + if (vacation) { + const isHalfDay = vacation.half_day === 1; + const vacationHours = isHalfDay ? 4 : 8; + + day.vacation = { + hours: vacationHours, + halfDay: isHalfDay + }; + + // Status setzen (kann später überschrieben werden wenn auch gearbeitet wurde) + if (!day.workTime) { + day.status = isHalfDay ? 'vacation-half' : 'vacation-full'; + day.statusText = isHalfDay ? 'Urlaub (halber Tag)' : 'Urlaub'; + } else { + // Urlaub + Arbeit + day.status = 'vacation-work'; + day.statusText = isHalfDay ? 'Urlaub (halber Tag) + Arbeit' : 'Urlaub + Arbeit'; + } + } + } + + // Wochenenden (nur wenn kein Urlaub, Feiertag oder Arbeit) + if (dayOfWeek === 0 || dayOfWeek === 6) { + if (!day.status) { + day.status = 'weekend'; + day.statusText = 'Wochenende'; + } + } + }); + + // Berechne Wochensumme (Arbeit + Urlaub + Krankheit + Feiertage) + let totalMinutes = 0; + Object.values(dayData).forEach(day => { + // Krankheitsstunden (haben Vorrang) + if (day.sick) { + totalMinutes += day.sick.hours * 60; + } else { + // Nettoarbeitszeit + if (day.netWorkTime) { + const [hours, minutes] = day.netWorkTime.split(':').map(Number); + totalMinutes += hours * 60 + minutes; + } + + // Feiertagsstunden + if (day.holiday) { + totalMinutes += day.holiday.hours * 60; + } + + // Urlaubsstunden + if (day.vacation) { + totalMinutes += day.vacation.hours * 60; + } + } + }); + + const weekTotalHours = Math.floor(totalMinutes / 60); + const weekTotalMinutes = totalMinutes % 60; + const weekTotal = `${weekTotalHours}:${weekTotalMinutes.toString().padStart(2, '0')}`; + + // Berechne nur die Arbeitszeit (ohne arbeitsfreie Tage) + let workMinutes = 0; + Object.values(dayData).forEach(day => { + if (day.netWorkTime && !day.vacation && !day.sick && !day.holiday) { + const [hours, minutes] = day.netWorkTime.split(':').map(Number); + workMinutes += hours * 60 + minutes; + } + }); + + const workHours = Math.floor(workMinutes / 60); + const workMins = workMinutes % 60; + const workTotal = `${workHours}:${workMins.toString().padStart(2, '0')}`; + + // Berechne arbeitsfreie Stunden für die Woche (separat für Anzeige) + let nonWorkingMinutes = 0; + let nonWorkingDays = 0; + const nonWorkingDetails = []; + + Object.values(dayData).forEach(day => { + if (day.vacation && day.vacation.hours > 0) { + nonWorkingMinutes += day.vacation.hours * 60; + nonWorkingDays++; + nonWorkingDetails.push({ + date: day.date, + type: 'Urlaub', + hours: day.vacation.hours + }); + } + if (day.sick && day.sick.hours > 0) { + nonWorkingMinutes += day.sick.hours * 60; + nonWorkingDays++; + nonWorkingDetails.push({ + date: day.date, + type: 'Krankheit', + hours: day.sick.hours + }); + } + if (day.holiday && day.holiday.hours > 0) { + nonWorkingMinutes += day.holiday.hours * 60; + nonWorkingDays++; + nonWorkingDetails.push({ + date: day.date, + type: 'Feiertag', + hours: day.holiday.hours + }); + } + }); + + const nonWorkingHours = Math.floor(nonWorkingMinutes / 60); + const nonWorkingMins = nonWorkingMinutes % 60; + const nonWorkingTotal = `${nonWorkingHours}:${nonWorkingMins.toString().padStart(2, '0')}`; + + // Gesamtsumme = Arbeitszeit + arbeitsfreie Zeit + const totalAllMinutes = workMinutes + nonWorkingMinutes; + const totalAllHours = Math.floor(totalAllMinutes / 60); + const totalAllMins = totalAllMinutes % 60; + const totalAll = `${totalAllHours}:${totalAllMins.toString().padStart(2, '0')}`; + + return { + weekStart: weekStart.toISOString().split('T')[0], + weekEnd: weekEnd.toISOString().split('T')[0], + weekOffset, + days: Object.values(dayData).sort((a, b) => a.date.localeCompare(b.date)), + weekTotal: workTotal, // Nur Arbeitszeit + nonWorkingTotal, + nonWorkingDays, + nonWorkingDetails, + totalAll // Arbeitszeit + arbeitsfreie Zeit + }; + } + + /** + * Aktuellen Status/Zustand des Benutzers abrufen + * @param {number} userId - Benutzer-ID + * @returns {Promise} Letzter Action-Status oder null + */ + async getCurrentState(userId) { + const uid = userId || this.defaultUserId; + + // Hole letzten Worklog-Eintrag (inkl. Timefix-Korrekturen) + const lastEntry = await worklogRepository.findLatestByUser(uid); + + if (!lastEntry) { + return null; + } + + // Parse state + let state = lastEntry.state; + if (typeof state === 'string') { + try { + state = JSON.parse(state); + } catch (e) { + // state bleibt als String + } + } + + const action = state?.action || state; + + // Prüfe auf Timefix-Korrektur für diesen Eintrag + const timefixMap = await worklogRepository.getTimefixesByWorklogIds([lastEntry.id]); + const timefix = timefixMap.get(lastEntry.id)?.[0]; + + if (timefix) { + // Verwende korrigierten Action-Typ + return timefix.fix_type; + } + + return action; + } + + /** + * Stempeln (Clock-Aktion durchführen) + * @param {number} userId - Benutzer-ID + * @param {string} action - Aktion: 'start work', 'stop work', 'start pause', 'stop pause' + * @returns {Promise} Erstellter Worklog-Eintrag + */ + async clock(userId, action) { + const uid = userId || this.defaultUserId; + + // Validiere Action + const validActions = ['start work', 'stop work', 'start pause', 'stop pause']; + if (!validActions.includes(action)) { + throw new Error('Ungültige Aktion'); + } + + // Hole aktuellen Status + const currentState = await this.getCurrentState(uid); + + // Validiere State-Transition + const isValidTransition = this._validateTransition(currentState, action); + if (!isValidTransition) { + throw new Error(`Ungültiger Übergang: ${currentState} -> ${action}`); + } + + // Erstelle Worklog-Eintrag + const relatedToId = await this._getRelatedToId(currentState, action, uid); + + // Die DB speichert UTC-Zeit (ohne TZ-Info im String) + // Format: YYYY-MM-DD HH:MM:SS (immer UTC) + // Das Frontend konvertiert dann zu lokaler Zeit für die Anzeige + const now = new Date(); + + const year = now.getUTCFullYear(); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + const day = String(now.getUTCDate()).padStart(2, '0'); + const hours = String(now.getUTCHours()).padStart(2, '0'); + const minutes = String(now.getUTCMinutes()).padStart(2, '0'); + const seconds = String(now.getUTCSeconds()).padStart(2, '0'); + const utcTimeString = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + + console.log(`DEBUG clock: Lokale Zeit = ${now.toLocaleString('de-DE', { timeZone: 'Europe/Berlin' })}`); + console.log(`DEBUG clock: UTC-String für DB = ${utcTimeString}`); + console.log(`DEBUG clock: ISO-String = ${now.toISOString()}`); + + const entry = await worklogRepository.create({ + user_id: uid, + state: action, // Speichere nur den Action-String, nicht als JSON + tstamp: utcTimeString, // UTC-Zeit als String + relatedTo_id: relatedToId + }); + + console.log(`DEBUG clock: Entry gespeichert mit tstamp = ${entry.tstamp}`); + + return entry; + } + + /** + * Validiert ob eine State-Transition erlaubt ist + * @private + */ + _validateTransition(currentState, newAction) { + const transitions = { + 'null': ['start work'], + 'stop work': ['start work'], + 'start work': ['stop work', 'start pause'], + 'stop pause': ['stop work', 'start pause'], + 'start pause': ['stop pause'] + }; + + const allowedActions = transitions[currentState || 'null'] || []; + return allowedActions.includes(newAction); + } + + /** + * Ermittelt die relatedTo_id für einen neuen Eintrag + * @private + */ + async _getRelatedToId(currentState, newAction, userId) { + if (newAction === 'start work') { + return null; // Start work hat keine Referenz + } + + // stop work und stop pause referenzieren den entsprechenden Start + if (newAction === 'stop work') { + // Finde letzten 'start work' ohne 'stop work' + return await this._findLastUnpairedStart(userId, 'start work'); + } + + if (newAction === 'stop pause') { + // Finde letzten 'start pause' ohne 'stop pause' + return await this._findLastUnpairedStart(userId, 'start pause'); + } + + if (newAction === 'start pause') { + // start pause referenziert den laufenden 'start work' + return await this._findLastUnpairedStart(userId, 'start work'); + } + + return null; + } + + /** + * Findet den letzten Start-Eintrag ohne passendes End + * @private + */ + async _findLastUnpairedStart(userId, startAction) { + const allEntries = await worklogRepository.findByUser(userId); + + console.log(`DEBUG _findLastUnpairedStart: Suche nach '${startAction}', ${allEntries.length} Einträge gefunden`); + + // Finde den letzten Start ohne Stop + for (let i = allEntries.length - 1; i >= 0; i--) { + const entry = allEntries[i]; + let state = entry.state; + + if (typeof state === 'string') { + try { + state = JSON.parse(state); + } catch (e) { + // ignore - state bleibt als String + } + } + + const action = state?.action || state; + + if (action === startAction) { + // Prüfe ob es einen passenden Stop gibt + // WICHTIG: Nur nach dem entsprechenden STOP-Action suchen, nicht nach Pausen! + const stopAction = startAction === 'start work' ? 'stop work' : 'stop pause'; + + const hasStop = allEntries.some(e => { + // Parse state für diesen Eintrag + let eState = e.state; + if (typeof eState === 'string') { + try { + eState = JSON.parse(eState); + } catch (err) { + // ignore + } + } + const eAction = eState?.action || eState; + + // Prüfe ob dieser Eintrag ein STOP ist, der auf unseren START zeigt + return eAction === stopAction && e.relatedTo_id === entry.id; + }); + + console.log(`DEBUG: Eintrag ${entry.id} (${action}), hasStop: ${hasStop}`); + + if (!hasStop) { + console.log(`DEBUG: Verwende Eintrag ${entry.id} als relatedTo_id`); + return entry.id; + } + } + } + + console.log(`DEBUG: Kein ungepaarter '${startAction}' gefunden`); + return null; + } + + /** + * Hilfsmethode: Wochentag-Namen + * @private + * @param {number} dayOfWeek - 0 = Sonntag, 1 = Montag, ..., 6 = Samstag + */ + _getDayName(dayOfWeek) { + const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; + return days[dayOfWeek]; + } + + /** + * Aktuell laufenden Timer abrufen + * @param {number} userId - Benutzer-ID (optional) + * @returns {Promise} Der laufende Zeiteintrag oder null + */ + async getRunningEntry(userId = null) { + const uid = userId || this.defaultUserId; + + // Hole letzten Eintrag + const lastEntry = await worklogRepository.findLatestByUser(uid); + + if (!lastEntry) { + console.log('DEBUG getRunningEntry: Kein letzter Eintrag gefunden'); + return null; + } + + // Parse state + let state = lastEntry.state; + if (typeof state === 'string') { + try { + state = JSON.parse(state); + } catch (e) { + // state bleibt als String + } + } + + const action = state?.action || state; + + console.log(`DEBUG getRunningEntry: Letzter Eintrag ID ${lastEntry.id}, action: ${action}`); + + // Wenn letzter Eintrag "stop work" oder null ist, läuft nichts + if (!action || action === 'stop work') { + console.log(`DEBUG getRunningEntry: Keine laufende Arbeit (action: ${action})`); + return null; + } + + // Finde den zugehörigen "start work" + const startWorkId = await this._findLastUnpairedStart(uid, 'start work'); + + console.log(`DEBUG getRunningEntry: startWorkId = ${startWorkId}`); + + if (!startWorkId) { + console.log('DEBUG getRunningEntry: Kein startWorkId gefunden'); + return null; + } + + // Hole alle Einträge seit dem Start + const allEntries = await worklogRepository.findByUser(uid); + const startWorkEntry = allEntries.find(e => e.id === startWorkId); + + console.log(`DEBUG getRunningEntry: startWorkEntry gefunden: ${!!startWorkEntry}`); + + if (!startWorkEntry) { + console.log('DEBUG getRunningEntry: Kein startWorkEntry gefunden'); + return null; + } + + // Sammle alle abgeschlossenen Pausen + const pauseDurations = []; + let currentPauseStart = null; + + // Finde alle Pausen-Paare + const relevantEntries = allEntries.filter(e => { + const eDate = new Date(e.tstamp); + const startDate = new Date(startWorkEntry.tstamp); + return eDate >= startDate; + }); + + const pauseStarts = {}; + + relevantEntries.forEach(entry => { + let eState = entry.state; + if (typeof eState === 'string') { + try { + eState = JSON.parse(eState); + } catch (e) { + // ignore + } + } + + const eAction = eState?.action || eState; + + if (eAction === 'start pause') { + pauseStarts[entry.id] = entry; + } else if (eAction === 'stop pause' && entry.relatedTo_id) { + const startPause = pauseStarts[entry.relatedTo_id]; + if (startPause) { + const duration = new Date(entry.tstamp).getTime() - new Date(startPause.tstamp).getTime(); + pauseDurations.push(duration); + delete pauseStarts[entry.relatedTo_id]; + } + } + }); + + // Wenn noch ein pauseStart übrig ist, ist das die laufende Pause + const runningPauseIds = Object.keys(pauseStarts); + if (runningPauseIds.length > 0) { + const pauseId = parseInt(runningPauseIds[0]); + currentPauseStart = pauseStarts[pauseId].tstamp; + } + + const result = { + id: startWorkId, + startTime: startWorkEntry.tstamp, + endTime: null, + description: (state?.description || ''), + project: (state?.project || 'Allgemein'), + duration: null, + isRunning: true, + userId: uid, + pauses: pauseDurations, + currentPauseStart: currentPauseStart + }; + + console.log(`DEBUG getRunningEntry: Returning result with ${pauseDurations.length} pauses, currentPauseStart: ${currentPauseStart}`); + + return result; + } + + /** + * Alle Einträge nach Projekt filtern + * @param {string} projectName - Name des Projekts + * @param {number} userId - Benutzer-ID (optional) + * @returns {Promise} Gefilterte Liste der Zeiteinträge + */ + async getEntriesByProject(projectName, userId = null) { + const uid = userId || this.defaultUserId; + const pairs = await worklogRepository.findPairsByUser(uid); + const safePairs = Array.isArray(pairs) ? pairs : []; + + return safePairs + .map(pair => this._mapWorklogPairToTimeEntry(pair)) + .filter(entry => + entry.project.toLowerCase() === projectName.toLowerCase() + ); + } + + /** + * Alle Einträge in einem Datumsbereich abrufen + * @param {Date} startDate - Startdatum + * @param {Date} endDate - Enddatum + * @param {number} userId - Benutzer-ID (optional) + * @returns {Promise} Gefilterte Liste der Zeiteinträge + */ + async getEntriesByDateRange(startDate, endDate, userId = null) { + const uid = userId || this.defaultUserId; + const worklogs = await worklogRepository.findByDateRange(uid, startDate, endDate); + + // Zu Paaren gruppieren + const pairs = []; + const startEntries = worklogs.filter(w => w.relatedTo_id === null); + + for (const start of startEntries) { + const end = worklogs.find(w => w.relatedTo_id === start.id); + + let duration = null; + if (end) { + const startTime = new Date(start.tstamp); + const endTime = new Date(end.tstamp); + duration = Math.floor((endTime - startTime) / 1000); + } + + pairs.push({ + start_id: start.id, + start_time: start.tstamp, + start_state: start.state, + end_id: end ? end.id : null, + end_time: end ? end.tstamp : null, + end_state: end ? end.state : null, + duration + }); + } + + return pairs.map(pair => this._mapWorklogPairToTimeEntry(pair)); + } + + /** + * Worklog-Paar zu TimeEntry-Format konvertieren (privat) + * @private + */ + _mapWorklogPairToTimeEntry(pair) { + let startStateData, endStateData; + + try { + startStateData = JSON.parse(pair.start_state); + } catch { + startStateData = { project: 'Allgemein', description: '' }; + } + + try { + endStateData = pair.end_state ? JSON.parse(pair.end_state) : null; + } catch { + endStateData = null; + } + + return { + id: pair.start_id, + startTime: pair.start_time, + endTime: pair.end_time || null, + description: (endStateData && endStateData.description) || startStateData.description || '', + project: (endStateData && endStateData.project) || startStateData.project || 'Allgemein', + duration: pair.duration || null, + isRunning: !pair.end_time, + userId: null // Wird vom Repository nicht zurückgegeben im Pair + }; + } +} + +// Singleton-Instanz exportieren +module.exports = new TimeEntryService(); diff --git a/backend/src/utils/hashId.js b/backend/src/utils/hashId.js new file mode 100644 index 0000000..69393ed --- /dev/null +++ b/backend/src/utils/hashId.js @@ -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} 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} 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; + + + diff --git a/backend/update-overtime-offset.sql b/backend/update-overtime-offset.sql new file mode 100644 index 0000000..f53286b --- /dev/null +++ b/backend/update-overtime-offset.sql @@ -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; + diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..aafd3f9 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.DS_Store +*.log +.vite + + + diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..332222d --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,107 @@ +# TimeClock Frontend v3.0 + +Vue 3 Frontend für die TimeClock Zeiterfassungsanwendung. + +## Installation + +```bash +npm install +``` + +## Entwicklung + +```bash +npm run dev +``` + +Öffnen Sie `http://localhost:5010` in Ihrem Browser. + +## Build + +```bash +npm run build +``` + +Die Build-Dateien werden im `dist/` Ordner erstellt. + +## Preview (nach Build) + +```bash +npm run preview +``` + +## Technologien + +- **Vue 3** - Progressive JavaScript Framework +- **Vue Router** - Offizielle Router-Bibliothek +- **Pinia** - State Management +- **Vite** - Next Generation Frontend Tooling + +## Projektstruktur + +``` +src/ +├── assets/ # CSS und statische Assets +├── components/ # Wiederverwendbare Komponenten +├── router/ # Vue Router Konfiguration +├── stores/ # Pinia Stores +├── views/ # Seiten-Komponenten +│ ├── Dashboard.vue +│ ├── Entries.vue +│ └── Stats.vue +├── App.vue # Root-Komponente +└── main.js # Entry Point +``` + +## Features + +### Dashboard +- Timer starten/stoppen +- Echtzeit-Anzeige der laufenden Zeit +- Projekt und Beschreibung eingeben +- Letzte 5 Einträge anzeigen + +### Einträge +- Tabellarische Übersicht aller Einträge +- Status-Anzeige (laufend/beendet) +- Einträge löschen + +### Statistiken +- Gesamtstatistiken +- Projekt-basierte Auswertungen +- Visualisierung der Arbeitszeit + +## State Management + +Die Anwendung verwendet Pinia für zentrales State Management: + +```javascript +// stores/timeStore.js +const timeStore = useTimeStore() + +// Verfügbare Actions +timeStore.fetchEntries() +timeStore.startTimer({ project, description }) +timeStore.stopTimer() +timeStore.deleteEntry(id) +timeStore.fetchStats() +``` + +## API-Konfiguration + +Die API-URL ist in `src/stores/timeStore.js` konfiguriert: + +```javascript +const API_URL = 'http://localhost:3010/api' +``` + +Für Produktion sollte dies in eine Environment-Variable verschoben werden. + +## Styling + +Die Anwendung verwendet: +- Custom CSS mit CSS Variables +- Moderne Gradients +- Responsive Design +- Card-basiertes Layout + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e767280 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + TimeClock v3 - Zeiterfassung + + +
+ + + + + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..cbc1d03 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "timeclock-frontend", + "version": "3.0.0", + "description": "TimeClock v3 - Frontend für Zeiterfassung", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.3.11", + "vue-router": "^4.2.5", + "pinia": "^2.1.7" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.5.2", + "vite": "^5.0.8" + } +} + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..b7b7138 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,255 @@ + + + + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000..3a58bc5 --- /dev/null +++ b/frontend/src/assets/main.css @@ -0,0 +1,158 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #ffffff; + color: #000000; +} + +#app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +h1, h2, h3, h4, h5, h6 { + margin-top: 0; +} + +button { + cursor: pointer; + font-family: inherit; +} + +.container { + max-width: 100%; + margin: 0 auto; + padding: 0 3rem; +} + +/* Utility Classes */ +.card { + background: #fafafa; + border: 1px solid #e0e0e0; + padding: 1.5rem; + margin-bottom: 1.5rem; + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); + transition: box-shadow 0.2s, border-color 0.2s; +} + +.card:hover { + border-color: #ccc; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08); +} + +.btn { + padding: 0.5rem 1rem; + border: 1px solid #ccc; + border-radius: 4px; + font-weight: normal; + font-size: 0.95rem; + cursor: pointer; + background-color: #f5f5f5; + color: #333; + transition: background-color 0.2s; +} + +.btn:hover { + background-color: #e5e5e5; +} + +.btn-primary { + background-color: #5bc0de; + border-color: #46b8da; + color: white; +} + +.btn-primary:hover { + background-color: #31b0d5; + border-color: #269abc; +} + +.btn-success { + background-color: #5cb85c; + border-color: #4cae4c; + color: white; +} + +.btn-success:hover { + background-color: #449d44; + border-color: #398439; +} + +.btn-danger { + background-color: #d9534f; + border-color: #d43f3a; + color: white; +} + +.btn-danger:hover { + background-color: #c9302c; + border-color: #ac2925; +} + +.btn-secondary { + background-color: #6c757d; + border-color: #6c757d; + color: white; +} + +.btn-secondary:hover { + background-color: #5a6268; +} + +.input { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.input:focus { + outline: none; + border-color: #5bc0de; + box-shadow: 0 0 0 3px rgba(91, 192, 222, 0.1); +} + +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #333; +} + +.grid { + display: grid; + gap: 1.5rem; +} + +.grid-2 { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} + +.text-center { + text-align: center; +} + +.mt-1 { margin-top: 0.5rem; } +.mt-2 { margin-top: 1rem; } +.mt-3 { margin-top: 1.5rem; } +.mt-4 { margin-top: 2rem; } + +.mb-1 { margin-bottom: 0.5rem; } +.mb-2 { margin-bottom: 1rem; } +.mb-3 { margin-bottom: 1.5rem; } +.mb-4 { margin-bottom: 2rem; } + diff --git a/frontend/src/components/SideMenu.vue b/frontend/src/components/SideMenu.vue new file mode 100644 index 0000000..7987237 --- /dev/null +++ b/frontend/src/components/SideMenu.vue @@ -0,0 +1,190 @@ + + + + + + + diff --git a/frontend/src/components/StatusBox.vue b/frontend/src/components/StatusBox.vue new file mode 100644 index 0000000..68ff677 --- /dev/null +++ b/frontend/src/components/StatusBox.vue @@ -0,0 +1,522 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..4ef2d45 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,25 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './assets/main.css' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) +app.use(router) + +// Session beim App-Start wiederherstellen +import { useAuthStore } from './stores/authStore' +const authStore = useAuthStore() + +// Versuche Token aus localStorage zu laden +if (authStore.loadToken()) { + authStore.fetchCurrentUser().catch(err => { + console.error('Session-Wiederherstellung fehlgeschlagen:', err) + }) +} + +app.mount('#app') + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..f2b6147 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,104 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/authStore' + +// Views +import Entries from '../views/Entries.vue' +import Stats from '../views/Stats.vue' +import Login from '../views/Login.vue' +import Register from '../views/Register.vue' +import PasswordForgot from '../views/PasswordForgot.vue' +import PasswordReset from '../views/PasswordReset.vue' +import OAuthCallback from '../views/OAuthCallback.vue' +import WeekOverview from '../views/WeekOverview.vue' + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + // Auth-Routes (öffentlich) + { + path: '/login', + name: 'login', + component: Login, + meta: { requiresGuest: true } + }, + { + path: '/register', + name: 'register', + component: Register, + meta: { requiresGuest: true } + }, + { + path: '/password-forgot', + name: 'password-forgot', + component: PasswordForgot, + meta: { requiresGuest: true } + }, + { + path: '/password-reset', + name: 'password-reset', + component: PasswordReset, + meta: { requiresGuest: true } + }, + { + path: '/oauth-callback', + name: 'oauth-callback', + component: OAuthCallback + }, + + // Geschützte Routes + { + path: '/', + redirect: '/bookings/week' + }, + { + path: '/bookings/week', + name: 'week-overview', + component: WeekOverview, + meta: { requiresAuth: true } + }, + { + path: '/entries', + name: 'entries', + component: Entries, + meta: { requiresAuth: true } + }, + { + path: '/stats', + name: 'stats', + component: Stats, + meta: { requiresAuth: true } + } + ] +}) + +// Navigation Guards +router.beforeEach(async (to, from, next) => { + const authStore = useAuthStore() + + // Session-Wiederherstellung beim ersten Laden + if (!authStore.isAuthenticated && authStore.loadToken()) { + try { + await authStore.fetchCurrentUser() + } catch (error) { + console.error('Session-Wiederherstellung fehlgeschlagen:', error) + authStore.clearAuth() + } + } + + const requiresAuth = to.matched.some(record => record.meta.requiresAuth) + const requiresGuest = to.matched.some(record => record.meta.requiresGuest) + + if (requiresAuth && !authStore.isAuthenticated) { + // Geschützte Route aber nicht eingeloggt -> Login + next({ name: 'login', query: { redirect: to.fullPath } }) + } else if (requiresGuest && authStore.isAuthenticated) { + // Guest-Route aber bereits eingeloggt -> Wochenübersicht + next({ name: 'week-overview' }) + } else { + // Alles OK + next() + } +}) + +export default router + diff --git a/frontend/src/stores/authStore.js b/frontend/src/stores/authStore.js new file mode 100644 index 0000000..383c0a7 --- /dev/null +++ b/frontend/src/stores/authStore.js @@ -0,0 +1,338 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +const API_URL = 'http://localhost:3010/api' + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const token = ref(null) + const isAuthenticated = ref(false) + const isLoading = ref(false) + + /** + * Token in localStorage speichern + */ + const saveToken = (newToken) => { + token.value = newToken + localStorage.setItem('timeclock_token', newToken) + } + + /** + * Token aus localStorage laden + */ + const loadToken = () => { + const savedToken = localStorage.getItem('timeclock_token') + if (savedToken) { + token.value = savedToken + return savedToken + } + return null + } + + /** + * Token und Benutzerdaten löschen + */ + const clearAuth = () => { + token.value = null + user.value = null + isAuthenticated.value = false + localStorage.removeItem('timeclock_token') + } + + /** + * Registrierung + */ + const register = async (userData) => { + try { + isLoading.value = true + + const response = await fetch(`${API_URL}/auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(userData) + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Registrierung fehlgeschlagen') + } + + return data + } catch (error) { + console.error('Registrierungsfehler:', error) + throw error + } finally { + isLoading.value = false + } + } + + /** + * Login + */ + const login = async (credentials) => { + try { + isLoading.value = true + + const response = await fetch(`${API_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(credentials) + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Login fehlgeschlagen') + } + + // Token speichern + saveToken(data.token) + + // Benutzerdaten setzen + user.value = data.user + isAuthenticated.value = true + + return data + } catch (error) { + console.error('Login-Fehler:', error) + clearAuth() + throw error + } finally { + isLoading.value = false + } + } + + /** + * Logout + */ + const logout = async () => { + try { + if (token.value) { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token.value}` + } + }) + } + } catch (error) { + console.error('Logout-Fehler:', error) + } finally { + clearAuth() + } + } + + /** + * Aktuellen Benutzer laden (für Session-Wiederherstellung) + */ + const fetchCurrentUser = async () => { + try { + const savedToken = loadToken() + + if (!savedToken) { + return false + } + + isLoading.value = true + + const response = await fetch(`${API_URL}/auth/me`, { + headers: { + 'Authorization': `Bearer ${savedToken}` + } + }) + + const data = await response.json() + + if (!response.ok) { + // Token ungültig - ausloggen + clearAuth() + return false + } + + // Session wiederherstellen + user.value = data.user + isAuthenticated.value = true + + return true + } catch (error) { + console.error('Fehler beim Laden des Benutzers:', error) + clearAuth() + return false + } finally { + isLoading.value = false + } + } + + /** + * Token validieren + */ + const validateToken = async () => { + try { + const savedToken = loadToken() + + if (!savedToken) { + return false + } + + const response = await fetch(`${API_URL}/auth/validate`, { + headers: { + 'Authorization': `Bearer ${savedToken}` + } + }) + + if (!response.ok) { + clearAuth() + return false + } + + return true + } catch (error) { + console.error('Token-Validierungsfehler:', error) + clearAuth() + return false + } + } + + /** + * Passwort-Reset anfordern + */ + const requestPasswordReset = async (email) => { + try { + isLoading.value = true + + const response = await fetch(`${API_URL}/auth/request-reset`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email }) + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Anfrage fehlgeschlagen') + } + + return data + } catch (error) { + console.error('Passwort-Reset-Anfrage-Fehler:', error) + throw error + } finally { + isLoading.value = false + } + } + + /** + * Passwort zurücksetzen + */ + const resetPassword = async (resetToken, password) => { + try { + isLoading.value = true + + const response = await fetch(`${API_URL}/auth/reset-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + token: resetToken, + password + }) + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Passwort-Reset fehlgeschlagen') + } + + return data + } catch (error) { + console.error('Passwort-Reset-Fehler:', error) + throw error + } finally { + isLoading.value = false + } + } + + /** + * Passwort ändern (eingeloggter Benutzer) + */ + const changePassword = async (oldPassword, newPassword) => { + try { + isLoading.value = true + + const response = await fetch(`${API_URL}/auth/change-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token.value}` + }, + body: JSON.stringify({ + oldPassword, + newPassword + }) + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Passwort-Änderung fehlgeschlagen') + } + + return data + } catch (error) { + console.error('Passwort-Änderungs-Fehler:', error) + throw error + } finally { + isLoading.value = false + } + } + + /** + * HTTP-Header mit Authorization zurückgeben + */ + const getAuthHeaders = () => { + const savedToken = token.value || loadToken() + + if (savedToken) { + return { + 'Authorization': `Bearer ${savedToken}`, + 'Content-Type': 'application/json' + } + } + + return { + 'Content-Type': 'application/json' + } + } + + return { + // State + user, + token, + isAuthenticated, + isLoading, + + // Actions + register, + login, + logout, + fetchCurrentUser, + validateToken, + requestPasswordReset, + resetPassword, + changePassword, + getAuthHeaders, + loadToken, + saveToken, + clearAuth + } +}) + diff --git a/frontend/src/stores/timeStore.js b/frontend/src/stores/timeStore.js new file mode 100644 index 0000000..05b2323 --- /dev/null +++ b/frontend/src/stores/timeStore.js @@ -0,0 +1,125 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { useAuthStore } from './authStore' + +const API_URL = 'http://localhost:3010/api' + +export const useTimeStore = defineStore('time', () => { + const entries = ref([]) + const currentEntry = ref(null) + + /** + * API-Request mit Auth-Header + */ + const fetchWithAuth = async (url, options = {}) => { + const authStore = useAuthStore() + const headers = authStore.getAuthHeaders() + + const response = await fetch(url, { + ...options, + headers: { + ...headers, + ...options.headers + } + }) + + // Bei 401 Unauthorized -> Logout + if (response.status === 401) { + authStore.clearAuth() + window.location.href = '/login' + throw new Error('Session abgelaufen') + } + + return response + } + + const fetchEntries = async () => { + try { + const response = await fetchWithAuth(`${API_URL}/time-entries`) + const data = await response.json() + entries.value = data + + // Finde laufenden Eintrag + currentEntry.value = data.find(entry => entry.isRunning) || null + } catch (error) { + console.error('Fehler beim Laden der Einträge:', error) + } + } + + const startTimer = async (entryData) => { + try { + const response = await fetchWithAuth(`${API_URL}/time-entries`, { + method: 'POST', + body: JSON.stringify(entryData) + }) + + const newEntry = await response.json() + currentEntry.value = newEntry + entries.value.unshift(newEntry) + } catch (error) { + console.error('Fehler beim Starten des Timers:', error) + } + } + + const stopTimer = async () => { + if (!currentEntry.value) return + + try { + const response = await fetchWithAuth(`${API_URL}/time-entries/${currentEntry.value.id}`, { + method: 'PUT', + body: JSON.stringify({ + endTime: new Date().toISOString() + }) + }) + + const updatedEntry = await response.json() + + // Aktualisiere Eintrag in Liste + const index = entries.value.findIndex(e => e.id === updatedEntry.id) + if (index !== -1) { + entries.value[index] = updatedEntry + } + + currentEntry.value = null + } catch (error) { + console.error('Fehler beim Stoppen des Timers:', error) + } + } + + const deleteEntry = async (id) => { + try { + await fetchWithAuth(`${API_URL}/time-entries/${id}`, { + method: 'DELETE' + }) + + entries.value = entries.value.filter(entry => entry.id !== id) + + if (currentEntry.value && currentEntry.value.id === id) { + currentEntry.value = null + } + } catch (error) { + console.error('Fehler beim Löschen des Eintrags:', error) + } + } + + const fetchStats = async () => { + try { + const response = await fetchWithAuth(`${API_URL}/time-entries/stats/summary`) + return await response.json() + } catch (error) { + console.error('Fehler beim Laden der Statistiken:', error) + return {} + } + } + + return { + entries, + currentEntry, + fetchEntries, + startTimer, + stopTimer, + deleteEntry, + fetchStats + } +}) + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..309c491 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,265 @@ + + + + + + diff --git a/frontend/src/views/Entries.vue b/frontend/src/views/Entries.vue new file mode 100644 index 0000000..1cab6b3 --- /dev/null +++ b/frontend/src/views/Entries.vue @@ -0,0 +1,209 @@ + + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..b3e6d52 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/frontend/src/views/OAuthCallback.vue b/frontend/src/views/OAuthCallback.vue new file mode 100644 index 0000000..64fe514 --- /dev/null +++ b/frontend/src/views/OAuthCallback.vue @@ -0,0 +1,97 @@ + + + + + + + + diff --git a/frontend/src/views/PasswordForgot.vue b/frontend/src/views/PasswordForgot.vue new file mode 100644 index 0000000..50ceb50 --- /dev/null +++ b/frontend/src/views/PasswordForgot.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/frontend/src/views/PasswordReset.vue b/frontend/src/views/PasswordReset.vue new file mode 100644 index 0000000..c236008 --- /dev/null +++ b/frontend/src/views/PasswordReset.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue new file mode 100644 index 0000000..1791c52 --- /dev/null +++ b/frontend/src/views/Register.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/frontend/src/views/Stats.vue b/frontend/src/views/Stats.vue new file mode 100644 index 0000000..4bd1414 --- /dev/null +++ b/frontend/src/views/Stats.vue @@ -0,0 +1,204 @@ + + + + + + diff --git a/frontend/src/views/WeekOverview.vue b/frontend/src/views/WeekOverview.vue new file mode 100644 index 0000000..921801b --- /dev/null +++ b/frontend/src/views/WeekOverview.vue @@ -0,0 +1,697 @@ + + + + + \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..b24888a --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,22 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + port: 5010, + proxy: { + '/api': { + target: 'http://localhost:3010', + changeOrigin: true + } + } + } +}) + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8a8b575 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4011 @@ +{ + "name": "timeclock", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "timeclock", + "version": "3.0.0", + "license": "ISC", + "workspaces": [ + "backend", + "frontend" + ], + "devDependencies": { + "concurrently": "^8.2.2" + } + }, + "backend": { + "name": "timeclock-backend", + "version": "3.0.0", + "license": "ISC", + "dependencies": { + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "crypto": "^1.0.1", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-session": "^1.18.0", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "mysql2": "^3.6.5", + "nodemailer": "^6.9.7", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "sequelize": "^6.35.2" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } + }, + "frontend": { + "name": "timeclock-frontend", + "version": "3.0.0", + "dependencies": { + "pinia": "^2.1.7", + "vue": "^3.3.11", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.5.2", + "vite": "^5.0.8" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.7.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", + "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.14.0" + } + }, + "node_modules/@types/validator": { + "version": "13.15.3", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz", + "integrity": "sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", + "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.0.0 || ^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz", + "integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "@vue/shared": "3.5.22", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz", + "integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz", + "integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "@vue/compiler-core": "3.5.22", + "@vue/compiler-dom": "3.5.22", + "@vue/compiler-ssr": "3.5.22", + "@vue/shared": "3.5.22", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.19", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz", + "integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz", + "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz", + "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz", + "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.22", + "@vue/runtime-core": "3.5.22", + "@vue/shared": "3.5.22", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz", + "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.22", + "@vue/shared": "3.5.22" + }, + "peerDependencies": { + "vue": "3.5.22" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz", + "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==", + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", + "license": "ISC" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dottie": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", + "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "engines": [ + "node >= 0.4.0" + ], + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/lru.min": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.2.tgz", + "integrity": "sha512-kFm5+jbwR5mC+lo+3Cy46eHiykWSpUtTLOH3GE+AR7GeLq8PgfJcvpMiyVWk9/O53DjQsqm6a3VOOfq7gYWFRg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", + "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/sequelize": { + "version": "6.37.7", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.7.tgz", + "integrity": "sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/sequelize" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.6", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.1", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.4", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependenciesMeta": { + "ibm_db": { + "optional": true + }, + "mariadb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-hstore": { + "optional": true + }, + "snowflake-sdk": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/sequelize/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/sequelize/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/timeclock-backend": { + "resolved": "backend", + "link": true + }, + "node_modules/timeclock-frontend": { + "resolved": "frontend", + "link": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", + "license": "MIT" + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.15", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", + "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", + "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.22", + "@vue/compiler-sfc": "3.5.22", + "@vue/runtime-dom": "3.5.22", + "@vue/server-renderer": "3.5.22", + "@vue/shared": "3.5.22" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.0.tgz", + "integrity": "sha512-YRrWLi4ayHe1d6zyH6sMPwF/WwcDY8XgUOfQGa0Kx4kmugSorLavD1ExrM/Y83B4X2NQMXYpJFSq2pbZh9ildQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8ee9c88 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "timeclock", + "version": "3.0.0", + "description": "TimeClock v3 - Full-Stack Zeiterfassungsanwendung", + "private": true, + "workspaces": [ + "backend", + "frontend" + ], + "scripts": { + "install:all": "npm install && cd backend && npm install && cd ../frontend && npm install", + "dev:backend": "cd backend && npm run dev", + "dev:frontend": "cd frontend && npm run dev", + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "start:backend": "cd backend && npm start", + "build:frontend": "cd frontend && npm run build" + }, + "keywords": [ + "timeclock", + "zeiterfassung", + "time-tracking", + "vue3", + "nodejs" + ], + "author": "", + "license": "ISC", + "devDependencies": { + "concurrently": "^8.2.2" + } +} + + + diff --git a/worklog_backup_20251015_215729.sql b/worklog_backup_20251015_215729.sql new file mode 100644 index 0000000..b1ffe7d --- /dev/null +++ b/worklog_backup_20251015_215729.sql @@ -0,0 +1,2 @@ +mysqldump: Deprecated program name. It will be removed in a future release, use '/usr/bin/mariadb-dump' instead +mysqldump: Got error: 1045: "Access denied for user 'timeclock_user'@'localhost' (using password: YES)" when trying to connect