Add PWA reminders and flexible walk scoring
This commit is contained in:
+312
-10
@@ -10,6 +10,8 @@ final class App
|
||||
private LoginThrottle $throttle;
|
||||
private ScoringService $scoring;
|
||||
private Auth $auth;
|
||||
private NotificationRepository $notifications;
|
||||
private WebPushService $webPush;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -19,6 +21,8 @@ final class App
|
||||
$this->throttle = new LoginThrottle();
|
||||
$this->scoring = new ScoringService();
|
||||
$this->auth = new Auth($this->users);
|
||||
$this->notifications = new NotificationRepository();
|
||||
$this->webPush = new WebPushService($this->notifications);
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
@@ -29,6 +33,7 @@ final class App
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$hasUsers = $this->users->hasAnyUsers();
|
||||
$isAuthenticated = $this->auth->check();
|
||||
$systemPaths = ['/reminders/run'];
|
||||
|
||||
// A failed setup must never leave the app in a half-authenticated redirect loop.
|
||||
if (!$hasUsers && $isAuthenticated) {
|
||||
@@ -39,13 +44,13 @@ final class App
|
||||
if (!$hasUsers) {
|
||||
if ($path === '/login') {
|
||||
$path = '/setup';
|
||||
} elseif ($path !== '/setup') {
|
||||
} elseif ($path !== '/setup' && !in_array($path, $systemPaths, true)) {
|
||||
redirect('/setup');
|
||||
}
|
||||
} elseif (!$isAuthenticated) {
|
||||
if ($path === '/setup') {
|
||||
$path = '/login';
|
||||
} elseif ($path !== '/login') {
|
||||
} elseif ($path !== '/login' && !in_array($path, $systemPaths, true)) {
|
||||
redirect('/login');
|
||||
}
|
||||
}
|
||||
@@ -90,6 +95,37 @@ final class App
|
||||
$method === 'POST' ? $this->handleOptions() : $this->showOptions();
|
||||
return;
|
||||
|
||||
case '/push/subscribe':
|
||||
if ($method !== 'POST') {
|
||||
http_response_code(405);
|
||||
exit('Method Not Allowed');
|
||||
}
|
||||
|
||||
$this->handlePushSubscribe();
|
||||
return;
|
||||
|
||||
case '/push/unsubscribe':
|
||||
if ($method !== 'POST') {
|
||||
http_response_code(405);
|
||||
exit('Method Not Allowed');
|
||||
}
|
||||
|
||||
$this->handlePushUnsubscribe();
|
||||
return;
|
||||
|
||||
case '/push/test':
|
||||
if ($method !== 'POST') {
|
||||
http_response_code(405);
|
||||
exit('Method Not Allowed');
|
||||
}
|
||||
|
||||
$this->handlePushTest();
|
||||
return;
|
||||
|
||||
case '/reminders/run':
|
||||
$this->handleReminderRun();
|
||||
return;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
View::render('not-found', [
|
||||
@@ -221,7 +257,9 @@ final class App
|
||||
'sport_minutes' => 0,
|
||||
'sport_type' => '',
|
||||
'sport_types' => [],
|
||||
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
|
||||
'walk_minutes' => 0,
|
||||
'walk_steps' => 0,
|
||||
'note' => '',
|
||||
];
|
||||
|
||||
@@ -263,7 +301,9 @@ final class App
|
||||
'sleep_feeling' => $_POST['sleep_feeling'] ?? 3,
|
||||
'sport_minutes' => $_POST['sport_minutes'] ?? 0,
|
||||
'sport_types' => $_POST['sport_types'] ?? [],
|
||||
'walk_mode' => $_POST['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time'),
|
||||
'walk_minutes' => $_POST['walk_minutes'] ?? 0,
|
||||
'walk_steps' => $_POST['walk_steps'] ?? 0,
|
||||
'note' => $_POST['note'] ?? '',
|
||||
]);
|
||||
|
||||
@@ -323,6 +363,16 @@ final class App
|
||||
return true;
|
||||
}
|
||||
));
|
||||
$pushAvailable = $this->webPush->isAvailable();
|
||||
$pushPublicKey = null;
|
||||
|
||||
if ($pushAvailable) {
|
||||
try {
|
||||
$pushPublicKey = $this->webPush->publicKey();
|
||||
} catch (RuntimeException) {
|
||||
$pushAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
View::render('options', [
|
||||
'pageTitle' => 'Optionen',
|
||||
@@ -331,6 +381,10 @@ final class App
|
||||
'settings' => $settings,
|
||||
'sportTypePresets' => $sportTypePresets,
|
||||
'sportLocationOptions' => sport_location_options(),
|
||||
'walkModeOptions' => walk_mode_options(),
|
||||
'pushAvailable' => $pushAvailable,
|
||||
'pushPublicKey' => $pushPublicKey,
|
||||
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
|
||||
'users' => $user['is_admin'] ? $this->users->all() : [],
|
||||
'maxScore' => $this->scoring->evaluate([
|
||||
'mood' => 10,
|
||||
@@ -343,7 +397,9 @@ final class App
|
||||
static fn (array $type): string => (string) ($type['id'] ?? ''),
|
||||
normalized_sport_types($settings)
|
||||
),
|
||||
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
|
||||
'walk_minutes' => 999,
|
||||
'walk_steps' => 10000,
|
||||
'note' => 'x',
|
||||
], $settings)['max_total'],
|
||||
]);
|
||||
@@ -357,7 +413,8 @@ final class App
|
||||
$form = (string) ($_POST['form_name'] ?? '');
|
||||
|
||||
if ($form === 'settings') {
|
||||
$settings = $this->sanitizeSettings($_POST['settings'] ?? []);
|
||||
$currentSettings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
||||
$settings = $this->sanitizeSettings($_POST['settings'] ?? [], $currentSettings);
|
||||
$this->settings->saveForUser($user['username'], $settings);
|
||||
flash('success', 'Deine persönlichen Optionen wurden aktualisiert.');
|
||||
redirect('/options');
|
||||
@@ -478,9 +535,10 @@ final class App
|
||||
'sport' => array_map(static function (array $entry): array {
|
||||
return [
|
||||
'date' => $entry['date'],
|
||||
'value' => $entry['sport_minutes'] + $entry['walk_minutes'],
|
||||
'value' => $entry['sport_minutes'] + walk_chart_value($entry),
|
||||
'sport' => $entry['sport_minutes'],
|
||||
'walk' => $entry['walk_minutes'],
|
||||
'walk' => walk_chart_value($entry),
|
||||
'walk_label' => format_walk_value($entry),
|
||||
'sport_labels' => array_values(array_filter(array_map(
|
||||
static function (array $type): string {
|
||||
$label = (string) ($type['label'] ?? '');
|
||||
@@ -528,10 +586,14 @@ final class App
|
||||
return $streak;
|
||||
}
|
||||
|
||||
private function sanitizeSettings(array $input): array
|
||||
private function sanitizeSettings(array $input, ?array $existingSettings = null): array
|
||||
{
|
||||
$defaults = Defaults::settings();
|
||||
$settings = $defaults;
|
||||
$settings = array_replace_recursive($defaults, $existingSettings ?? []);
|
||||
|
||||
$settings['walk'] = [
|
||||
'mode' => ($input['walk']['mode'] ?? ($settings['walk']['mode'] ?? 'time')) === 'steps' ? 'steps' : 'time',
|
||||
];
|
||||
|
||||
$settings['scoring']['mood_multiplier'] = max(0, min(10, (int) ($input['scoring']['mood_multiplier'] ?? 3)));
|
||||
$settings['scoring']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? 2)));
|
||||
@@ -545,10 +607,12 @@ final class App
|
||||
|
||||
foreach (['sport_bands', 'walk_bands'] as $bandKey) {
|
||||
foreach ($defaults['scoring'][$bandKey] as $index => $defaultBand) {
|
||||
$currentBand = $settings['scoring'][$bandKey][$index] ?? $defaultBand;
|
||||
|
||||
$settings['scoring'][$bandKey][$index] = [
|
||||
'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $defaultBand['min']))),
|
||||
'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $defaultBand['max']))),
|
||||
'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $defaultBand['points']))),
|
||||
'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $currentBand['min'] ?? $defaultBand['min']))),
|
||||
'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $currentBand['max'] ?? $defaultBand['max']))),
|
||||
'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $currentBand['points'] ?? $defaultBand['points']))),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -579,12 +643,30 @@ final class App
|
||||
: ($sportTypesProvided ? [] : $defaults['sport_types']),
|
||||
]);
|
||||
|
||||
$time = trim((string) ($input['notifications']['time'] ?? ($settings['notifications']['time'] ?? $defaults['notifications']['time'])));
|
||||
if (!$this->isValidTime($time)) {
|
||||
$time = $defaults['notifications']['time'];
|
||||
}
|
||||
|
||||
$settings['notifications'] = [
|
||||
'enabled' => isset($input['notifications']['enabled']) && $input['notifications']['enabled'] === '1',
|
||||
'time' => $time,
|
||||
];
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
private function hydrateSettings(array $settings): array
|
||||
{
|
||||
$settings['sport_types'] = normalized_sport_types($settings);
|
||||
$settings['walk'] = array_replace(
|
||||
Defaults::settings()['walk'],
|
||||
is_array($settings['walk'] ?? null) ? $settings['walk'] : []
|
||||
);
|
||||
$settings['notifications'] = array_replace(
|
||||
Defaults::settings()['notifications'],
|
||||
is_array($settings['notifications'] ?? null) ? $settings['notifications'] : []
|
||||
);
|
||||
|
||||
return $settings;
|
||||
}
|
||||
@@ -632,6 +714,16 @@ final class App
|
||||
}
|
||||
}
|
||||
|
||||
private function enforceRequestCsrf(): void
|
||||
{
|
||||
if (!verify_request_csrf()) {
|
||||
json_response([
|
||||
'ok' => false,
|
||||
'message' => 'Ungültiges Formular-Token.',
|
||||
], 419);
|
||||
}
|
||||
}
|
||||
|
||||
private function requireUser(): array
|
||||
{
|
||||
$user = $this->auth->user();
|
||||
@@ -660,10 +752,220 @@ final class App
|
||||
return strlen($password) >= 10;
|
||||
}
|
||||
|
||||
private function isValidTime(string $time): bool
|
||||
{
|
||||
return preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) === 1;
|
||||
}
|
||||
|
||||
private function throttleKey(string $username): string
|
||||
{
|
||||
$remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
|
||||
return sha1($remoteAddress . '|' . normalize_username($username));
|
||||
}
|
||||
|
||||
private function handlePushSubscribe(): void
|
||||
{
|
||||
$this->enforceRequestCsrf();
|
||||
|
||||
$user = $this->requireUser();
|
||||
$payload = request_json_body();
|
||||
$subscription = $payload['subscription'] ?? null;
|
||||
|
||||
if (!is_array($subscription)) {
|
||||
json_response(['ok' => false, 'message' => 'Die Push-Daten fehlen.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->notifications->saveSubscription($user['username'], [
|
||||
'endpoint' => trim((string) ($subscription['endpoint'] ?? '')),
|
||||
'keys' => [
|
||||
'p256dh' => trim((string) ($subscription['keys']['p256dh'] ?? '')),
|
||||
'auth' => trim((string) ($subscription['keys']['auth'] ?? '')),
|
||||
],
|
||||
'content_encoding' => trim((string) ($payload['contentEncoding'] ?? 'aes128gcm')),
|
||||
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
|
||||
]);
|
||||
} catch (RuntimeException $exception) {
|
||||
json_response(['ok' => false, 'message' => $exception->getMessage()], 422);
|
||||
}
|
||||
|
||||
json_response([
|
||||
'ok' => true,
|
||||
'message' => 'Push ist auf diesem Gerät aktiviert.',
|
||||
'count' => $this->notifications->subscriptionCount($user['username']),
|
||||
]);
|
||||
}
|
||||
|
||||
private function handlePushUnsubscribe(): void
|
||||
{
|
||||
$this->enforceRequestCsrf();
|
||||
|
||||
$user = $this->requireUser();
|
||||
$payload = request_json_body();
|
||||
$endpoint = trim((string) ($payload['endpoint'] ?? ''));
|
||||
|
||||
if ($endpoint === '') {
|
||||
json_response(['ok' => false, 'message' => 'Kein Push-Endpunkt übergeben.'], 422);
|
||||
}
|
||||
|
||||
$this->notifications->removeSubscription($user['username'], $endpoint);
|
||||
|
||||
json_response([
|
||||
'ok' => true,
|
||||
'message' => 'Push wurde für dieses Gerät entfernt.',
|
||||
'count' => $this->notifications->subscriptionCount($user['username']),
|
||||
]);
|
||||
}
|
||||
|
||||
private function handlePushTest(): void
|
||||
{
|
||||
$this->enforceRequestCsrf();
|
||||
|
||||
$user = $this->requireUser();
|
||||
$result = $this->sendNotificationsForUser($user['username'], [
|
||||
'title' => 'Mood-Board',
|
||||
'body' => 'Die Push-Erinnerung ist auf diesem Gerät bereit.',
|
||||
'url' => '/options',
|
||||
'tag' => 'mood-push-test',
|
||||
]);
|
||||
|
||||
if ($result['sent'] <= 0) {
|
||||
json_response([
|
||||
'ok' => false,
|
||||
'message' => 'Es konnte noch keine Test-Benachrichtigung gesendet werden. Bitte aktiviere Push zuerst auf diesem Gerät.',
|
||||
'removed' => $result['removed'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
json_response([
|
||||
'ok' => true,
|
||||
'message' => 'Die Test-Benachrichtigung wurde verschickt.',
|
||||
'sent' => $result['sent'],
|
||||
'removed' => $result['removed'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleReminderRun(): void
|
||||
{
|
||||
$providedToken = trim((string) ($_GET['token'] ?? ($_SERVER['HTTP_X_REMINDER_TOKEN'] ?? '')));
|
||||
$expectedToken = $this->webPush->cronToken();
|
||||
|
||||
if ($providedToken === '' || !hash_equals($expectedToken, $providedToken)) {
|
||||
json_response(['ok' => false, 'message' => 'Ungültiger Reminder-Token.'], 403);
|
||||
}
|
||||
|
||||
$now = new DateTimeImmutable('now');
|
||||
$today = $now->format('Y-m-d');
|
||||
$currentTime = $now->format('H:i');
|
||||
$processed = 0;
|
||||
$sentUsers = 0;
|
||||
$alreadyTracked = 0;
|
||||
$skipped = 0;
|
||||
$removed = 0;
|
||||
|
||||
foreach ($this->users->all() as $account) {
|
||||
$username = (string) ($account['username'] ?? '');
|
||||
if ($username === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
$settings = $this->hydrateSettings($this->settings->forUser($username));
|
||||
$state = $this->notifications->reminderState($username);
|
||||
|
||||
if (!$this->isReminderDue($settings, $state, $today, $currentTime)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->entries->find($username, $today) !== null) {
|
||||
$alreadyTracked++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->sendNotificationsForUser($username, [
|
||||
'title' => 'Mood-Board',
|
||||
'body' => 'Nimm dir kurz Zeit für deinen heutigen Eintrag.',
|
||||
'url' => '/track?date=' . rawurlencode($today),
|
||||
'tag' => 'mood-reminder-' . $today,
|
||||
]);
|
||||
|
||||
$removed += $result['removed'];
|
||||
|
||||
if ($result['sent'] > 0) {
|
||||
$sentUsers++;
|
||||
$this->notifications->saveReminderState($username, [
|
||||
'last_sent_date' => $today,
|
||||
'last_sent_at' => date(DATE_ATOM),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
json_response([
|
||||
'ok' => true,
|
||||
'processed' => $processed,
|
||||
'sent_users' => $sentUsers,
|
||||
'already_tracked' => $alreadyTracked,
|
||||
'skipped' => $skipped,
|
||||
'removed_subscriptions' => $removed,
|
||||
]);
|
||||
}
|
||||
|
||||
private function sendNotificationsForUser(string $username, array $message): array
|
||||
{
|
||||
$subscriptions = $this->notifications->subscriptionsForUser($username);
|
||||
$sent = 0;
|
||||
$removedEndpoints = [];
|
||||
|
||||
foreach ($subscriptions as $subscription) {
|
||||
try {
|
||||
$result = $this->webPush->send($subscription, $message);
|
||||
} catch (RuntimeException) {
|
||||
$result = [
|
||||
'ok' => false,
|
||||
'remove' => false,
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($result['ok'])) {
|
||||
$sent++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($result['remove'])) {
|
||||
$endpoint = (string) ($subscription['endpoint'] ?? '');
|
||||
if ($endpoint !== '') {
|
||||
$removedEndpoints[] = $endpoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($removedEndpoints !== []) {
|
||||
$this->notifications->removeInvalidSubscriptions($username, $removedEndpoints);
|
||||
}
|
||||
|
||||
return [
|
||||
'sent' => $sent,
|
||||
'removed' => count($removedEndpoints),
|
||||
];
|
||||
}
|
||||
|
||||
private function isReminderDue(array $settings, array $state, string $today, string $currentTime): bool
|
||||
{
|
||||
if (empty($settings['notifications']['enabled'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reminderTime = (string) ($settings['notifications']['time'] ?? '');
|
||||
if (!$this->isValidTime($reminderTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($currentTime < $reminderTime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (string) ($state['last_sent_date'] ?? '') !== $today;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user