# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview This is the OMSORG website plus **OMSORG Connect**, an internal employee web app ("Mitarbeiter-App") for a German nursing care company. There is no build system — everything is plain PHP, HTML, CSS, and vanilla JS deployed directly to a web server (`htdocs`). **OMSORG Connect wird gerade komplett neu aufgebaut.** `mitarbeiter-app-legacy/` ist die alte, produktiv gelaufene Version — dient nur noch als Referenz/Vorlage, wird nicht mehr weiterentwickelt. Der Rest dieser Datei beschreibt `mitarbeiter-app-legacy/`, nicht den Neuaufbau. Die aktive Entwicklung findet in `mitarbeiter-app/` statt (aktuell: Login-Flow + eigenes Passwort ändern/zurücksetzen gegen `omsorgCore`, siehe unten "Neuaufbau"). Auth in der Legacy-Version läuft **nicht mehr** über lokales bcrypt/Session-Lockout wie unten in "Entry point" beschrieben — das ist bereits auf omsorgCore-JWT-Auth umgestellt (`lib/omsorgCoreClient.php`, `lib/auth.php`), lokales MySQL dient dort nur noch als Read-Cache für Profildaten. ### Neuaufbau (`mitarbeiter-app/`) Frischer, minimaler PHP-Flow gegen `omsorgCore` — kein Framework, gleiches Deployment-Modell wie die Legacy-App. Login/Passwort, das Abwesenheits-/Urlaubs-/Krankmeldungsformular und die strukturierte Zeiterfassung pro Schicht (siehe unten) sind umgesetzt; weiterhin bewusst (noch) ohne: Dienstplan, Downloads, Admin-Oberfläche, PWA-Assets, MySQL. Admin-/Mitarbeiterverwaltung gehört nicht hierher, sondern exklusiv zu OMSORG Desktop (`omsorgapp`) — Connect zeigt/bearbeitet ausschließlich Daten des eingeloggten Nutzers selbst. **Abwesenheits-/Urlaubs-/Krankmeldungsanträge (FR-CON-1, `pages/urlaubsantrag.php`):** einzige Seite in diesem Neuaufbau mit echtem Formular + eigener Datenliste. Formular (Art/Zeitraum/Grund/Vertretung/Nachricht) postet inline auf sich selbst (kein separates `actions/*.php` wie in der Legacy-App) über `omsorgcore_absences_create()` (`lib/omsorgCoreClient.php`) gegen `POST /api/absences` in `omsorgCore` — schickt bewusst **keine** `employeeId` mit, das Backend löst den eingeloggten Mitarbeiter serverseitig über den JWT-Claim auf (`AbsenceService.CreateAsync`, siehe `omsorgCore/CLAUDE.md`). Darunter die eigene Antragshistorie über `omsorgcore_absences_list()` (Own-Scope filtert automatisch serverseitig, kein `employeeId`-Parameter nötig). Die Art-Dropdown-Optionen kommen über den neuen generischen `omsorgcore_value_list_items($config, $token, $key)`-Wrapper (erste Nicht-Auth-Verwendung des generierten PHP-Clients hier) aus `GET /api/value-lists/AbsenceType/items`, nicht hartcodiert. Genehmigen/Ablehnen passiert ausschließlich in `omsorgapp` (`AbsencesPage`) — Connect selbst hat keine Entscheidungs-UI, nur Anlegen/Bearbeiten + eigenen Status einsehen. Zweispaltiges Layout (`.split-layout` in `app.css`, Liste links/Formular rechts, bricht unter 860px auf eine Spalte um) statt gestapelter Karten. **Bearbeiten eines eigenen Antrags:** jeder eigene Antrag im initialen Status bekommt in der Liste einen "Bearbeiten"-Link (`urlaubsantrag.php?edit=`) — das rechte Formular wechselt dann in den Edit-Modus (vorbefüllt aus dem passenden Eintrag der bereits geladenen `$absences`-Liste, kein extra `GET`), postet über `omsorgcore_absences_update()` gegen `PUT /api/absences/{id}` (mit `mode=edit`/`absence_id` als Hidden-Fields, um im selben Formular-Handler zwischen Anlegen und Bearbeiten zu unterscheiden) und leitet bei Erfolg per Post-Redirect-Get auf `urlaubsantrag.php?updated=1` weiter. Der initiale Status wird dynamisch über `$initialStatus` ermittelt (`omsorgcore_value_list_items(..., 'AbsenceStatus')`, das Item mit `isInitial === true` — **nicht** der Literal `"Eingereicht"`, siehe `omsorgCore/CLAUDE.md` "Abwesenheits-/Urlaubs-/Krankmeldungsanträge"; ein Umbenennen über die Status-Verwaltung in `omsorgapp` bricht diese Seite dadurch nicht). Serverseitig (nicht nur hier) gilt dieselbe Regel: nur solange der Antrag im initialen Status ist, danach `400` — ein bereits genehmigter/abgelehnter Antrag fällt deshalb defensiv aus dem Edit-Modus zurück auf "Neuer Antrag", falls doch mal ein veralteter Link aufgerufen wird. Braucht `has_permission('Absences','Edit')` zusätzlich zu `View`. **Zeiterfassung (FR-ZE-1/FR-ZE-2, `pages/stundenerfassung.php`):** 1:1 nach dem Muster von `urlaubsantrag.php` (Liste links/Formular rechts, inline-POST auf sich selbst, `mode=create|edit` als Hidden-Field), aber mit drei statt zwei möglichen Aktionen, weil `TimeEntryStatus` eine echte Mehrstufen-Pipeline statt einer binären Entscheidung ist (siehe `omsorgCore/CLAUDE.md` "Zeiterfassung"): Anlegen (`omsorgcore_time_entries_create()` gegen `POST /api/time-entries`, ohne `employeeId`), Bearbeiten solange `isEditableByOwner` (`omsorgcore_time_entries_update()` gegen `PUT /api/time-entries/{id}`, ohne `statusId`) und zusätzlich ein dritter `mode=submit`-Zweig im selben POST-Handler (`omsorgcore_time_entries_submit()` gegen `POST /api/time-entries/{id}/submit`, kein Payload) für den "Einreichen"-Button. Der "Einreichen"-Button erscheint nur bei Einträgen, deren `statusId` in der Menge der Selbst-Einreichungs-Kanten liegt (`omsorgcore_value_list_transitions($config, $token, 'TimeEntryStatus')`, gefiltert auf `requiresApproval === false`) — bewusst **nicht** dasselbe Kriterium wie für den "Bearbeiten"-Link (`isEditableByOwner` allein reicht hier nicht, weil auch der bereits eingereichte, aber noch nicht geprüfte Status `isEditableByOwner=true` trägt, aber keine ausgehende Selbst-Einreichungs-Kante mehr hat). Auftrags-Dropdown über `omsorgcore_orders_list()` (`GET /api/orders`) — zeigt mangels Mitarbeiter-Zuweisung auf `Order` (FR-EM-3 offen) bewusst alle aktiven Aufträge, nicht nur zugewiesene. Genehmigen/Prüfen/Freigeben passiert ausschließlich in `omsorgapp` (`TimeEntriesPage`) — Connect selbst hat keine Entscheidungs-UI. **Rechte im Client (`has_permission()`, `lib/auth.php`):** serverseitig ist jeder `omsorgCore`-Endpunkt ohnehin über `[RequirePermission]` gegated (siehe `omsorgCore/CLAUDE.md`, "Rechtesystem") — `has_permission(string $module, string $action): bool` ist nur die UI-Seite davon, analog zu `hasPermission()` in `omsorgapp/src/app/AuthContext.jsx`, liest `$_SESSION['omsorgcore_profile']['permissions']` (aus `GET /api/auth/me`, `PermissionDto[] { module, action, scope }`). `lib/layout.php` blendet den "Urlaub & Abwesenheit"-Tab aus, wenn `!has_permission('Absences','View')`; `pages/urlaubsantrag.php` leitet ohne dieses Recht direkt auf `dashboard.php` um (kein "leere Seite ohne Erklärung"-Fall) und blendet zusätzlich separat das Formular aus, wenn `!has_permission('Absences','Create')` (z. B. für eine Rolle mit `View`, aber ohne `Create`) — beide Prüfungen sind rein kosmetisch, ein direkt gepostetes Formular ohne UI wird serverseitig trotzdem mit `403` abgelehnt, wird hier aber zusätzlich mit einer klaren deutschen Fehlermeldung statt eines stillen Fehlschlags abgefangen. Neue Connect-Seiten mit einem Rechte-Bezug sollten `has_permission()` nach demselben Muster nutzen, statt ungegated jedem eingeloggten Nutzer alles zu zeigen. **Passwort ändern/vergessen:** `pages/settings.php` (nach Login, `require_login()`) und `pages/forgot-password.php` (vor Login, 3-stufig: Code anfordern → verifizieren → neues Passwort setzen), beide über die dafür in `lib/omsorgCoreClient.php` ergänzten `omsorgcore_change_password`/`omsorgcore_forgot_password_*`-Wrapper gegen dieselben `omsorgCore`-Endpunkte wie in `mitarbeiter-app-legacy`. Die Mindestlänge kommt nicht hartcodiert, sondern über `omsorgcore_password_policy()` (`GET /api/auth/password-policy`, siehe `omsorgCore/CLAUDE.md` Abschnitt "Passwort-Mindestlänge") — sowohl für das `minlength`-Attribut der Formularfelder als auch für die serverseitige Vorab-Fehlermeldung; die eigentliche Durchsetzung passiert im Backend. Nach erfolgreichem Anlegen/Admin-Reset eines Accounts (`mustChangePassword`-Flag aus der Login-Response, siehe `lib/auth.php`) leitet `pages/dashboard.php` erzwungen zu `settings.php` weiter. `forgot-password.php` geht bei Schritt "request" bewusst **immer** zu Schritt "verify" weiter, unabhängig davon, ob der Username existiert (kein Enumeration-Rückschluss, siehe `omsorgCore/CLAUDE.md` "Passwort-Reset/E-Mail-Versand") — **außer** der Status ist `"email_unavailable"` (E-Mail-Versand aktuell gestört, z. B. SMTP down): dann bleibt die Seite auf Schritt "request" und zeigt eine klare Fehlermeldung, statt den Nutzer auf eine Code-Eingabe warten zu lassen, die nie ankommt. This project is one part of the OMSORG monorepo — see the root `CLAUDE.md` (`../CLAUDE.md`) for the overall platform picture and `../REQUIREMENTS.md` for functional/non-functional requirements with FR-IDs. This app is the reference implementation for **OMSORG Connect**; its MySQL database is expected to eventually move behind the shared `omsorgCore` backend (currently empty, planned as C#/.NET + PostgreSQL) rather than staying a standalone data store. ## Deployment Upload the full directory contents to the server's `htdocs` folder and overwrite existing files. After deploying, clear the browser cache or test in an incognito window. There are no build steps, package managers, or test runners. ## Architecture ### Public website (`/`) Static HTML with a contact form: - `index.html` — main landing page - `send-form.php` — handles the public contact form, sends email via PHP `mail()` - `send-status-template.php` — reusable status page template for form results - `datenschutz.html`, `impressum.html` — legal pages ### Employee app (`/mitarbeiter-app/`) A session-based PHP app with no framework, backed by a **MySQL database** (PDO). #### Entry point - `index.php` — login page. Redirects to `pages/dashboard.php` if already logged in. Includes rate limiting: 5 failed attempts trigger a 10-minute lockout. - `setup.php` — one-time setup script (run once after first deploy to bootstrap the DB) #### Library (`lib/`) - `auth.php` — included at the top of every protected page. Provides `require_login()`, `require_admin()`, `is_logged_in()`, `is_admin()`, `current_user()`, `current_name()`, `current_username()`, `csrf_token()`, `csrf_field()`, `verify_csrf()`, `e()`. Users are stored in the `users` DB table. Login is case-insensitive on username. - `db.php` — provides `db(): PDO` (singleton). Reads credentials from `config.php`, runs pending migrations on first connection, and sets `PDO::ERRMODE_EXCEPTION`. - `config.php` — returns array with MySQL DSN/credentials, SMTP settings, and mail recipient addresses. Import with `$config = require __DIR__ . '/config.php';`. - `layout.php` — shared sidebar layout. Call `layout_start($title, $current_page)` and `layout_end()` to wrap page content. Renders the sidebar nav, user avatar, and injects the service worker. - `mail.php` — provides `smtp_send($cfg, $to, $subject, $body, $attach = [])`. Raw SMTP implementation (no PHPMailer), supports SSL (port 465) and STARTTLS (port 587), and optional file attachments. #### Pages (`pages/`) Each page is a standalone PHP file using `layout_start`/`layout_end`: - `dashboard.php` — main landing page after login; shows news and quick links - `stundennachweis.php` — upload monthly timesheet (PDF/image) - `urlaubsantrag.php` — vacation request form - `abwesenheitsantrag.php` — absence request form (sick leave, etc.) - `benefitsantrag.php` — employee benefits request - `fortbildungsantrag.php` — training request with optional file upload - `einsatzbewertung.php` — rate a deployment/assignment - `einsatzanweisung.php` — view/download personal assignment instructions (PDF) - `dokumentenarchiv.php` — personal document archive (upload/view own documents) - `downloads.php` — company-wide file downloads (admin-managed) - `dienstplan.php` — view personal shift schedule - `werben.php` — refer a new employee - `settings.php` — profile settings: change password, upload avatar - `admin.php` — admin-only: tabs for Anträge, Nutzer, Dienstplan, Downloads, Fortbildungsmaterial, Stundennachweis, Bewertungen, News, Einsatzanweisung, Login-Versuche (failed login attempts: lists recent attempts, shows currently locked IPs, and clears the `login_attempts` table) - `admin-dienstplan.php` — admin shift planner view - Serve pages (`*-serve.php`, `dokument-serve.php`, `download-serve.php`, etc.) — stream protected files from `uploads/`, `downloads/`, `fortbildung-materials/` with auth check #### Actions (`actions/`) POST-only endpoints, each does one thing and redirects back: - `submit-urlaubsantrag.php`, `submit-abwesenheitsantrag.php`, `submit-benefitsantrag.php`, `submit-fortbildungsantrag.php`, `submit-stundennachweis.php`, `submit-einsatzbewertung.php`, `submit-werben.php` — insert into the matching `requests_*` DB table, send notification email via `smtp_send()` - `admin-action.php` — admin status updates (accept/reject requests), user management (add/delete/reset password), news CRUD - `save-dienstplan.php` — admin saves shift entries - `downloads-action.php`, `downloads-reorder.php` — admin manages downloadable files - `fortbildung-material-action.php`, `fortbildung-material-reorder.php` — admin manages training materials - `einsatzanweisung-action.php` — admin uploads/assigns personal instruction PDFs - `upload-dokument.php`, `delete-dokument.php` — user document archive management - `upload-avatar.php` — user uploads profile picture (stored in `assets/avatars/`) - `save-profile.php` — user changes own password - `logout.php` — destroys session, redirects to `../index.php` #### Frontend - `app.css` — all styles (no framework) - `manifest.webmanifest` + `service-worker.js` — PWA support ("Add to Home Screen") ### Database schema (MySQL) Managed via migrations in `migrations/`. Key tables: - `users` — id, username, name, role, password_hash, active, created_at, email, telefon, avatar - `requests_urlaubsantrag` — vacation requests (von, bis, vertretung, nachricht, status, admin_note) - `requests_abwesenheitsantrag` — absence requests (von, bis, grund, vertretung, nachricht, status, admin_note) - `requests_benefitsantrag` — benefits requests - `requests_fortbildungsantrag` — training requests (with optional file upload) - `requests_stundennachweis` — timesheet uploads (monat, filename) - `requests_werben` — employee referrals - `dienstplan` — shift schedule (user_id, date, schicht; UNIQUE on user_id+date) - `downloads` — company download files (title, filename, sort_order) - `fortbildung_materials` — training material files (title, filename, sort_order) - `dokumente` — user personal document archive (user_id, kategorie, filename) - `einsatzbewertungen` — deployment ratings - `einsatzanweisung` — one PDF per user (UNIQUE on user_id) - `news` — company news posts (title, text, date) - `schema_migrations` — tracks applied migrations ### Migration system `db.php` runs `_run_migrations()` on every connection. Migrations are PHP files in `migrations/` returning `['description' => ..., 'up' => [...SQL...]]`. They are applied in filename order and tracked in `schema_migrations`. Already-existing DBs without the migrations table are stamped as fully applied on first run. ### File storage Uploaded files are stored outside webroot or protected by `.htaccess`: - `uploads/` — user form attachments (stundennachweis, fortbildung, einsatzanweisung). Filename pattern: `{date}_{username}_{type}_{randomhex}.{ext}` - `downloads/` — admin-managed company downloads (hashed filenames) - `fortbildung-materials/` — admin-managed training materials (hashed filenames) - `assets/avatars/` — user profile pictures (`{username}_{randomhex}.{ext}`) Allowed upload types: pdf, doc, docx, jpg, jpeg, png. Max size: 12 MB. ### Security conventions - `lib/` is blocked from direct HTTP access via `lib/.htaccess` - `uploads/`, `downloads/`, `fortbildung-materials/`, `data/`, `migrations/` all have `.htaccess` denying direct access; files are served only through `*-serve.php` pages with auth checks - All output uses `e()` (alias for `htmlspecialchars`) to prevent XSS - Every POST form includes a CSRF token (`csrf_field()` in form, `verify_csrf()` in action) - Login rate-limiting: 5 failures → 10-minute session lockout - Uploads are renamed to random hex filenames before storage ### Email (configured in `config.php`) - SMTP via `smtp_send()` in `lib/mail.php`; credentials in `config.php` - `mail_info` → `info@omsorg-pflegedienste.de` (Geschäftsführung) - `mail_sabrina` → `s.berggoetz@omsorg-pflegedienste.de` (Disposition) - `mail_from` → `no-reply@omsorg-pflegedienste.de`