diff --git a/core/lib/Controllers/TenantSettingsController.php b/core/lib/Controllers/TenantSettingsController.php new file mode 100644 index 0000000..ebb66fc --- /dev/null +++ b/core/lib/Controllers/TenantSettingsController.php @@ -0,0 +1,74 @@ +tenantService->fetchSettings($this->tenantIdentity->identifier()); + + return new JsonResponse($settings, JsonResponse::HTTP_OK); + } + + /** + * Update one or more settings for the current tenant. + * + * @param array $data Key-value pairs to persist + * + * @example request body: + * { + * "data": { + * "default_mode": "dark", + * "primary_color": "#6366F1", + * "lock_user_colors": true + * } + * } + * + * @return JsonResponse The updated values that were written + */ + #[AuthenticatedRoute( + '/tenant/settings', + name: 'tenant.settings.update', + methods: ['PUT', 'PATCH'], + permissions: ['tenant.settings.update'], + )] + public function update(array $data): JsonResponse + { + $this->tenantService->storeSettings($this->tenantIdentity->identifier(), $data); + + $updatedSettings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier(), array_keys($data)); + + return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK); + } +} diff --git a/core/lib/Service/TenantService.php b/core/lib/Service/TenantService.php index 57b0311..fb3f413 100644 --- a/core/lib/Service/TenantService.php +++ b/core/lib/Service/TenantService.php @@ -7,9 +7,9 @@ use KTXC\Stores\TenantStore; class TenantService { - public function __construct(protected readonly TenantStore $store) - { - } + public function __construct( + protected readonly TenantStore $store, + ) {} public function fetchByDomain(string $domain): ?TenantObject { @@ -21,4 +21,31 @@ class TenantService return $this->store->fetch($identifier); } + // ========================================================================= + // Settings + // ========================================================================= + + /** + * Fetch all – or a filtered subset of – a tenant's settings. + * + * @param string $identifier Tenant identifier + * @param string[] $keys Optional list of keys to return; empty = all + * + * @return array + */ + public function fetchSettings(string $identifier, array $keys = []): array + { + return $this->store->fetchSettings($identifier, $keys); + } + + /** + * Merge-update settings for a tenant. + * + * @param string $identifier Tenant identifier + * @param array $settings Key-value pairs to persist + */ + public function storeSettings(string $identifier, array $settings): bool + { + return $this->store->storeSettings($identifier, $settings); + } } diff --git a/core/lib/Stores/TenantStore.php b/core/lib/Stores/TenantStore.php index a13b2dc..157d4f1 100644 --- a/core/lib/Stores/TenantStore.php +++ b/core/lib/Stores/TenantStore.php @@ -72,4 +72,57 @@ class TenantStore $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]); } + // ========================================================================= + // Settings Operations + // ========================================================================= + + /** + * Fetch settings for a tenant, optionally filtered to specific keys. + * + * @param string $identifier Tenant identifier + * @param string[] $keys Optional list of keys to return; empty = all + * + * @return array + */ + public function fetchSettings(string $identifier, array $keys = []): array + { + $entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne( + ['identifier' => $identifier], + ['projection' => ['settings' => 1]] + ); + + $settings = (array)($entry['settings'] ?? []); + + if (empty($keys)) { + return $settings; + } + + $result = []; + foreach ($keys as $key) { + $result[$key] = $settings[$key] ?? null; + } + return $result; + } + + /** + * Merge-update settings for a tenant using atomic $set on sub-fields. + * + * @param string $identifier Tenant identifier + * @param array $settings Key-value pairs to persist + */ + public function storeSettings(string $identifier, array $settings): bool + { + $updates = []; + foreach ($settings as $key => $value) { + $updates["settings.{$key}"] = $value; + } + + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne( + ['identifier' => $identifier], + ['$set' => $updates] + ); + + return $result->getMatchedCount() > 0; + } + } \ No newline at end of file diff --git a/core/src/App.vue b/core/src/App.vue index 52fe0f4..c26ae6a 100644 --- a/core/src/App.vue +++ b/core/src/App.vue @@ -4,17 +4,68 @@ diff --git a/core/src/config.ts b/core/src/config.ts deleted file mode 100644 index 63d4192..0000000 --- a/core/src/config.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type ConfigProps = { - Sidebar_drawer: boolean; - mini_sidebar: boolean; - actTheme: string; - fontTheme: string; -}; - -const config: ConfigProps = { - Sidebar_drawer: true, - mini_sidebar: false, - actTheme: 'light', - fontTheme: 'Public sans' -}; - -export default config; diff --git a/core/src/stores/layoutStore.ts b/core/src/stores/layoutStore.ts index 32ec5a4..b161680 100644 --- a/core/src/stores/layoutStore.ts +++ b/core/src/stores/layoutStore.ts @@ -1,6 +1,5 @@ import { ref, watch } from 'vue'; import { defineStore } from 'pinia'; -import config from '@KTXC/config'; import { useUserStore } from './userStore'; export type MenuMode = 'apps' | 'user-settings' | 'admin-settings'; @@ -9,15 +8,15 @@ export const useLayoutStore = defineStore('layout', () => { // Loading state const isLoading = ref(false); - // Sidebar state - initialize from settings or config + // Sidebar state - initialize from settings or defaults const userStore = useUserStore(); - const sidebarDrawer = ref(userStore.getSetting('sidebar_drawer') ?? config.Sidebar_drawer); - const miniSidebar = ref(userStore.getSetting('mini_sidebar') ?? config.mini_sidebar); + const sidebarDrawer = ref(userStore.getSetting('sidebar_drawer') ?? true); + const miniSidebar = ref(userStore.getSetting('mini_sidebar') ?? false); const menuMode = ref('apps'); - // Theme state - initialize from settings or config - const theme = ref(userStore.getSetting('theme') ?? config.actTheme); - const font = ref(userStore.getSetting('font') ?? config.fontTheme); + // Theme state - initialize from settings or defaults + const theme = ref(userStore.getSetting('theme') ?? 'light'); + const font = ref(userStore.getSetting('font') ?? 'Public sans'); // Watch and sync sidebar state to settings watch(sidebarDrawer, (value) => { diff --git a/core/src/stores/tenantStore.ts b/core/src/stores/tenantStore.ts index 4c96706..8e3a2a2 100644 --- a/core/src/stores/tenantStore.ts +++ b/core/src/stores/tenantStore.ts @@ -1,4 +1,6 @@ import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'; export interface TenantState { id: string | null; @@ -6,22 +8,117 @@ export interface TenantState { label: string | null; } -export const useTenantStore = defineStore('tenantStore', { - state: () => ({ - tenant: null as TenantState | null, - }), - actions: { - init(tenant: Partial | null) { - this.tenant = tenant - ? { - id: tenant.id ?? null, - domain: tenant.domain ?? null, - label: tenant.label ?? null, - } - : null; - }, - reset() { - this.tenant = null; - }, - }, +export interface TenantData extends TenantState { + settings?: Record; +} + +// Flush pending settings writes before the page unloads +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + useTenantStore().flushSettings(); + }); +} + +export const useTenantStore = defineStore('tenantStore', () => { + // ========================================================================= + // State + // ========================================================================= + + const tenant = ref(null); + const settings = ref>({}); + + // Pending batch for debounced writes + let _pendingSettings: Record = {}; + let _debounceTimer: ReturnType | null = null; + + // ========================================================================= + // Getters + // ========================================================================= + + function getSetting(key: string): unknown { + return settings.value[key] ?? null; + } + + // ========================================================================= + // Settings actions + // ========================================================================= + + function setSetting(key: string, value: unknown): void { + settings.value[key] = value; + + // Batch writes with a 500 ms debounce — same pattern as userService + _pendingSettings[key] = value; + + if (_debounceTimer !== null) { + clearTimeout(_debounceTimer); + } + + _debounceTimer = setTimeout(() => { + _flush(); + }, 500); + } + + async function _flush(): Promise { + if (Object.keys(_pendingSettings).length === 0) return; + + const payload = { ..._pendingSettings }; + _pendingSettings = {}; + _debounceTimer = null; + + try { + await fetchWrapper.patch('/tenant/settings', { data: payload }); + } catch (error) { + console.error('Failed to save tenant settings:', error); + } + } + + /** Force-flush any pending settings writes (called on beforeunload). */ + async function flushSettings(): Promise { + if (_debounceTimer !== null) { + clearTimeout(_debounceTimer); + _debounceTimer = null; + } + await _flush(); + } + + // ========================================================================= + // Initialise from /init endpoint + // ========================================================================= + + function init(tenantData: Partial | null): void { + tenant.value = tenantData + ? { + id: tenantData.id ?? null, + domain: tenantData.domain ?? null, + label: tenantData.label ?? null, + } + : null; + + settings.value = (tenantData?.settings as Record) ?? {}; + } + + function reset(): void { + tenant.value = null; + settings.value = {}; + _pendingSettings = {}; + if (_debounceTimer !== null) { + clearTimeout(_debounceTimer); + _debounceTimer = null; + } + } + + return { + // State + tenant, + settings, + + // Getters + getSetting, + + // Actions + setSetting, + flushSettings, + init, + reset, + }; });