From 2f5f74b0298e81458adc378e8a5085af98335470 Mon Sep 17 00:00:00 2001 From: Venkat SF Date: Thu, 10 Sep 2026 04:54:56 +0000 Subject: [PATCH] =?UTF-8?q?polish:=20Digest=20Preferences=20UI=20=E2=80=94?= =?UTF-8?q?=20names,=20one=20gear,=20first-run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show human connection display names on pref cards (API connectionName with client-side fallback). Consolidate prefs entry to a single gear, simplify persona/schedule/on-off controls, and auto-open the panel once when Digests opens with zero enabled prefs. --- .../DigestPreferenceController.java | 81 ++++++- .../dto/DigestPreferenceResponse.java | 26 +++ docs/DIGEST_PREFERENCES.md | 13 +- .../sections/DigestPreferencesPanel.jsx | 208 ++++++++++++------ .../DigestPreferencesPanel.module.css | 43 ++++ src/components/sections/DigestSection.jsx | 150 ++++--------- 6 files changed, 325 insertions(+), 196 deletions(-) create mode 100644 backend/src/main/java/com/dbaagent/dto/DigestPreferenceResponse.java diff --git a/backend/src/main/java/com/dbaagent/controller/DigestPreferenceController.java b/backend/src/main/java/com/dbaagent/controller/DigestPreferenceController.java index 837f35d..d164112 100644 --- a/backend/src/main/java/com/dbaagent/controller/DigestPreferenceController.java +++ b/backend/src/main/java/com/dbaagent/controller/DigestPreferenceController.java @@ -1,8 +1,11 @@ package com.dbaagent.controller; +import com.dbaagent.dto.DigestPreferenceResponse; +import com.dbaagent.model.DatabaseConnection; import com.dbaagent.model.DigestDeliveryMethod; import com.dbaagent.model.PersonaTag; import com.dbaagent.model.UserDigestPreference; +import com.dbaagent.repository.CredentialRepository; import com.dbaagent.service.DigestPreferenceSeedService; import com.dbaagent.service.security.AccessControlService; import com.dbaagent.service.UserDigestPreferenceService; @@ -13,8 +16,12 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; /** * REST controller for managing per-user digest preferences. @@ -32,21 +39,23 @@ public class DigestPreferenceController { private final AccessControlService accessControlService; private final DigestPreferenceSeedService seedService; private final SlackDailyDigestService digestService; + private final CredentialRepository credentialRepository; /** - * Get the current user's digest preferences. + * Get the current user's digest preferences (includes connection display names). */ @GetMapping - public ResponseEntity> getMyPreferences() { + public ResponseEntity> getMyPreferences() { String username = accessControlService.requireCurrentUsername(); - return ResponseEntity.ok(preferenceService.getPreferencesForUser(username)); + List prefs = preferenceService.getPreferencesForUser(username); + return ResponseEntity.ok(toResponses(prefs)); } /** * Create a new digest preference for the current user. */ @PostMapping - public ResponseEntity createPreference(@RequestBody CreatePreferenceRequest request) { + public ResponseEntity createPreference(@RequestBody CreatePreferenceRequest request) { String username = accessControlService.requireCurrentUsername(); DigestDeliveryMethod method; @@ -80,14 +89,14 @@ public ResponseEntity createPreference(@RequestBody Create request.timezone ); - return ResponseEntity.ok(preference); + return ResponseEntity.ok(toResponse(preference)); } /** * Update an existing preference. */ @PutMapping("/{id}") - public ResponseEntity updatePreference( + public ResponseEntity updatePreference( @PathVariable Long id, @RequestBody UpdatePreferenceRequest request) { @@ -112,14 +121,14 @@ public ResponseEntity updatePreference( request.timezone ); - return ResponseEntity.ok(updated); + return ResponseEntity.ok(toResponse(updated)); } /** * Enable or disable a preference. */ @PatchMapping("/{id}/enabled") - public ResponseEntity setEnabled( + public ResponseEntity setEnabled( @PathVariable Long id, @RequestBody Map body) { @@ -138,7 +147,7 @@ public ResponseEntity setEnabled( } UserDigestPreference updated = preferenceService.setEnabled(id, enabled); - return ResponseEntity.ok(updated); + return ResponseEntity.ok(toResponse(updated)); } /** @@ -286,4 +295,58 @@ public record DigestStatusResponse(boolean perUserMode, long enabledPreferences, public ResponseEntity> handleBadRequest(IllegalArgumentException e) { return ResponseEntity.badRequest().body(Map.of("message", e.getMessage() != null ? e.getMessage() : "Bad request")); } + + // ───────────────────────────────────────────────────────────────────────── + // Response mapping (connection display names) + // ───────────────────────────────────────────────────────────────────────── + + private List toResponses(List prefs) { + Map namesById = resolveConnectionNames(prefs); + return prefs.stream() + .map(pref -> toResponse(pref, namesById)) + .toList(); + } + + private DigestPreferenceResponse toResponse(UserDigestPreference pref) { + Map namesById = resolveConnectionNames(List.of(pref)); + return toResponse(pref, namesById); + } + + private DigestPreferenceResponse toResponse(UserDigestPreference pref, Map namesById) { + String connectionName = null; + if (pref.getConnectionId() != null) { + connectionName = namesById.get(pref.getConnectionId()); + } + return DigestPreferenceResponse.builder() + .id(pref.getId()) + .username(pref.getUsername()) + .connectionId(pref.getConnectionId()) + .connectionName(connectionName) + .enabled(pref.isEnabled()) + .personaTag(pref.getPersonaTag() != null ? pref.getPersonaTag().name() : null) + .cronExpression(pref.getCronExpression()) + .deliveryMethod(pref.getDeliveryMethod() != null ? pref.getDeliveryMethod().name() : null) + .timezone(pref.getTimezone()) + .createdAt(pref.getCreatedAt()) + .updatedAt(pref.getUpdatedAt()) + .build(); + } + + private Map resolveConnectionNames(List prefs) { + Set ids = prefs.stream() + .map(UserDigestPreference::getConnectionId) + .filter(Objects::nonNull) + .filter(id -> !id.isBlank()) + .collect(Collectors.toSet()); + if (ids.isEmpty()) { + return Map.of(); + } + Map names = new HashMap<>(); + for (DatabaseConnection conn : credentialRepository.findAllById(ids)) { + if (conn.getId() != null && conn.getConnectionName() != null) { + names.put(conn.getId(), conn.getConnectionName()); + } + } + return names; + } } diff --git a/backend/src/main/java/com/dbaagent/dto/DigestPreferenceResponse.java b/backend/src/main/java/com/dbaagent/dto/DigestPreferenceResponse.java new file mode 100644 index 0000000..723ec13 --- /dev/null +++ b/backend/src/main/java/com/dbaagent/dto/DigestPreferenceResponse.java @@ -0,0 +1,26 @@ +package com.dbaagent.dto; + +import lombok.Builder; +import lombok.Value; + +import java.time.LocalDateTime; + +/** + * API view of a {@code UserDigestPreference} with a human-readable connection name. + */ +@Value +@Builder +public class DigestPreferenceResponse { + Long id; + String username; + String connectionId; + /** Display name for {@link #connectionId}; null when unknown or preference is connection-wide. */ + String connectionName; + boolean enabled; + String personaTag; + String cronExpression; + String deliveryMethod; + String timezone; + LocalDateTime createdAt; + LocalDateTime updatedAt; +} diff --git a/docs/DIGEST_PREFERENCES.md b/docs/DIGEST_PREFERENCES.md index 9290417..24b0df2 100644 --- a/docs/DIGEST_PREFERENCES.md +++ b/docs/DIGEST_PREFERENCES.md @@ -86,7 +86,7 @@ Note: Do not claim EMAIL or WhatsApp delivery is available. These are planned fo | Endpoint | Method | Description | |----------|--------|-------------| -| `/api/digest/preferences` | GET | Get current user's preferences | +| `/api/digest/preferences` | GET | Get current user's preferences (includes `connectionName`) | | `/api/digest/preferences` | POST | Create a new preference | | `/api/digest/preferences/{id}` | PUT | Update a preference | | `/api/digest/preferences/{id}/enabled` | PATCH | Enable/disable | @@ -134,16 +134,17 @@ To migrate from legacy singleton mode to per-user mode: The digest preferences are accessible from: -1. **Digest Section** (sidebar): Click the bell icon (🔔) to open preferences panel -2. **Preferences Panel**: Create, edit, enable/disable, delete preferences +1. **Digest Section** (sidebar): Click the **gear** (⚙) to open the preferences panel — single entry point (no competing bell) +2. **First-run**: Opening Digests with zero preferences (or none enabled) auto-opens the panel once (`localStorage` flag) +3. **Preferences Panel**: Create, edit, enable/disable, delete preferences ### Preferences Panel Features -- View all your digest subscriptions +- Pref cards show **connection display names** (API `connectionName`, with client-side fallback from the connections list; UUID only if name is missing) - Toggle digests on/off per connection -- Change persona without recreating +- Change persona and schedule inline (compact controls — no duplicate meta labels) - Quick schedule presets (8 AM, 9 AM, Noon, etc.) — stored on the preference and honored by the minute-tick scheduler -- Seed for all your connections at once +- Seed for all your connections at once (`POST /api/digest/preferences/seed/me`) ## Database Schema diff --git a/src/components/sections/DigestPreferencesPanel.jsx b/src/components/sections/DigestPreferencesPanel.jsx index cfb098a..a2432e1 100644 --- a/src/components/sections/DigestPreferencesPanel.jsx +++ b/src/components/sections/DigestPreferencesPanel.jsx @@ -1,9 +1,6 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { X, - User, - Bell, - Clock, Check, AlertCircle, RefreshCw, @@ -21,11 +18,37 @@ const CRON_PRESETS = [ { label: '9 AM daily', value: '0 0 9 * * *' }, { label: '10 AM daily', value: '0 0 10 * * *' }, { label: 'Noon daily', value: '0 0 12 * * *' }, - { label: 'Use global', value: null }, + { label: 'Use global', value: '' }, ] +function cronPresetLabel(cronExpression) { + if (!cronExpression) return 'Use global' + const match = CRON_PRESETS.find((p) => p.value === cronExpression) + return match ? match.label : 'Custom' +} + +/** + * Prefer API connectionName; else resolve from the connections list; else UUID. + */ +function resolveConnectionDisplayName(pref, connections, selectedConnection, connectionId) { + if (pref?.connectionName && String(pref.connectionName).trim()) { + return pref.connectionName + } + if (!pref?.connectionId) { + return 'All connections' + } + const fromList = connections?.find((c) => c.id === pref.connectionId) + if (fromList?.connectionName) { + return fromList.connectionName + } + if (pref.connectionId === connectionId && selectedConnection?.connectionName) { + return selectedConnection.connectionName + } + return pref.connectionId +} + export default function DigestPreferencesPanel({ onClose }) { - const { connectionId, selectedConnection } = useConnectionManager() + const { connectionId, selectedConnection, connections } = useConnectionManager() const [preferences, setPreferences] = useState([]) const [personaTags, setPersonaTags] = useState([]) const [status, setStatus] = useState(null) @@ -63,6 +86,11 @@ export default function DigestPreferencesPanel({ onClose }) { load() }, [load]) + const showSuccess = (msg) => { + setSuccessMsg(msg) + setTimeout(() => setSuccessMsg(null), 2500) + } + const handleToggleEnabled = async (pref) => { try { const updated = await digestPreferencesAPI.setEnabled(pref.id, !pref.enabled) @@ -92,6 +120,23 @@ export default function DigestPreferencesPanel({ onClose }) { } } + const handleUpdateSchedule = async (pref, cronExpression) => { + setSaving(true) + try { + const updated = await digestPreferencesAPI.updatePreference(pref.id, { + cronExpression: cronExpression ?? '', + }) + setPreferences((prev) => + prev.map((p) => (p.id === pref.id ? updated : p)) + ) + showSuccess('Schedule updated') + } catch { + setError('Failed to update schedule') + } finally { + setSaving(false) + } + } + const handleDelete = async (pref) => { if (!window.confirm('Delete this digest preference? You can recreate it later.')) { return @@ -155,25 +200,28 @@ export default function DigestPreferencesPanel({ onClose }) { setNewCron(preset.value || '') } - const showSuccess = (msg) => { - setSuccessMsg(msg) - setTimeout(() => setSuccessMsg(null), 2500) - } - const currentConnectionPref = preferences.find( (p) => p.connectionId === connectionId ) + const connectionNameById = useMemo(() => { + const map = {} + for (const c of connections || []) { + if (c?.id) map[c.id] = c.connectionName + } + return map + }, [connections]) + return (
e.stopPropagation()}> {/* Header */}
- + Digest Preferences
-
@@ -203,7 +251,7 @@ export default function DigestPreferencesPanel({ onClose }) {
{error} - +
)} @@ -216,7 +264,7 @@ export default function DigestPreferencesPanel({ onClose }) { {!loading && preferences.length === 0 && (
- +

No digest preferences yet

Set up personalized digests to receive database insights via Slack @@ -227,6 +275,7 @@ export default function DigestPreferencesPanel({ onClose }) { className={styles.seedBtn} onClick={handleSeedForMe} disabled={saving} + type="button" > {saving ? 'Setting up...' : 'Set up for all my connections'} @@ -235,6 +284,7 @@ export default function DigestPreferencesPanel({ onClose }) { className={styles.createBtn} onClick={() => setShowCreate(true)} disabled={!connectionId} + type="button" > Create for current connection @@ -257,6 +307,7 @@ export default function DigestPreferencesPanel({ onClose }) { ? 'Already configured for this connection' : 'Add preference for current connection' } + type="button" > @@ -266,11 +317,21 @@ export default function DigestPreferencesPanel({ onClose }) { handleToggleEnabled(pref)} onUpdatePersona={(tag) => handleUpdatePersona(pref, tag)} + onUpdateSchedule={(cron) => handleUpdateSchedule(pref, cron)} onDelete={() => handleDelete(pref)} /> ))} @@ -282,19 +343,22 @@ export default function DigestPreferencesPanel({ onClose }) {

New digest preference

- For: {selectedConnection?.connectionName || connectionId || 'Select a connection'} + For:{' '} + {selectedConnection?.connectionName || + connectionId || + 'Select a connection'}

- + @@ -304,6 +368,7 @@ export default function DigestPreferencesPanel({ onClose }) { {CRON_PRESETS.map((p) => ( @@ -343,6 +409,7 @@ export default function DigestPreferencesPanel({ onClose }) { className={styles.saveBtn} onClick={handleCreate} disabled={saving || !connectionId} + type="button" > {saving ? 'Creating...' : 'Create preference'} @@ -357,28 +424,17 @@ export default function DigestPreferencesPanel({ onClose }) { function PreferenceCard({ pref, + displayName, personaTags, - selectedConnection, connectionId, onToggle, onUpdatePersona, + onUpdateSchedule, onDelete, }) { const isCurrentConnection = pref.connectionId === connectionId - const connName = - isCurrentConnection && selectedConnection - ? selectedConnection.connectionName - : pref.connectionId || 'All connections' - - const personaLabel = - personaTags.find((t) => t.value === pref.personaTag)?.label || 'Role-based' - - const deliveryLabel = - pref.deliveryMethod === 'SLACK_DM' - ? 'Slack DM' - : pref.deliveryMethod === 'SLACK_CHANNEL' - ? 'Channel' - : pref.deliveryMethod || 'Slack' + const scheduleValue = pref.cronExpression || '' + const knownSchedule = CRON_PRESETS.some((p) => p.value === scheduleValue) return (
- {connName} + + {displayName} + {isCurrentConnection && ( Current )} -
-
- - - {personaLabel} - - - - {deliveryLabel} - - {pref.cronExpression && ( - - - Custom schedule - - )} +
+ + +
- - -
- -
-

- Choose when the daily digest is sent to Slack. Changes take effect on the next server restart. -

- - -
- {PRESETS.map(p => ( - - ))} -
- - - { setCron(e.target.value); setPreset('custom') }} - placeholder="0 0 9 * * *" - spellCheck={false} - /> -

Format: seconds minutes hours day month weekday

- - {error &&

{error}

} - - -
-
-
- ) -} - // ───────────────────────────────────────────── // Main section // ───────────────────────────────────────────── +const DIGEST_PREFS_AUTOPEN_KEY = 'deepsql.digestPrefs.autoOpened.v1' + export default function DigestFeedSection() { const { connectionId, selectedConnection } = useConnectionManager() const [digests, setDigests] = useState([]) const [loading, setLoading] = useState(false) const [triggering, setTriggering] = useState(false) const [triggerMsg, setTriggerMsg] = useState(null) - const [showSettings, setShowSettings] = useState(false) const [showPreferences, setShowPreferences] = useState(false) const [error, setError] = useState(null) @@ -300,6 +202,40 @@ export default function DigestFeedSection() { useEffect(() => { load() }, [load]) + // First-run: auto-open Digest Preferences once when there are no prefs + // or none enabled. localStorage flag prevents repeat prompts. + useEffect(() => { + let cancelled = false + try { + if (localStorage.getItem(DIGEST_PREFS_AUTOPEN_KEY)) return + } catch { + return + } + + digestPreferencesAPI + .getMyPreferences() + .then((prefs) => { + if (cancelled) return + const list = Array.isArray(prefs) ? prefs : [] + const hasEnabled = list.some((p) => p?.enabled) + if (list.length === 0 || !hasEnabled) { + setShowPreferences(true) + try { + localStorage.setItem(DIGEST_PREFS_AUTOPEN_KEY, '1') + } catch { + // ignore quota / private mode + } + } + }) + .catch(() => { + // Silent: first-run helper must not block the digest feed + }) + + return () => { + cancelled = true + } + }, []) + const triggerNow = async () => { setTriggering(true) setTriggerMsg(null) @@ -357,14 +293,7 @@ export default function DigestFeedSection() { - @@ -415,7 +344,6 @@ export default function DigestFeedSection() { )}
- {showSettings && setShowSettings(false)} />} {showPreferences && setShowPreferences(false)} />}
)