Files
projectsend/app/Modules/Notifications/Http/Controllers/NotificationPreferencesController.php
T
denkfabrik-li d19ec11970 Accept only notification types that can actually notify
update() validated preferences.*.type as ['required', 'string'], so any
string at all became a row in notification_preferences. Nothing reads it
afterwards: emailEnabledFor() looks preferences up by a key the registry
knows, so a row under an unknown key is invisible for good.

It is not a way into somebody else's settings — user_id comes from the
session, never the payload — which is why this is validation rather than
authorization. The cost is a table that quietly accumulates rows nobody
can see, explain, or remove through the interface.

edit() already knew the answer. It filters the registry down to the types
that can email at all, by either route, and renders exactly those as
toggles. That list is now derived once and used by both halves, so what
the screen offers and what it accepts back cannot drift apart.

Rejecting a registered-but-unmailable key (client_uploaded is the one in
tree) is deliberate rather than incidental: FilesServiceProvider explains
that it has no mail companion on purpose, so a preference row for it
could never change what anybody receives.
2026-08-26 01:16:39 +02:00

96 lines
3.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Notifications\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Notifications\NotificationPreference;
use App\Modules\Notifications\NotificationPreferences;
use App\Modules\Notifications\NotificationTypeDefinition;
use App\Modules\Notifications\NotificationTypeRegistry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
/**
* Per-user "also email me for this" toggles — every account (staff or
* client) manages their own, hence living under settings/* alongside
* profile/password/two-factor rather than system/settings/*.
*/
class NotificationPreferencesController extends Controller
{
public function __construct(
private readonly NotificationTypeRegistry $types,
private readonly NotificationPreferences $preferences,
) {}
public function edit(Request $request): Response
{
$user = $request->user();
assert($user !== null);
return Inertia::render('settings/notifications', [
'types' => array_map(fn (NotificationTypeDefinition $type): array => [
'key' => $type->key,
'label' => $type->label,
'email_enabled' => $this->preferences->emailEnabledFor($user, $type),
], $this->emailable()),
]);
}
public function update(Request $request): RedirectResponse
{
$user = $request->user();
assert($user !== null);
$validated = $request->validate([
'preferences' => ['required', 'array'],
// Against the registry, not merely "a string": a preference row
// for a type nothing can send is a row that will never be read
// again, and the screen only ever offers back what edit() gave
// it.
'preferences.*.type' => ['required', 'string', Rule::in($this->emailableKeys())],
'preferences.*.email_enabled' => ['required', 'boolean'],
]);
foreach ($validated['preferences'] as $preference) {
NotificationPreference::query()->updateOrCreate(
['user_id' => $user->id, 'type' => $preference['type']],
['email_enabled' => $preference['email_enabled']],
);
}
return back();
}
/**
* Only types that can email at all have anything to opt in or out of —
* a pure in-app type has no toggle to show. Either route counts:
* Notifier sending a mail class directly, or the digest buffering and
* sending one.
*
* Shared by both halves on purpose, so what the screen offers and what
* it accepts back cannot drift apart.
*
* @return list<NotificationTypeDefinition>
*/
private function emailable(): array
{
return array_values(array_filter(
$this->types->all(),
fn (NotificationTypeDefinition $type): bool => $type->mailNotification !== null || $type->digestMail !== null,
));
}
/**
* @return list<string>
*/
private function emailableKeys(): array
{
return array_map(fn (NotificationTypeDefinition $type): string => $type->key, $this->emailable());
}
}