Skip to content

Commit e358131

Browse files
PR2: Role-aware digest insight assembler — Same Brain, different lens per role (#104)
Includes role-aware DigestInsight assembler (PR2), per-recipient Slack delivery + UI prefs + per-user cron tick (PR3/#105), conflict-resolved against main after #103.
1 parent c240171 commit e358131

26 files changed

Lines changed: 5823 additions & 134 deletions

backend/src/main/java/com/dbaagent/controller/DigestPreferenceController.java

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
import com.dbaagent.model.DigestDeliveryMethod;
44
import com.dbaagent.model.PersonaTag;
55
import com.dbaagent.model.UserDigestPreference;
6+
import com.dbaagent.service.DigestPreferenceSeedService;
67
import com.dbaagent.service.security.AccessControlService;
78
import com.dbaagent.service.UserDigestPreferenceService;
9+
import com.dbaagent.service.SlackDailyDigestService;
810
import lombok.RequiredArgsConstructor;
911
import lombok.extern.slf4j.Slf4j;
1012
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.access.prepost.PreAuthorize;
1114
import org.springframework.web.bind.annotation.*;
1215

1316
import java.util.List;
@@ -27,6 +30,8 @@ public class DigestPreferenceController {
2730

2831
private final UserDigestPreferenceService preferenceService;
2932
private final AccessControlService accessControlService;
33+
private final DigestPreferenceSeedService seedService;
34+
private final SlackDailyDigestService digestService;
3035

3136
/**
3237
* Get the current user's digest preferences.
@@ -44,9 +49,23 @@ public ResponseEntity<List<UserDigestPreference>> getMyPreferences() {
4449
public ResponseEntity<UserDigestPreference> createPreference(@RequestBody CreatePreferenceRequest request) {
4550
String username = accessControlService.requireCurrentUsername();
4651

47-
DigestDeliveryMethod method = request.deliveryMethod != null
48-
? DigestDeliveryMethod.fromString(request.deliveryMethod)
49-
: DigestDeliveryMethod.SLACK_DM;
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+
}
5069

5170
PersonaTag persona = request.personaTag != null
5271
? PersonaTag.fromString(request.personaTag)
@@ -159,10 +178,10 @@ public ResponseEntity<List<Map<String, String>>> getPersonaTags() {
159178
*/
160179
@GetMapping("/delivery-methods")
161180
public ResponseEntity<List<Map<String, String>>> getDeliveryMethods() {
181+
// Only advertise methods this PR actually delivers. EMAIL/WhatsApp are PR4.
162182
List<Map<String, String>> methods = List.of(
163183
Map.of("value", "SLACK_DM", "label", DigestDeliveryMethod.SLACK_DM.getDisplayName(), "description", DigestDeliveryMethod.SLACK_DM.getDescription()),
164-
Map.of("value", "SLACK_CHANNEL", "label", DigestDeliveryMethod.SLACK_CHANNEL.getDisplayName(), "description", DigestDeliveryMethod.SLACK_CHANNEL.getDescription()),
165-
Map.of("value", "EMAIL", "label", DigestDeliveryMethod.EMAIL.getDisplayName(), "description", DigestDeliveryMethod.EMAIL.getDescription())
184+
Map.of("value", "SLACK_CHANNEL", "label", DigestDeliveryMethod.SLACK_CHANNEL.getDisplayName(), "description", DigestDeliveryMethod.SLACK_CHANNEL.getDescription())
166185
);
167186
return ResponseEntity.ok(methods);
168187
}
@@ -181,4 +200,90 @@ public record UpdatePreferenceRequest(
181200
String cronExpression,
182201
String timezone
183202
) {}
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+
}
184289
}

backend/src/main/java/com/dbaagent/model/UserDigestPreference.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public class UserDigestPreference {
103103

104104
/**
105105
* Optional timezone for schedule interpretation.
106-
* When null, uses system default (typically UTC).
106+
* When null or invalid, the digest tick evaluates the cron in UTC.
107107
* Format: IANA timezone ID (e.g., "America/New_York", "Europe/London").
108108
*/
109109
@Column(name = "timezone", length = 64)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package com.dbaagent.model.digest;
2+
3+
import com.dbaagent.model.PersonaTag;
4+
import com.dbaagent.model.Role;
5+
import lombok.Builder;
6+
import lombok.Data;
7+
8+
import java.time.LocalDateTime;
9+
import java.util.List;
10+
import java.util.Map;
11+
12+
/**
13+
* Result of assembling a personalized digest for a user.
14+
*
15+
* <p>Contains the ranked insights tailored to the user's role and persona,
16+
* along with metadata about the assembly process.
17+
*/
18+
@Data
19+
@Builder
20+
public class DigestAssemblyResult {
21+
22+
/**
23+
* Username this digest was assembled for.
24+
*/
25+
private String username;
26+
27+
/**
28+
* Connection ID this digest covers.
29+
*/
30+
private String connectionId;
31+
32+
/**
33+
* User's RBAC role at assembly time.
34+
*/
35+
private Role role;
36+
37+
/**
38+
* User's persona tag (may be null for role-only personalization).
39+
*/
40+
private PersonaTag personaTag;
41+
42+
/**
43+
* When this digest was assembled.
44+
*/
45+
private LocalDateTime assembledAt;
46+
47+
/**
48+
* Time window start for insights (e.g., since last digest).
49+
*/
50+
private LocalDateTime windowStart;
51+
52+
/**
53+
* Time window end for insights.
54+
*/
55+
private LocalDateTime windowEnd;
56+
57+
/**
58+
* Ranked insights for this user (highest rank first).
59+
*/
60+
private List<DigestInsight> insights;
61+
62+
/**
63+
* Summary counts by category for quick overview.
64+
*/
65+
private Map<InsightCategory, Integer> categoryCounts;
66+
67+
/**
68+
* Total insights considered before filtering/ranking.
69+
*/
70+
private int totalCandidates;
71+
72+
/**
73+
* Insights suppressed as duplicates from last digest.
74+
*/
75+
private int suppressedDuplicates;
76+
77+
/**
78+
* Insights filtered out due to acknowledgment.
79+
*/
80+
private int filteredAcknowledged;
81+
82+
/**
83+
* Whether this is an empty digest (no actionable insights).
84+
*/
85+
@Builder.Default
86+
private boolean empty = false;
87+
88+
/**
89+
* Executive summary for EXEC personas (3 bullets max).
90+
* Null for other personas.
91+
*/
92+
private List<String> executiveSummary;
93+
94+
/**
95+
* Top decision ask for EXEC personas.
96+
* Null for other personas.
97+
*/
98+
private String decisionAsk;
99+
100+
/**
101+
* Get insights by category.
102+
*/
103+
public List<DigestInsight> getInsightsByCategory(InsightCategory category) {
104+
if (insights == null) return List.of();
105+
return insights.stream()
106+
.filter(i -> i.getCategory() == category)
107+
.toList();
108+
}
109+
110+
/**
111+
* Get top N insights.
112+
*/
113+
public List<DigestInsight> getTopInsights(int n) {
114+
if (insights == null) return List.of();
115+
return insights.stream().limit(n).toList();
116+
}
117+
118+
/**
119+
* Check if digest has critical insights.
120+
*/
121+
public boolean hasCriticalInsights() {
122+
return insights != null && insights.stream()
123+
.anyMatch(i -> i.getSeverity() >= 90);
124+
}
125+
126+
/**
127+
* Check if digest has high-severity insights.
128+
*/
129+
public boolean hasHighSeverityInsights() {
130+
return insights != null && insights.stream()
131+
.anyMatch(i -> i.getSeverity() >= 70);
132+
}
133+
134+
/**
135+
* Get the headline for the digest.
136+
*/
137+
public String getHeadline() {
138+
if (empty || insights == null || insights.isEmpty()) {
139+
return "No new insights since your last digest";
140+
}
141+
int count = insights.size();
142+
if (hasCriticalInsights()) {
143+
long criticalCount = insights.stream().filter(i -> i.getSeverity() >= 90).count();
144+
return String.format("%d insight%s (%d critical)",
145+
count, count != 1 ? "s" : "", criticalCount);
146+
}
147+
if (hasHighSeverityInsights()) {
148+
long highCount = insights.stream().filter(i -> i.getSeverity() >= 70).count();
149+
return String.format("%d insight%s (%d high priority)",
150+
count, count != 1 ? "s" : "", highCount);
151+
}
152+
return String.format("%d new insight%s", count, count != 1 ? "s" : "");
153+
}
154+
155+
/**
156+
* Create an empty result for a user with no insights.
157+
*/
158+
public static DigestAssemblyResult empty(String username, String connectionId,
159+
Role role, PersonaTag personaTag) {
160+
return DigestAssemblyResult.builder()
161+
.username(username)
162+
.connectionId(connectionId)
163+
.role(role)
164+
.personaTag(personaTag)
165+
.assembledAt(LocalDateTime.now())
166+
.insights(List.of())
167+
.categoryCounts(Map.of())
168+
.empty(true)
169+
.build();
170+
}
171+
}

0 commit comments

Comments
 (0)