opsdash-app/opsdash/lib/Controller/PersistController.php
blade34242 4da913a47d Add active profile name to email recap and targets widget
- Persist active_preset to backend on every save (PersistController)
- Read active_preset back on page load via OverviewLoadContextService and OverviewCorePayloadComposer
- Seed activePresetRef from onCoreLoaded so the pill shows on every page load, not just the session where a profile was loaded
- Watch lastLoadedPreset → activePresetRef so loading or saving a profile updates the widget live
- TimeTargetsCard: new presetLabel prop renders a brand-colored pill next to the title
- targets_v2 buildProps passes ctx.activePreset as presetLabel
- WidgetRenderContext carries activePreset field
- ReportRenderService: show profile name as frosted pill in hero card when set
- ReportSummaryService: include active_preset in email summary payload
- Fix activity highlights sprintf arg mismatch (extra 'Quiet days' literal)
- Redesign all email sections as widget-style cards matching app visual language
2026-05-19 10:07:45 +07:00

335 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Opsdash\Controller;
use OCA\Opsdash\Service\CalendarAccessService;
use OCA\Opsdash\Service\OverviewLoadCacheService;
use OCA\Opsdash\Service\PersistSanitizer;
use OCA\Opsdash\Service\UserConfigService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
final class PersistController extends Controller {
use CsrfEnforcerTrait;
use RequestGuardTrait;
private const CONFIG_ONBOARDING = 'onboarding_state';
private const MAX_CONFIG_BYTES = 65535;
public function __construct(
string $appName,
IRequest $request,
private IUserSession $userSession,
protected LoggerInterface $logger,
private IConfig $config,
private CalendarAccessService $calendarAccess,
private PersistSanitizer $persistSanitizer,
private UserConfigService $userConfigService,
private OverviewLoadCacheService $loadCacheService,
) {
parent::__construct($appName, $request);
}
#[NoAdminRequired]
public function persist(): DataResponse {
$uid = (string)($this->userSession->getUser()?->getUID() ?? '');
if ($uid === '') return new DataResponse(['message' => 'unauthorized'], Http::STATUS_UNAUTHORIZED);
if ($csrf = $this->enforceCsrf()) {
return $csrf;
}
// Read request
$data = $this->readJsonBodyDefault();
if ($data instanceof DataResponse) {
return $data;
}
$hasCals = array_key_exists('cals', $data);
$reqOriginal = null;
if ($hasCals) {
$reqOriginal = $data['cals'];
if (is_string($reqOriginal)) {
$reqOriginal = array_values(array_filter(explode(',', $reqOriginal), fn($x) => $x !== ''));
}
$reqOriginal = is_array($reqOriginal) ? $reqOriginal : [];
}
// Intersect with user's calendars
$allowedIds = $this->calendarAccess->getCalendarIdsFor($uid);
$allowed = array_fill_keys($allowedIds, true);
// Save
$didMutate = false;
$after = null;
$csv = null;
if ($hasCals) {
$after = array_values(array_unique(array_filter(array_map(fn($x) => substr((string)$x, 0, 128), $reqOriginal), fn($x) => isset($allowed[$x]))));
$csv = implode(',', $after);
$this->config->setUserValue($uid, $this->appName, 'selected_cals', $csv);
$didMutate = true;
}
// Optional: groups mapping
$groupsSaved = null; $groupsRead = null;
if (isset($data['groups']) && is_array($data['groups'])) {
$gclean = $this->persistSanitizer->cleanGroups($data['groups'], $allowed, array_keys($allowed));
if ($resp = $this->writeUserJsonValue($uid, 'cal_groups', $gclean, 'groups')) {
return $resp;
}
$didMutate = true;
$groupsSaved = $gclean;
try {
$gjson = (string)$this->config->getUserValue($uid, $this->appName, 'cal_groups', '');
$tmp = $gjson !== '' ? json_decode($gjson, true) : [];
if (is_array($tmp)) $groupsRead = $tmp;
} catch (\Throwable) {}
}
// Optional: per-calendar targets (week/month) mapping: { id: hours }
$targetsWeekSaved = null; $targetsMonthSaved = null; $targetsWeekRead = null; $targetsMonthRead = null;
$targetsConfigSaved = null; $targetsConfigRead = null;
$onboardingSaved = null; $onboardingRead = null;
$themeSaved = null; $themeRead = null;
$reportingSaved = null; $reportingRead = null;
$deckSaved = null; $deckRead = null;
$widgetsSaved = null; $widgetsRead = null;
if (isset($data['targets_week'])) {
$tw = $this->persistSanitizer->cleanTargets(is_array($data['targets_week']) ? $data['targets_week'] : [], $allowed);
if ($resp = $this->writeUserJsonValue($uid, 'cal_targets_week', $tw, 'targets_week')) {
return $resp;
}
$didMutate = true;
$targetsWeekSaved = $tw;
try {
$r = (string)$this->config->getUserValue($uid, $this->appName, 'cal_targets_week', '');
$targetsWeekRead = $r !== '' ? json_decode($r, true) : [];
} catch (\Throwable) {}
}
if (isset($data['targets_config'])) {
$cleanCfg = $this->persistSanitizer->cleanTargetsConfig($data['targets_config']);
if ($resp = $this->writeUserJsonValue($uid, 'targets_config', $cleanCfg, 'targets_config')) {
return $resp;
}
$didMutate = true;
$targetsConfigSaved = $cleanCfg;
try {
$cfgJson = (string)$this->config->getUserValue($uid, $this->appName, 'targets_config', '');
if ($cfgJson !== '') {
$tmp = json_decode($cfgJson, true);
if (is_array($tmp)) {
$targetsConfigRead = $this->persistSanitizer->cleanTargetsConfig($tmp);
}
}
} catch (\Throwable) {}
}
if (isset($data['targets_month'])) {
$tm = $this->persistSanitizer->cleanTargets(is_array($data['targets_month']) ? $data['targets_month'] : [], $allowed);
if ($resp = $this->writeUserJsonValue($uid, 'cal_targets_month', $tm, 'targets_month')) {
return $resp;
}
$didMutate = true;
$targetsMonthSaved = $tm;
try {
$r = (string)$this->config->getUserValue($uid, $this->appName, 'cal_targets_month', '');
$targetsMonthRead = $r !== '' ? json_decode($r, true) : [];
} catch (\Throwable) {}
}
if (!empty($data['onboarding_reset'])) {
try {
$this->config->deleteUserValue($uid, $this->appName, self::CONFIG_ONBOARDING);
} catch (\Throwable) {}
$didMutate = true;
} elseif (array_key_exists('onboarding', $data)) {
$cleanOnboarding = $this->persistSanitizer->cleanOnboardingState($data['onboarding']);
$cleanOnboarding = $this->mergeExistingReleaseNotesSeenVersion($uid, $cleanOnboarding);
if ($resp = $this->writeUserJsonValue($uid, self::CONFIG_ONBOARDING, $cleanOnboarding, 'onboarding')) {
return $resp;
}
$didMutate = true;
$onboardingSaved = $cleanOnboarding;
}
$onboardingRead = $this->userConfigService->readOnboardingState($this->appName, $uid);
if (array_key_exists('theme_preference', $data)) {
$themeValue = $this->persistSanitizer->sanitizeThemePreference($data['theme_preference']);
if ($themeValue === null) {
try { $this->config->deleteUserValue($uid, $this->appName, 'theme_preference'); } catch (\Throwable) {}
} else {
$this->config->setUserValue($uid, $this->appName, 'theme_preference', $themeValue);
$themeSaved = $themeValue;
}
$didMutate = true;
$themeRead = $this->userConfigService->readThemePreference($this->appName, $uid);
}
if (isset($data['reporting_config'])) {
$cleanReporting = $this->persistSanitizer->sanitizeReportingConfig($data['reporting_config']);
if ($resp = $this->writeUserJsonValue($uid, 'reporting_config', $cleanReporting, 'reporting_config')) {
return $resp;
}
$didMutate = true;
$reportingSaved = $cleanReporting;
}
$reportingRead = $this->userConfigService->readReportingConfig($this->appName, $uid);
if (array_key_exists('active_preset', $data)) {
$presetName = $data['active_preset'];
if ($presetName === null || $presetName === '') {
$this->config->deleteUserValue($uid, $this->appName, 'active_preset');
} else {
$clean = substr(preg_replace('/[^\p{L}\p{N} _\-\.]/u', '', (string)$presetName) ?? '', 0, 64);
if ($clean !== '') {
$this->config->setUserValue($uid, $this->appName, 'active_preset', $clean);
}
}
$didMutate = true;
}
if (isset($data['deck_settings'])) {
$cleanDeck = $this->persistSanitizer->sanitizeDeckSettings($data['deck_settings']);
if ($resp = $this->writeUserJsonValue($uid, 'deck_settings', $cleanDeck, 'deck_settings')) {
return $resp;
}
$didMutate = true;
$deckSaved = $cleanDeck;
}
$deckRead = $this->userConfigService->readDeckSettings($this->appName, $uid);
if (isset($data['widgets'])) {
$cleanWidgets = $this->persistSanitizer->sanitizeWidgets($data['widgets']);
if ($resp = $this->writeUserJsonValue($uid, 'widgets_layout', $cleanWidgets, 'widgets')) {
return $resp;
}
$didMutate = true;
$widgetsSaved = $cleanWidgets;
}
try {
$widgetsRaw = (string)$this->config->getUserValue($uid, $this->appName, 'widgets_layout', '');
if ($widgetsRaw !== '') {
$tmp = json_decode($widgetsRaw, true);
if (is_array($tmp)) {
$widgetsRead = $this->persistSanitizer->sanitizeWidgets($tmp);
}
}
} catch (\Throwable) {}
// Read-back
$readCsv = (string)$this->config->getUserValue($uid, $this->appName, 'selected_cals', '');
$read = array_values(array_filter(explode(',', $readCsv), fn($x) => $x !== ''));
if ($themeRead === null) {
$themeRead = $this->userConfigService->readThemePreference($this->appName, $uid);
}
if ($targetsConfigRead === null) {
$targetsConfigRead = $this->userConfigService->readTargetsConfig($this->appName, $uid);
}
if ($didMutate) {
$this->loadCacheService->bumpUserCoreCacheVersion($this->appName, $uid);
}
return new DataResponse([
'ok' => true,
'request' => $hasCals ? $reqOriginal : null,
'saved_csv' => $csv,
'read_csv' => $readCsv,
'saved' => $after,
'read' => $read,
'groups_saved' => $groupsSaved,
'groups_read' => $groupsRead,
'targets_week_saved' => $targetsWeekSaved,
'targets_week_read' => $targetsWeekRead,
'targets_month_saved' => $targetsMonthSaved,
'targets_month_read' => $targetsMonthRead,
'targets_config_saved' => $targetsConfigSaved,
'targets_config_read' => $targetsConfigRead,
'onboarding_saved' => $onboardingSaved,
'onboarding_read' => $onboardingRead,
'theme_preference_saved' => $themeSaved,
'theme_preference_read' => $themeRead,
'reporting_config_saved' => $reportingSaved,
'reporting_config_read' => $reportingRead,
'deck_settings_saved' => $deckSaved,
'deck_settings_read' => $deckRead,
'widgets_saved' => $widgetsSaved,
'widgets_read' => $widgetsRead,
], Http::STATUS_OK);
}
/**
* Keep older in-flight dashboard saves from erasing a release-note dismissal.
*
* @param array<string,mixed> $incoming
* @return array<string,mixed>
*/
private function mergeExistingReleaseNotesSeenVersion(string $uid, array $incoming): array {
$existing = $this->userConfigService->readOnboardingState($this->appName, $uid);
$existingVersion = $this->normalizeReleaseVersion((string)($existing['releaseNotesSeenVersion'] ?? ''));
$incomingVersion = $this->normalizeReleaseVersion((string)($incoming['releaseNotesSeenVersion'] ?? ''));
if ($existingVersion !== '' && (
$incomingVersion === ''
|| $this->compareReleaseVersions($existingVersion, $incomingVersion) > 0
)) {
$incoming['releaseNotesSeenVersion'] = $existingVersion;
}
return $incoming;
}
private function normalizeReleaseVersion(string $version): string {
return preg_replace('/^v/i', '', trim($version)) ?? trim($version);
}
private function compareReleaseVersions(string $left, string $right): int {
$leftParts = $this->releaseVersionParts($left);
$rightParts = $this->releaseVersionParts($right);
$length = max(count($leftParts), count($rightParts));
for ($index = 0; $index < $length; $index++) {
$delta = ($leftParts[$index] ?? 0) <=> ($rightParts[$index] ?? 0);
if ($delta !== 0) {
return $delta;
}
}
return 0;
}
/**
* @return array<int,int>
*/
private function releaseVersionParts(string $version): array {
return array_map(
static fn(string $part): int => is_numeric($part) ? (int)$part : 0,
explode('.', $this->normalizeReleaseVersion($version)),
);
}
/**
* @param array<string,mixed> $payload
*/
private function writeUserJsonValue(string $uid, string $key, array $payload, string $label): ?DataResponse {
try {
$json = $this->encodeConfigValue($payload, $label);
$this->config->setUserValue($uid, $this->appName, $key, $json);
return null;
} catch (\LengthException $e) {
return new DataResponse(['message' => $label . ' too large'], Http::STATUS_REQUEST_ENTITY_TOO_LARGE);
} catch (\Throwable $e) {
$this->logger->error('persist failed: ' . $e->getMessage(), ['app' => $this->appName]);
return new DataResponse(['message' => 'error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* @param array<string,mixed> $payload
*/
private function encodeConfigValue(array $payload, string $label): string {
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
throw new \RuntimeException('json encode failed: ' . $label);
}
if (strlen($json) > self::MAX_CONFIG_BYTES) {
throw new \LengthException('payload too large: ' . $label);
}
return $json;
}
}