Skip to content

Commit dd39c29

Browse files
Merge branch 'main' into claude/deepsql-repo-access-10a631
2 parents c48e350 + e358131 commit dd39c29

42 files changed

Lines changed: 7703 additions & 15 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,8 @@ their native runners. See `desktop/README.md` for the full picture.
255255
Dashboards are **generated by the embedded DeepSQL Agent acting as a coding agent**
256256
(customized Hermes runtime — see [`agent/README.md`](agent/README.md)) — it writes the whole dashboard as a single self-contained HTML document, not a JSON spec. The earlier spec+renderer model (metrics/charts/tables + a `{{placeholder}}` substitution engine + `DashboardBuilder.js`) was thrown away: the rigid `col BETWEEN {{name}}` convention couldn't express real SQL (e.g. a Unix-epoch date filter → `near '{range.start}'` syntax errors) and boxed the agent in.
257257

258-
- `DashboardAgentService` is a thin broker: `ensureProfileForUser``ensureSession` (fresh session) → `sendAndAwait` with an **artifact contract**. The agent grounds on the brain/schema, verifies every query with `execute_sql`, then emits ONE HTML doc (in a ` ```html ` block). The broker extracts the HTML and returns `{version:3, renderMode:"artifact", title, html, trace}`, stored verbatim in `saved_dashboards.dashboardConfig`.
258+
- `DashboardAgentService` is a thin broker: `ensureProfileForUser``ensureSession` (fresh session) → `sendAndAwait` with an **artifact contract**. The agent grounds on the brain/schema, verifies every query with `execute_sql`, then emits ONE HTML doc (in a ` ```html ` block). The broker extracts the HTML and returns `{version:3, renderMode:"artifact", title, html, summary, trace}`, stored verbatim in `saved_dashboards.dashboardConfig`.
259+
- **The chat reply is the agent's own words, not a template.** The build contract ends with a required ` ```dashboard-note ` fence (1–3 sentences): what THIS turn changed, plus anything it could not do/verify or deliberately skipped, and on a correction turn whether the disputed value actually changed. `DashboardAgentService.extractSummary` reads it (and `stripNotes` removes it *before* HTML extraction, so prose can't be mistaken for the artifact); it rides in `config.summary`. Both persistence (`SavedDashboardService.buildReplyText`) and the live tab (`useDashboardChatStore.buildReplyText`) render it, falling back to `DEFAULT_BUILD_REPLY` ("Done — built and verified…") only when a turn produced no note. Before this, the contract banned prose and both sites hardcoded that constant, so every build — including one answering a user's correction ("you're showing 3k, I expected 6k+") — reported the same confident "built and verified", which is worse than templated: it claimed success over an unverified change. **The two `buildReplyText` helpers must stay textually in sync** (backend Java + frontend JS) — the live bubble and the persisted reply are read from the same `summary`.
259260
- The agent loads the **`dashboard-design` skill** (`agent/skills/dashboard-design/SKILL.md`, v2 — artifact contract, the `deepsql.query` runtime, composition/UX rules, an **intent checklist**, and Unix-epoch date handling).
260261
- **Rendering + data access**: `DashboardArtifact.jsx` renders the HTML in a **sandboxed iframe** (`sandbox="allow-scripts"`, opaque origin + a strict CSP — no external network). The artifact fetches data only through an injected `deepsql.query(sql)` bridge that `postMessage`s to the parent; the parent calls **`POST /api/dashboards/query`** (`DashboardQueryController`), which is **read-only twice over** (`McpSqlGuardService.validateReadOnlySql` + `QueryExecutionContext.api` = `READ_ONLY_ONLY`) and access-scoped via `assertCanReadConnectionContent`. So the agent's code has full creative freedom while every query stays guarded and sandboxed. The bridge also auto-sizes the iframe and forwards runtime errors.
261262
- Generation endpoints unchanged (`POST /api/dashboards/generate` + `/generate/stream`). `DashboardBuilder.js`/`DashboardInputs.js` remain only because `tabs/Core/PreviewTab.js` still uses them — the dashboard *creation* path no longer touches them.
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
package com.dbaagent.controller;
2+
3+
import com.dbaagent.model.DigestDeliveryMethod;
4+
import com.dbaagent.model.PersonaTag;
5+
import com.dbaagent.model.UserDigestPreference;
6+
import com.dbaagent.service.DigestPreferenceSeedService;
7+
import com.dbaagent.service.security.AccessControlService;
8+
import com.dbaagent.service.UserDigestPreferenceService;
9+
import com.dbaagent.service.SlackDailyDigestService;
10+
import lombok.RequiredArgsConstructor;
11+
import lombok.extern.slf4j.Slf4j;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.access.prepost.PreAuthorize;
14+
import org.springframework.web.bind.annotation.*;
15+
16+
import java.util.List;
17+
import java.util.Map;
18+
19+
/**
20+
* REST controller for managing per-user digest preferences.
21+
*
22+
* <p>Users can manage their own digest subscriptions. Admins can manage any user's
23+
* preferences via the admin endpoints in {@link SlackAdminController}.
24+
*/
25+
@RestController
26+
@RequestMapping("/digest/preferences")
27+
@RequiredArgsConstructor
28+
@Slf4j
29+
public class DigestPreferenceController {
30+
31+
private final UserDigestPreferenceService preferenceService;
32+
private final AccessControlService accessControlService;
33+
private final DigestPreferenceSeedService seedService;
34+
private final SlackDailyDigestService digestService;
35+
36+
/**
37+
* Get the current user's digest preferences.
38+
*/
39+
@GetMapping
40+
public ResponseEntity<List<UserDigestPreference>> getMyPreferences() {
41+
String username = accessControlService.requireCurrentUsername();
42+
return ResponseEntity.ok(preferenceService.getPreferencesForUser(username));
43+
}
44+
45+
/**
46+
* Create a new digest preference for the current user.
47+
*/
48+
@PostMapping
49+
public ResponseEntity<UserDigestPreference> createPreference(@RequestBody CreatePreferenceRequest request) {
50+
String username = accessControlService.requireCurrentUsername();
51+
52+
DigestDeliveryMethod method;
53+
if (request.deliveryMethod == null || request.deliveryMethod.isBlank()) {
54+
method = DigestDeliveryMethod.SLACK_DM;
55+
} else {
56+
method = DigestDeliveryMethod.fromString(request.deliveryMethod);
57+
if (method == null) {
58+
throw new IllegalArgumentException("Unknown delivery method: " + request.deliveryMethod);
59+
}
60+
}
61+
62+
if (method == DigestDeliveryMethod.EMAIL) {
63+
throw new IllegalArgumentException("Email digest delivery is not available yet");
64+
}
65+
66+
if (request.connectionId != null && !request.connectionId.isBlank()) {
67+
accessControlService.assertCanReadConnectionContent(request.connectionId);
68+
}
69+
70+
PersonaTag persona = request.personaTag != null
71+
? PersonaTag.fromString(request.personaTag)
72+
: null;
73+
74+
UserDigestPreference preference = preferenceService.createPreference(
75+
username,
76+
request.connectionId,
77+
method,
78+
persona,
79+
request.cronExpression,
80+
request.timezone
81+
);
82+
83+
return ResponseEntity.ok(preference);
84+
}
85+
86+
/**
87+
* Update an existing preference.
88+
*/
89+
@PutMapping("/{id}")
90+
public ResponseEntity<UserDigestPreference> updatePreference(
91+
@PathVariable Long id,
92+
@RequestBody UpdatePreferenceRequest request) {
93+
94+
String username = accessControlService.requireCurrentUsername();
95+
96+
UserDigestPreference existing = preferenceService.getPreference(id)
97+
.orElseThrow(() -> new IllegalArgumentException("Preference not found: " + id));
98+
99+
if (!existing.getUsername().equals(username) && !accessControlService.isCurrentUserAdmin()) {
100+
throw new IllegalArgumentException("Cannot update another user's preference");
101+
}
102+
103+
PersonaTag persona = request.personaTag != null
104+
? PersonaTag.fromString(request.personaTag)
105+
: null;
106+
107+
UserDigestPreference updated = preferenceService.updatePreference(
108+
id,
109+
request.enabled,
110+
persona,
111+
request.cronExpression,
112+
request.timezone
113+
);
114+
115+
return ResponseEntity.ok(updated);
116+
}
117+
118+
/**
119+
* Enable or disable a preference.
120+
*/
121+
@PatchMapping("/{id}/enabled")
122+
public ResponseEntity<UserDigestPreference> setEnabled(
123+
@PathVariable Long id,
124+
@RequestBody Map<String, Boolean> body) {
125+
126+
String username = accessControlService.requireCurrentUsername();
127+
128+
UserDigestPreference existing = preferenceService.getPreference(id)
129+
.orElseThrow(() -> new IllegalArgumentException("Preference not found: " + id));
130+
131+
if (!existing.getUsername().equals(username) && !accessControlService.isCurrentUserAdmin()) {
132+
throw new IllegalArgumentException("Cannot update another user's preference");
133+
}
134+
135+
Boolean enabled = body.get("enabled");
136+
if (enabled == null) {
137+
throw new IllegalArgumentException("Missing 'enabled' field");
138+
}
139+
140+
UserDigestPreference updated = preferenceService.setEnabled(id, enabled);
141+
return ResponseEntity.ok(updated);
142+
}
143+
144+
/**
145+
* Delete a preference.
146+
*/
147+
@DeleteMapping("/{id}")
148+
public ResponseEntity<Void> deletePreference(@PathVariable Long id) {
149+
String username = accessControlService.requireCurrentUsername();
150+
151+
UserDigestPreference existing = preferenceService.getPreference(id)
152+
.orElseThrow(() -> new IllegalArgumentException("Preference not found: " + id));
153+
154+
if (!existing.getUsername().equals(username) && !accessControlService.isCurrentUserAdmin()) {
155+
throw new IllegalArgumentException("Cannot delete another user's preference");
156+
}
157+
158+
preferenceService.deletePreference(id);
159+
return ResponseEntity.noContent().build();
160+
}
161+
162+
/**
163+
* Get available persona tags.
164+
*/
165+
@GetMapping("/persona-tags")
166+
public ResponseEntity<List<Map<String, String>>> getPersonaTags() {
167+
List<Map<String, String>> tags = List.of(
168+
Map.of("value", "DBA", "label", PersonaTag.DBA.getDisplayName(), "description", PersonaTag.DBA.getDescription()),
169+
Map.of("value", "APP_ENG", "label", PersonaTag.APP_ENG.getDisplayName(), "description", PersonaTag.APP_ENG.getDescription()),
170+
Map.of("value", "DATA_ENG", "label", PersonaTag.DATA_ENG.getDisplayName(), "description", PersonaTag.DATA_ENG.getDescription()),
171+
Map.of("value", "EXEC", "label", PersonaTag.EXEC.getDisplayName(), "description", PersonaTag.EXEC.getDescription())
172+
);
173+
return ResponseEntity.ok(tags);
174+
}
175+
176+
/**
177+
* Get available delivery methods.
178+
*/
179+
@GetMapping("/delivery-methods")
180+
public ResponseEntity<List<Map<String, String>>> getDeliveryMethods() {
181+
// Only advertise methods this PR actually delivers. EMAIL/WhatsApp are PR4.
182+
List<Map<String, String>> methods = List.of(
183+
Map.of("value", "SLACK_DM", "label", DigestDeliveryMethod.SLACK_DM.getDisplayName(), "description", DigestDeliveryMethod.SLACK_DM.getDescription()),
184+
Map.of("value", "SLACK_CHANNEL", "label", DigestDeliveryMethod.SLACK_CHANNEL.getDisplayName(), "description", DigestDeliveryMethod.SLACK_CHANNEL.getDescription())
185+
);
186+
return ResponseEntity.ok(methods);
187+
}
188+
189+
public record CreatePreferenceRequest(
190+
String connectionId,
191+
String deliveryMethod,
192+
String personaTag,
193+
String cronExpression,
194+
String timezone
195+
) {}
196+
197+
public record UpdatePreferenceRequest(
198+
Boolean enabled,
199+
String personaTag,
200+
String cronExpression,
201+
String timezone
202+
) {}
203+
204+
// ─────────────────────────────────────────────────────────────────────────
205+
// Admin: Seed & Status endpoints
206+
// ─────────────────────────────────────────────────────────────────────────
207+
208+
/**
209+
* Preview what preferences would be seeded from singleton config.
210+
* Admin only.
211+
*/
212+
@GetMapping("/admin/seed/preview")
213+
@PreAuthorize("hasRole('ADMIN')")
214+
public ResponseEntity<SeedPreviewResponse> previewSeed() {
215+
DigestPreferenceSeedService.SeedResult result = seedService.previewSeed();
216+
return ResponseEntity.ok(new SeedPreviewResponse(
217+
result.usersProcessed(),
218+
result.preferencesCreated(),
219+
result.skipped(),
220+
result.preferences().stream()
221+
.map(p -> new PreferencePreview(p.getUsername(), p.getConnectionId(),
222+
p.getPersonaTag() != null ? p.getPersonaTag().name() : null))
223+
.toList()
224+
));
225+
}
226+
227+
/**
228+
* Seed preferences for all Slack-linked users from singleton config.
229+
* Admin only. Idempotent: skips users with existing preferences.
230+
*/
231+
@PostMapping("/admin/seed")
232+
@PreAuthorize("hasRole('ADMIN')")
233+
public ResponseEntity<SeedResultResponse> executeSeed() {
234+
DigestPreferenceSeedService.SeedResult result = seedService.executeSeed();
235+
log.info("Admin seeded {} digest preferences for {} users",
236+
result.preferencesCreated(), result.usersProcessed());
237+
return ResponseEntity.ok(new SeedResultResponse(
238+
result.usersProcessed(),
239+
result.preferencesCreated(),
240+
result.skipped()
241+
));
242+
}
243+
244+
/**
245+
* Seed preferences for the current user.
246+
* Available to any authenticated user.
247+
*/
248+
@PostMapping("/seed/me")
249+
public ResponseEntity<SeedResultResponse> seedForCurrentUser() {
250+
String username = accessControlService.requireCurrentUsername();
251+
DigestPreferenceSeedService.SeedResult result = seedService.seedPreferencesForUser(username, false);
252+
return ResponseEntity.ok(new SeedResultResponse(
253+
1,
254+
result.preferencesCreated(),
255+
result.skipped()
256+
));
257+
}
258+
259+
/**
260+
* Get current digest mode info.
261+
*/
262+
@GetMapping("/status")
263+
public ResponseEntity<DigestStatusResponse> getStatus() {
264+
SlackDailyDigestService.DigestModeInfo modeInfo = digestService.getDigestModeInfo();
265+
return ResponseEntity.ok(new DigestStatusResponse(
266+
modeInfo.perUserMode(),
267+
modeInfo.enabledPreferences(),
268+
modeInfo.distinctUsers()
269+
));
270+
}
271+
272+
public record SeedPreviewResponse(
273+
int usersProcessed,
274+
int wouldCreate,
275+
List<String> wouldSkip,
276+
List<PreferencePreview> preferences
277+
) {}
278+
279+
public record PreferencePreview(String username, String connectionId, String personaTag) {}
280+
281+
public record SeedResultResponse(int usersProcessed, int preferencesCreated, List<String> skipped) {}
282+
283+
public record DigestStatusResponse(boolean perUserMode, long enabledPreferences, int distinctUsers) {}
284+
285+
@ExceptionHandler(IllegalArgumentException.class)
286+
public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException e) {
287+
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage() != null ? e.getMessage() : "Bad request"));
288+
}
289+
}

0 commit comments

Comments
 (0)