Reorganize into monorepo layout, move mitarbeiter-app to legacy reference

Consolidates the previously separate omsorgapp and omsorgCore repos
(each had their own nested .git with GitHub history) plus the old
root-level website/mitarbeiter-app into a single monorepo, matching
the structure already documented in the root CLAUDE.md. Also moves
the PHP employee app aside as omsorgWeb/mitarbeiter-app-legacy/ to
serve as a template for a ground-up rewrite.

Fixes .gitignore in the same pass: the config-secrets/uploads/data
patterns were unanchored (relative to repo root, not depth-agnostic),
so they silently stopped matching once the app moved under omsorgWeb/.
Patterns are now **/-prefixed and cover both mitarbeiter-app and
mitarbeiter-app-legacy, keeping DB/SMTP credentials and uploaded
employee documents out of version control.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-07 14:21:37 +02:00
co-authored by Claude Sonnet 5
parent 8beb0fcf52
commit b6c1389c55
355 changed files with 24348 additions and 361 deletions
@@ -0,0 +1 @@
Require all denied
@@ -0,0 +1,209 @@
<?php
// Auth (Login, Passwort, Rechte) läuft jetzt vollständig über omsorgCore (siehe lib/omsorgCoreClient.php) -
// kein lokaler Credential-/Rollen-Check mehr, `password_hash` wird beim Cutover gelöscht (siehe
// migrate-users-to-core.php).
//
// Die lokale `users`-Tabelle bleibt trotzdem bestehen, aber nur noch als **Lesecache** für Name/
// E-Mail/Telefon/Avatar/Aktiv-Status - Dutzende bestehende Seiten (admin.php, dienstplan.php, ...)
// joinen darauf für Anzeige/Filter, das ist bewusst nicht Teil dieser Migrationsrunde (siehe Plan,
// Abschnitt "Out of Scope"). Der Cache wird bei jedem Login/Session-Refresh aus omsorgCore
// nachgezogen (_refresh_profile()) und bei jeder Admin-Aktion sofort mitgeschrieben - omsorgCore
// bleibt die Quelle der Wahrheit, die lokale Zeile ist nur eine synchron gehaltene Kopie plus die
// stabile Integer-`id`, die die restlichen (noch nicht migrierten) Tabellen als Fremdschlüssel nutzen.
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/omsorgCoreClient.php';
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['SERVER_PORT'] ?? null) == 443);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'httponly' => true,
'secure' => $secure,
'samesite' => 'Lax',
]);
session_name('OMSORG_FELIX_APP');
session_start();
function _omsorgcore_config(): array {
static $config = null;
if ($config === null) {
$config = require __DIR__ . '/config.php';
}
return $config;
}
// Stellt sicher, dass $_SESSION['omsorgcore_access_token'] gültig ist - erneuert bei Bedarf still
// über den Refresh-Token (Server-seitig, der Browser bekommt nie einen Token zu sehen).
function _ensure_fresh_token(): bool {
if (empty($_SESSION['omsorgcore_access_token']) || empty($_SESSION['omsorgcore_refresh_token'])) {
return false;
}
$expiresAt = $_SESSION['omsorgcore_access_expires_at'] ?? null;
$stillValid = $expiresAt && strtotime($expiresAt) > time() + 30; // 30s Puffer
if ($stillValid) {
return true;
}
$result = omsorgcore_refresh(_omsorgcore_config(), $_SESSION['omsorgcore_refresh_token']);
if (!$result['ok']) {
_clear_omsorgcore_session();
return false;
}
_apply_token_pair($result['data']);
return true;
}
function _apply_token_pair(array $data): void {
$_SESSION['omsorgcore_access_token'] = $data['accessToken'];
$_SESSION['omsorgcore_refresh_token'] = $data['refreshToken'];
$_SESSION['omsorgcore_access_expires_at'] = $data['expiresAt'];
$_SESSION['omsorgcore_must_change_password'] = !empty($data['mustChangePassword']);
}
function _clear_omsorgcore_session(): void {
unset(
$_SESSION['omsorgcore_access_token'],
$_SESSION['omsorgcore_refresh_token'],
$_SESSION['omsorgcore_access_expires_at'],
$_SESSION['omsorgcore_must_change_password'],
$_SESSION['omsorgcore_profile'],
$_SESSION['felix_local_user_id']
);
}
// Schreibt Name/E-Mail/Telefon/Avatar/Aktiv-Status in die lokale users-Zeile (per omsorgcore_user_id
// gefunden, siehe migrate-users-to-core.php) - hält den Lesecache für die noch nicht migrierten
// Seiten aktuell. Legt die Zeile an, falls sie fehlt (z. B. gerade erst über admin.php angelegter User).
function _sync_local_user_cache(string $omsorgcoreUserId, string $username, array $profile): int {
$name = trim(($profile['firstName'] ?? '') . ' ' . ($profile['lastName'] ?? '')) ?: $username;
$stmt = db()->prepare('SELECT id FROM users WHERE omsorgcore_user_id = ?');
$stmt->execute([$omsorgcoreUserId]);
$row = $stmt->fetch();
$role = $profile['role'] ?? '';
if ($row) {
db()->prepare('UPDATE users SET username=?, name=?, role=?, email=?, telefon=?, avatar=?, active=1 WHERE id=?')
->execute([$username, $name, $role, $profile['email'] ?? '', $profile['phoneNumber'] ?? '', $profile['avatarFileName'] ?? null, $row['id']]);
return (int)$row['id'];
}
db()->prepare(
'INSERT INTO users (username, name, role, password_hash, active, created_at, email, telefon, avatar, omsorgcore_user_id, omsorgcore_employee_id)
VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)'
)->execute([
$username, $name, $role, '', date('c'),
$profile['email'] ?? '', $profile['phoneNumber'] ?? '', $profile['avatarFileName'] ?? null,
$omsorgcoreUserId, $profile['employeeId'] ?? null,
]);
return (int)db()->lastInsertId();
}
// Lädt/cached /api/auth/me in der Session (Username/Rolle/Rechte/Employee-Basisdaten) und hält den
// lokalen Lesecache (siehe oben) aktuell. Wird nach Login und bei jedem is_logged_in()-Aufruf ohne
// gültigen Cache erneuert.
function _refresh_profile(): bool {
$result = omsorgcore_me(_omsorgcore_config(), $_SESSION['omsorgcore_access_token']);
if (!$result['ok']) {
return false;
}
$_SESSION['omsorgcore_profile'] = $result['data'];
$_SESSION['felix_local_user_id'] = _sync_local_user_cache(
$result['data']['userId'], $result['data']['username'], $result['data']
);
return true;
}
function is_logged_in(): bool {
if (!_ensure_fresh_token()) {
return false;
}
if (empty($_SESSION['omsorgcore_profile'])) {
return _refresh_profile();
}
return true;
}
function must_change_password(): bool {
return !empty($_SESSION['omsorgcore_must_change_password']);
}
function require_login(): void {
if (!is_logged_in()) {
header('Location: ../index.php');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST)) {
verify_csrf();
}
if (must_change_password() && basename($_SERVER['SCRIPT_NAME']) !== 'change-password.php') {
header('Location: change-password.php');
exit;
}
}
function csrf_token(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_field(): string {
return '<input type="hidden" name="csrf_token" value="' . csrf_token() . '">';
}
function verify_csrf(): void {
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals(csrf_token(), $token)) {
http_response_code(403);
exit('Ungültige Anfrage.');
}
}
function require_admin(): void {
require_login();
if (!is_admin()) {
header('Location: ../pages/dashboard.php');
exit;
}
}
function is_admin(): bool {
return ($_SESSION['omsorgcore_profile']['role'] ?? null) === 'Administrator';
}
// Unverändert wie vorher: current_user()['id']/['name']/['email']/['telefon']/['avatar'] kommen aus
// der lokalen users-Zeile (jetzt ein Lesecache, siehe oben) - bestehende Seiten laufen unverändert.
function current_user(): array|false {
if (empty($_SESSION['felix_local_user_id'])) {
return false;
}
$stmt = db()->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$_SESSION['felix_local_user_id']]);
$row = $stmt->fetch();
if (!$row) {
return false;
}
$row['role'] = $_SESSION['omsorgcore_profile']['role'] ?? $row['role'];
return $row;
}
function current_username(): string {
return $_SESSION['omsorgcore_profile']['username'] ?? '';
}
function current_name(): string {
$user = current_user();
return $user ? $user['name'] : '';
}
function e(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
@@ -0,0 +1,10 @@
<?php
// Vorlage: nach config.secret.php kopieren und mit echten Werten füllen.
// config.secret.php niemals ins Repo/Backup geben.
return [
'db_user' => 'DEIN_DB_BENUTZER',
'db_password' => 'DEIN_DB_PASSWORT',
'smtp_password' => 'DEIN_SMTP_PASSWORT',
// Produktiv auf die echte omsorgCore-URL überschreiben (Default in config.php ist localhost, nur für lokale Entwicklung):
// 'omsorg_core_url' => 'https://core.omsorg-pflegedienste.de',
];
@@ -0,0 +1,90 @@
<?php
function db(): PDO {
static $pdo = null;
if ($pdo === null) {
$c = require __DIR__ . '/config.php';
$pdo = new PDO(
$c['db_dsn'],
$c['db_user'],
$c['db_password'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
if ($c['db_driver'] === 'sqlite') {
$pdo->exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;');
}
_run_migrations($pdo);
}
return $pdo;
}
function _table_exists(PDO $pdo, string $table, string $driver): bool {
if ($driver === 'sqlite') {
return (bool) $pdo->query(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=" . $pdo->quote($table)
)->fetchColumn();
}
return (bool) $pdo->query(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=" . $pdo->quote($table)
)->fetchColumn();
}
function _run_migrations(PDO $pdo): void {
$c = require __DIR__ . '/config.php';
$driver = $c['db_driver'];
$migrationTableExisted = _table_exists($pdo, 'schema_migrations', $driver);
$pdo->exec("
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) NOT NULL PRIMARY KEY,
applied_at VARCHAR(32) NOT NULL
)
");
$files = glob(__DIR__ . '/../migrations/*.php');
if (!$files) return;
sort($files);
// Existing DB without migration table: stamp all known migrations as applied.
if (!$migrationTableExisted && _table_exists($pdo, 'users', $driver)) {
$insertIgnore = $driver === 'sqlite' ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
$stmt = $pdo->prepare(
"$insertIgnore INTO schema_migrations (version, applied_at) VALUES (?, ?)"
);
foreach ($files as $file) {
$stmt->execute([basename($file, '.php'), date('c')]);
}
return;
}
$applied = $pdo->query('SELECT version FROM schema_migrations')
->fetchAll(PDO::FETCH_COLUMN);
$applied = array_flip($applied);
$useTransactions = ($driver === 'sqlite');
foreach ($files as $file) {
$version = basename($file, '.php');
if (isset($applied[$version])) continue;
$migration = require $file;
if ($useTransactions) $pdo->beginTransaction();
try {
foreach ((array) $migration['up'] as $sql) {
$pdo->exec($sql);
}
$pdo->prepare(
'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)'
)->execute([$version, date('c')]);
if ($useTransactions) $pdo->commit();
} catch (Throwable $e) {
if ($useTransactions && $pdo->inTransaction()) $pdo->rollBack();
throw new RuntimeException(
"Migration {$version} failed: " . $e->getMessage(), 0, $e
);
}
}
}
@@ -0,0 +1,110 @@
<?php
function layout_start(string $title, string $current_page, string $extra_head = ''): void {
$name = current_name();
$admin = is_admin();
$user = current_user();
$nav = [
'dashboard' => ['href' => 'dashboard.php', 'icon' => '⊞', 'label' => 'Dashboard'],
'stundennachweis' => ['href' => 'stundennachweis.php', 'icon' => '🕐', 'label' => 'Stundennachweis'],
'urlaubsantrag' => ['href' => 'urlaubsantrag.php', 'icon' => '🌴', 'label' => 'Urlaubsantrag'],
'dienstplan' => ['href' => 'dienstplan.php', 'icon' => '📋', 'label' => 'Dienstplan'],
'downloads' => ['href' => 'downloads.php', 'icon' => '📥', 'label' => 'Downloads'],
'einsatzanweisung' => ['href' => 'einsatzanweisung.php', 'icon' => '📄', 'label' => 'Einsatzanweisung'],
'benefitsantrag' => ['href' => 'benefitsantrag.php', 'icon' => '🎁', 'label' => 'Benefitsantrag'],
'abwesenheitsantrag' => ['href' => 'abwesenheitsantrag.php', 'icon' => '📅', 'label' => 'Abwesenheitsantrag'],
'fortbildungsantrag' => ['href' => 'fortbildungsantrag.php', 'icon' => '🎓', 'label' => 'Fortbildungsantrag'],
'dokumentenarchiv' => ['href' => 'dokumentenarchiv.php', 'icon' => '🗂', 'label' => 'Dokumentenarchiv'],
'einsatzbewertung' => ['href' => 'einsatzbewertung.php', 'icon' => '⭐', 'label' => 'Einsatzbewertung'],
'werben' => ['href' => 'werben.php', 'icon' => '🤝', 'label' => 'Mitarbeiter werben'],
'settings' => ['href' => 'settings.php', 'icon' => '⚙', 'label' => 'Einstellungen'],
];
if ($admin) {
$nav['admin'] = ['href' => 'admin.php', 'icon' => '🛡', 'label' => 'Verwaltung'];
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><?= e($title) ?></title>
<link rel="stylesheet" href="../app.css">
<link rel="manifest" href="../manifest.webmanifest">
<meta name="theme-color" content="#1a3a5c">
<meta name="csrf-token" content="<?= csrf_token() ?>">
<?= $extra_head ?>
</head>
<body>
<div class="dashboard-layout">
<aside class="sidebar glass">
<div class="sidebar-brand">
<img src="../assets/omsorg-wordmark-new.png" alt="OMSORG">
<span class="sidebar-app-name">Mitarbeiter-App</span>
<button class="nav-toggle" type="button" aria-label="Menü öffnen" aria-expanded="false">☰</button>
</div>
<div class="sidebar-user">
<div class="sidebar-avatar">
<?php if (!empty($user['avatar'])): ?>
<img src="../assets/avatars/<?= e($user['avatar']) ?>" alt="<?= e($name) ?>">
<?php else: ?>
<?= e(mb_substr($name, 0, 1)) ?>
<?php endif; ?>
</div>
<div>
<b><?= e($name) ?></b>
<small><?= $admin ? 'Admin' : 'Mitarbeiter' ?></small>
</div>
</div>
<nav class="sidebar-nav">
<p class="sidebar-nav-label">Navigation</p>
<?php foreach ($nav as $key => $item): ?>
<a href="<?= e($item['href']) ?>"<?= $current_page === $key ? ' class="active"' : '' ?>>
<span class="nav-icon"><?= $item['icon'] ?></span><span class="nav-label"> <?= e($item['label']) ?></span>
</a>
<?php endforeach; ?>
</nav>
<div class="sidebar-footer">
<a href="../actions/logout.php" class="sidebar-logout">Abmelden</a>
</div>
</aside>
<div class="nav-backdrop" onclick="closeNav()"></div>
<main class="main-content">
<div class="main-inner">
<?php
}
function layout_end(): void {
?>
</div>
</main>
</div>
<script>
if ('serviceWorker' in navigator) navigator.serviceWorker.register('../service-worker.js');
function closeNav(){
document.body.classList.remove('nav-open');
const btn=document.querySelector('.nav-toggle');
if(btn){btn.textContent='☰';btn.setAttribute('aria-expanded','false');}
}
const navToggle=document.querySelector('.nav-toggle');
if(navToggle){
navToggle.addEventListener('click',function(){
const open=document.body.classList.toggle('nav-open');
this.textContent=open?'✕':'☰';
this.setAttribute('aria-expanded',open);
});
}
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeNav();});
</script>
</body>
</html>
<?php
}
@@ -0,0 +1,137 @@
<?php
/**
* Sends an email with an optional file attachment via SMTP.
*
* @param array $cfg Config array with smtp_host, smtp_port, smtp_user, smtp_password, mail_from
* @param string $to Recipient address
* @param string $subject Subject (plain UTF-8, will be encoded)
* @param string $body Plain-text body
* @param array $attach Optional: ['path' => '/abs/path', 'name' => 'display.pdf', 'mime' => 'application/pdf']
* @return array ['ok' => bool, 'log' => string]
*/
function smtp_send(array $cfg, string $to, string $subject, string $body, array $attach = []): array
{
$log = '';
$read = function ($conn) use (&$log) {
$line = '';
while ($chunk = fgets($conn, 512)) {
$log .= '<< ' . $chunk;
$line = $chunk;
if ($chunk[3] === ' ') break; // last line of multi-line response
}
return $line;
};
$write = function ($conn, string $cmd) use (&$log) {
$log .= '>> ' . $cmd;
fwrite($conn, $cmd);
};
$code = fn(string $line) => (int)substr($line, 0, 3);
try {
$host = trim($cfg['smtp_host']);
$port = (int)$cfg['smtp_port'];
$ssl = ($port === 465);
$conn = @fsockopen(($ssl ? 'ssl://' : '') . $host, $port, $errno, $errstr, 10);
if (!$conn) {
return ['ok' => false, 'log' => "Connect failed: $errstr ($errno)"];
}
stream_set_timeout($conn, 10);
$line = $read($conn);
if ($code($line) !== 220) throw new RuntimeException("Unexpected greeting: $line");
$write($conn, "EHLO omsorg-connect\r\n");
$capabilities = '';
while ($chunk = fgets($conn, 512)) {
$log .= '<< ' . $chunk;
$capabilities .= $chunk;
if ($chunk[3] === ' ') break;
}
if ($code($capabilities) !== 250) throw new RuntimeException("EHLO failed");
if (!$ssl && str_contains($capabilities, 'STARTTLS')) {
$write($conn, "STARTTLS\r\n");
$line = $read($conn);
if ($code($line) !== 220) throw new RuntimeException("STARTTLS failed: $line");
if (!stream_socket_enable_crypto($conn, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new RuntimeException("TLS handshake failed");
}
$write($conn, "EHLO omsorg-connect\r\n");
while ($chunk = fgets($conn, 512)) {
$log .= '<< ' . $chunk;
if ($chunk[3] === ' ') break;
}
}
$write($conn, "AUTH LOGIN\r\n");
$line = $read($conn);
if ($code($line) !== 334) throw new RuntimeException("AUTH LOGIN failed: $line");
$write($conn, base64_encode($cfg['smtp_user']) . "\r\n");
$line = $read($conn);
if ($code($line) !== 334) throw new RuntimeException("Username rejected: $line");
$write($conn, base64_encode($cfg['smtp_password']) . "\r\n");
$line = $read($conn);
if ($code($line) !== 235) throw new RuntimeException("Password rejected: $line");
$write($conn, "MAIL FROM:<{$cfg['mail_from']}>\r\n");
$line = $read($conn);
if ($code($line) !== 250) throw new RuntimeException("MAIL FROM failed: $line");
$write($conn, "RCPT TO:<$to>\r\n");
$line = $read($conn);
if ($code($line) !== 250) throw new RuntimeException("RCPT TO failed: $line");
$write($conn, "DATA\r\n");
$line = $read($conn);
if ($code($line) !== 354) throw new RuntimeException("DATA failed: $line");
$enc_subject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
$boundary = 'omsorg_' . md5(uniqid((string)mt_rand(), true));
$headers = "From: OMSORG Connect <{$cfg['mail_from']}>\r\n";
$headers .= "To: $to\r\n";
$headers .= "Subject: $enc_subject\r\n";
$headers .= "MIME-Version: 1.0\r\n";
if ($attach) {
$headers .= "Content-Type: multipart/mixed; boundary=\"$boundary\"\r\n";
$msg = $headers . "\r\n";
$msg .= "--$boundary\r\n";
$msg .= "Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n";
$msg .= $body . "\r\n\r\n";
$safe_name = preg_replace('/[^A-Za-z0-9._-]/', '_', $attach['name']);
$file_data = chunk_split(base64_encode(file_get_contents($attach['path'])));
$msg .= "--$boundary\r\n";
$msg .= "Content-Type: {$attach['mime']}; name=\"$safe_name\"\r\n";
$msg .= "Content-Transfer-Encoding: base64\r\n";
$msg .= "Content-Disposition: attachment; filename=\"$safe_name\"\r\n\r\n";
$msg .= $file_data . "\r\n";
$msg .= "--$boundary--";
} else {
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
$msg = $headers . "\r\n" . $body;
}
// Dot-stuffing: lines starting with '.' must be doubled
$msg = str_replace("\n.", "\n..", $msg);
$write($conn, $msg . "\r\n.\r\n");
$line = $read($conn);
if ($code($line) !== 250) throw new RuntimeException("Message rejected: $line");
$write($conn, "QUIT\r\n");
$read($conn);
fclose($conn);
return ['ok' => true, 'log' => $log];
} catch (RuntimeException $e) {
if (isset($conn) && is_resource($conn)) fclose($conn);
return ['ok' => false, 'log' => $log . "\nError: " . $e->getMessage()];
}
}
@@ -0,0 +1,145 @@
<?php
// Einzige Stelle, die HTTP-Details von omsorgCore kennt (Basis-URL, Header, JSON-(De)Kodierung) -
// analog zu httpClient.cjs/authClient.cjs in omsorgapp. Alle Aufrufer bekommen ein einheitliches
// Ergebnis-Array: ['ok' => bool, 'status' => int, 'data' => array|null].
function omsorgcore_request(array $config, string $method, string $path, ?array $body = null, ?string $accessToken = null): array
{
$url = rtrim($config['omsorg_core_url'], '/') . $path;
$headers = ['Content-Type: application/json'];
if ($accessToken !== null) {
$headers[] = 'Authorization: Bearer ' . $accessToken;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_POSTFIELDS => $body !== null ? json_encode($body) : null,
]);
$responseBody = curl_exec($ch);
if ($responseBody === false) {
$error = curl_error($ch);
curl_close($ch);
return ['ok' => false, 'status' => 0, 'data' => null, 'error' => $error];
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = null;
if ($responseBody !== '') {
$decoded = json_decode($responseBody, true);
$data = json_last_error() === JSON_ERROR_NONE ? $decoded : null;
}
return ['ok' => $status >= 200 && $status < 300, 'status' => $status, 'data' => $data];
}
// --- Auth ---
function omsorgcore_login(array $config, string $username, string $password): array
{
return omsorgcore_request($config, 'POST', '/api/auth/login', ['username' => $username, 'password' => $password]);
}
function omsorgcore_refresh(array $config, string $refreshToken): array
{
return omsorgcore_request($config, 'POST', '/api/auth/refresh', ['refreshToken' => $refreshToken]);
}
function omsorgcore_logout(array $config, string $refreshToken): array
{
return omsorgcore_request($config, 'POST', '/api/auth/logout', ['refreshToken' => $refreshToken]);
}
function omsorgcore_me(array $config, string $accessToken): array
{
return omsorgcore_request($config, 'GET', '/api/auth/me', null, $accessToken);
}
function omsorgcore_change_password(array $config, string $accessToken, string $currentPassword, string $newPassword): array
{
return omsorgcore_request($config, 'POST', '/api/auth/change-password', [
'currentPassword' => $currentPassword,
'newPassword' => $newPassword,
], $accessToken);
}
function omsorgcore_forgot_password_request(array $config, string $username): array
{
return omsorgcore_request($config, 'POST', '/api/auth/forgot-password/request', ['username' => $username]);
}
function omsorgcore_forgot_password_verify(array $config, string $username, string $pin): array
{
return omsorgcore_request($config, 'POST', '/api/auth/forgot-password/verify', ['username' => $username, 'pin' => $pin]);
}
function omsorgcore_forgot_password_reset(array $config, string $resetToken, string $newPassword): array
{
return omsorgcore_request($config, 'POST', '/api/auth/forgot-password/reset', [
'resetToken' => $resetToken,
'newPassword' => $newPassword,
]);
}
// --- Users ---
function omsorgcore_users_list(array $config, string $accessToken): array
{
return omsorgcore_request($config, 'GET', '/api/users', null, $accessToken);
}
function omsorgcore_users_create(array $config, string $accessToken, array $payload): array
{
return omsorgcore_request($config, 'POST', '/api/users', $payload, $accessToken);
}
function omsorgcore_users_update(array $config, string $accessToken, string $userId, array $payload): array
{
return omsorgcore_request($config, 'PUT', '/api/users/' . urlencode($userId), $payload, $accessToken);
}
function omsorgcore_users_reset_password(array $config, string $accessToken, string $userId, array $payload): array
{
return omsorgcore_request($config, 'POST', '/api/users/' . urlencode($userId) . '/reset-password', $payload, $accessToken);
}
// --- Roles ---
function omsorgcore_roles_list(array $config, string $accessToken): array
{
return omsorgcore_request($config, 'GET', '/api/roles', null, $accessToken);
}
function omsorgcore_roles_create(array $config, string $accessToken, string $name): array
{
return omsorgcore_request($config, 'POST', '/api/roles', ['name' => $name], $accessToken);
}
// --- Employees (für die Datenmigration gebraucht - Employee je PHP-User finden/anlegen) ---
function omsorgcore_employees_list(array $config, string $accessToken, int $page = 1, int $pageSize = 100): array
{
return omsorgcore_request($config, 'GET', '/api/employees?page=' . $page . '&pageSize=' . $pageSize, null, $accessToken);
}
function omsorgcore_employees_get(array $config, string $accessToken, string $employeeId): array
{
return omsorgcore_request($config, 'GET', '/api/employees/' . urlencode($employeeId), null, $accessToken);
}
function omsorgcore_employees_create(array $config, string $accessToken, array $payload): array
{
return omsorgcore_request($config, 'POST', '/api/employees', $payload, $accessToken);
}
function omsorgcore_employees_update(array $config, string $accessToken, string $employeeId, array $payload): array
{
return omsorgcore_request($config, 'PUT', '/api/employees/' . urlencode($employeeId), $payload, $accessToken);
}
@@ -0,0 +1,132 @@
<?php
require_once __DIR__ . '/auth.php';
// Gemeinsame Endung→MIME-Whitelists.
const UPLOAD_TYPES_DOCS = [
'pdf' => ['application/pdf'],
'doc' => ['application/msword'],
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
];
const UPLOAD_TYPES_PDF_IMG = [
'pdf' => ['application/pdf'],
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
];
const UPLOAD_TYPES_IMAGES = [
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
'gif' => ['image/gif'],
'webp' => ['image/webp'],
];
const UPLOAD_TYPES_PDF = [
'pdf' => ['application/pdf'],
];
const UPLOAD_TYPES_OFFICE = [
'pdf' => ['application/pdf'],
'doc' => ['application/msword'],
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
'xls' => ['application/vnd.ms-excel'],
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
'ppt' => ['application/vnd.ms-powerpoint'],
'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
'txt' => ['text/plain'],
'zip' => ['application/zip', 'application/x-zip-compressed'],
];
/**
* Validiert und speichert eine hochgeladene Datei.
*
* @param array $file Eintrag aus $_FILES, z.B. $_FILES['datei']
* @param array $opts allowed (ext=>mimes, Pflicht), dir (Pflicht), prefix,
* max_bytes (Default 12 MB), required (Default true),
* naming ('dated' [Default] = Datum_Username_Prefix_Rand,
* 'plain' = Username_Rand, 'hash' = nur Rand),
* username (Default current_username(); für 'dated'/'plain'),
* messages (Überschreibt Standardtexte: missing/upload/ext/mime/size/move)
* @return array ['ok'=>bool, 'error'=>?string, 'filename'=>?string,
* 'original_name'=>?string, 'mime'=>?string]
*/
function handle_upload(array $file, array $opts): array {
$allowed = $opts['allowed'];
$dir = rtrim($opts['dir'], '/') . '/';
$maxBytes = $opts['max_bytes'] ?? 12 * 1024 * 1024;
$required = $opts['required'] ?? true;
$naming = $opts['naming'] ?? 'dated';
$prefix = $opts['prefix'] ?? 'datei';
$username = $opts['username'] ?? current_username();
$msg = array_merge([
'missing' => 'Bitte eine Datei auswählen.',
'upload' => 'Fehler beim Hochladen der Datei.',
'ext' => 'Dateityp nicht erlaubt.',
'mime' => 'Dateityp nicht erlaubt.',
'size' => 'Datei ist zu groß (max. 12 MB).',
'move' => 'Fehler beim Hochladen der Datei.',
], $opts['messages'] ?? []);
$err = $file['error'] ?? UPLOAD_ERR_NO_FILE;
if ($err === UPLOAD_ERR_NO_FILE || !isset($file['tmp_name'])) {
if ($required) return ['ok' => false, 'error' => $msg['missing']];
return ['ok' => true, 'error' => null, 'filename' => null, 'original_name' => null, 'mime' => null];
}
if ($err !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => $msg['upload']];
}
$originalName = $file['name'];
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if (!isset($allowed[$ext])) {
return ['ok' => false, 'error' => $msg['ext']];
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, $allowed[$ext], true)) {
return ['ok' => false, 'error' => $msg['mime']];
}
if ($file['size'] > $maxBytes) {
return ['ok' => false, 'error' => $msg['size']];
}
$safeName = match ($naming) {
'hash' => bin2hex(random_bytes(16)) . '.' . $ext,
'plain' => $username . '_' . bin2hex(random_bytes(8)) . '.' . $ext,
default => date('Y-m-d') . '_' . $username . '_' . $prefix . '_' . bin2hex(random_bytes(8)) . '.' . $ext,
};
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
if (!move_uploaded_file($file['tmp_name'], $dir . $safeName)) {
return ['ok' => false, 'error' => $msg['move']];
}
return [
'ok' => true,
'error' => null,
'filename' => $safeName,
'original_name' => $originalName,
'mime' => $mime,
];
}
function redirect_error(string $back, string $msg): never {
header('Location: ' . $back . '?error=' . urlencode($msg));
exit;
}
function redirect_ok(string $back, string $param = 'success', string $val = '1'): never {
header('Location: ' . $back . '?' . $param . '=' . $val);
exit;
}