Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
import com.dbaagent.model.DigestDeliveryMethod;
import com.dbaagent.model.PersonaTag;
import com.dbaagent.model.UserDigestPreference;
import com.dbaagent.service.DigestPreferenceSeedService;
import com.dbaagent.service.security.AccessControlService;
import com.dbaagent.service.UserDigestPreferenceService;
import com.dbaagent.service.SlackDailyDigestService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

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

private final UserDigestPreferenceService preferenceService;
private final AccessControlService accessControlService;
private final DigestPreferenceSeedService seedService;
private final SlackDailyDigestService digestService;

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

DigestDeliveryMethod method = request.deliveryMethod != null
? DigestDeliveryMethod.fromString(request.deliveryMethod)
: DigestDeliveryMethod.SLACK_DM;
DigestDeliveryMethod method;
if (request.deliveryMethod == null || request.deliveryMethod.isBlank()) {
method = DigestDeliveryMethod.SLACK_DM;
} else {
method = DigestDeliveryMethod.fromString(request.deliveryMethod);
if (method == null) {
throw new IllegalArgumentException("Unknown delivery method: " + request.deliveryMethod);
}
}

if (method == DigestDeliveryMethod.EMAIL) {
throw new IllegalArgumentException("Email digest delivery is not available yet");
}

if (request.connectionId != null && !request.connectionId.isBlank()) {
accessControlService.assertCanReadConnectionContent(request.connectionId);
}

PersonaTag persona = request.personaTag != null
? PersonaTag.fromString(request.personaTag)
Expand Down Expand Up @@ -159,10 +178,10 @@ public ResponseEntity<List<Map<String, String>>> getPersonaTags() {
*/
@GetMapping("/delivery-methods")
public ResponseEntity<List<Map<String, String>>> getDeliveryMethods() {
// Only advertise methods this PR actually delivers. EMAIL/WhatsApp are PR4.
List<Map<String, String>> methods = List.of(
Map.of("value", "SLACK_DM", "label", DigestDeliveryMethod.SLACK_DM.getDisplayName(), "description", DigestDeliveryMethod.SLACK_DM.getDescription()),
Map.of("value", "SLACK_CHANNEL", "label", DigestDeliveryMethod.SLACK_CHANNEL.getDisplayName(), "description", DigestDeliveryMethod.SLACK_CHANNEL.getDescription()),
Map.of("value", "EMAIL", "label", DigestDeliveryMethod.EMAIL.getDisplayName(), "description", DigestDeliveryMethod.EMAIL.getDescription())
Map.of("value", "SLACK_CHANNEL", "label", DigestDeliveryMethod.SLACK_CHANNEL.getDisplayName(), "description", DigestDeliveryMethod.SLACK_CHANNEL.getDescription())
);
return ResponseEntity.ok(methods);
}
Expand All @@ -181,4 +200,90 @@ public record UpdatePreferenceRequest(
String cronExpression,
String timezone
) {}

// ─────────────────────────────────────────────────────────────────────────
// Admin: Seed & Status endpoints
// ─────────────────────────────────────────────────────────────────────────

/**
* Preview what preferences would be seeded from singleton config.
* Admin only.
*/
@GetMapping("/admin/seed/preview")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<SeedPreviewResponse> previewSeed() {
DigestPreferenceSeedService.SeedResult result = seedService.previewSeed();
return ResponseEntity.ok(new SeedPreviewResponse(
result.usersProcessed(),
result.preferencesCreated(),
result.skipped(),
result.preferences().stream()
.map(p -> new PreferencePreview(p.getUsername(), p.getConnectionId(),
p.getPersonaTag() != null ? p.getPersonaTag().name() : null))
.toList()
));
}

/**
* Seed preferences for all Slack-linked users from singleton config.
* Admin only. Idempotent: skips users with existing preferences.
*/
@PostMapping("/admin/seed")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<SeedResultResponse> executeSeed() {
DigestPreferenceSeedService.SeedResult result = seedService.executeSeed();
log.info("Admin seeded {} digest preferences for {} users",
result.preferencesCreated(), result.usersProcessed());
return ResponseEntity.ok(new SeedResultResponse(
result.usersProcessed(),
result.preferencesCreated(),
result.skipped()
));
}

/**
* Seed preferences for the current user.
* Available to any authenticated user.
*/
@PostMapping("/seed/me")
public ResponseEntity<SeedResultResponse> seedForCurrentUser() {
String username = accessControlService.requireCurrentUsername();
DigestPreferenceSeedService.SeedResult result = seedService.seedPreferencesForUser(username, false);
return ResponseEntity.ok(new SeedResultResponse(
1,
result.preferencesCreated(),
result.skipped()
));
}

/**
* Get current digest mode info.
*/
@GetMapping("/status")
public ResponseEntity<DigestStatusResponse> getStatus() {
SlackDailyDigestService.DigestModeInfo modeInfo = digestService.getDigestModeInfo();
return ResponseEntity.ok(new DigestStatusResponse(
modeInfo.perUserMode(),
modeInfo.enabledPreferences(),
modeInfo.distinctUsers()
));
}

public record SeedPreviewResponse(
int usersProcessed,
int wouldCreate,
List<String> wouldSkip,
List<PreferencePreview> preferences
) {}

public record PreferencePreview(String username, String connectionId, String personaTag) {}

public record SeedResultResponse(int usersProcessed, int preferencesCreated, List<String> skipped) {}

public record DigestStatusResponse(boolean perUserMode, long enabledPreferences, int distinctUsers) {}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage() != null ? e.getMessage() : "Bad request"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public class UserDigestPreference {

/**
* Optional timezone for schedule interpretation.
* When null, uses system default (typically UTC).
* When null or invalid, the digest tick evaluates the cron in UTC.
* Format: IANA timezone ID (e.g., "America/New_York", "Europe/London").
*/
@Column(name = "timezone", length = 64)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package com.dbaagent.model.digest;

import com.dbaagent.model.PersonaTag;
import com.dbaagent.model.Role;
import lombok.Builder;
import lombok.Data;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

/**
* Result of assembling a personalized digest for a user.
*
* <p>Contains the ranked insights tailored to the user's role and persona,
* along with metadata about the assembly process.
*/
@Data
@Builder
public class DigestAssemblyResult {

/**
* Username this digest was assembled for.
*/
private String username;

/**
* Connection ID this digest covers.
*/
private String connectionId;

/**
* User's RBAC role at assembly time.
*/
private Role role;

/**
* User's persona tag (may be null for role-only personalization).
*/
private PersonaTag personaTag;

/**
* When this digest was assembled.
*/
private LocalDateTime assembledAt;

/**
* Time window start for insights (e.g., since last digest).
*/
private LocalDateTime windowStart;

/**
* Time window end for insights.
*/
private LocalDateTime windowEnd;

/**
* Ranked insights for this user (highest rank first).
*/
private List<DigestInsight> insights;

/**
* Summary counts by category for quick overview.
*/
private Map<InsightCategory, Integer> categoryCounts;

/**
* Total insights considered before filtering/ranking.
*/
private int totalCandidates;

/**
* Insights suppressed as duplicates from last digest.
*/
private int suppressedDuplicates;

/**
* Insights filtered out due to acknowledgment.
*/
private int filteredAcknowledged;

/**
* Whether this is an empty digest (no actionable insights).
*/
@Builder.Default
private boolean empty = false;

/**
* Executive summary for EXEC personas (3 bullets max).
* Null for other personas.
*/
private List<String> executiveSummary;

/**
* Top decision ask for EXEC personas.
* Null for other personas.
*/
private String decisionAsk;

/**
* Get insights by category.
*/
public List<DigestInsight> getInsightsByCategory(InsightCategory category) {
if (insights == null) return List.of();
return insights.stream()
.filter(i -> i.getCategory() == category)
.toList();
}

/**
* Get top N insights.
*/
public List<DigestInsight> getTopInsights(int n) {
if (insights == null) return List.of();
return insights.stream().limit(n).toList();
}

/**
* Check if digest has critical insights.
*/
public boolean hasCriticalInsights() {
return insights != null && insights.stream()
.anyMatch(i -> i.getSeverity() >= 90);
}

/**
* Check if digest has high-severity insights.
*/
public boolean hasHighSeverityInsights() {
return insights != null && insights.stream()
.anyMatch(i -> i.getSeverity() >= 70);
}

/**
* Get the headline for the digest.
*/
public String getHeadline() {
if (empty || insights == null || insights.isEmpty()) {
return "No new insights since your last digest";
}
int count = insights.size();
if (hasCriticalInsights()) {
long criticalCount = insights.stream().filter(i -> i.getSeverity() >= 90).count();
return String.format("%d insight%s (%d critical)",
count, count != 1 ? "s" : "", criticalCount);
}
if (hasHighSeverityInsights()) {
long highCount = insights.stream().filter(i -> i.getSeverity() >= 70).count();
return String.format("%d insight%s (%d high priority)",
count, count != 1 ? "s" : "", highCount);
}
return String.format("%d new insight%s", count, count != 1 ? "s" : "");
}

/**
* Create an empty result for a user with no insights.
*/
public static DigestAssemblyResult empty(String username, String connectionId,
Role role, PersonaTag personaTag) {
return DigestAssemblyResult.builder()
.username(username)
.connectionId(connectionId)
.role(role)
.personaTag(personaTag)
.assembledAt(LocalDateTime.now())
.insights(List.of())
.categoryCounts(Map.of())
.empty(true)
.build();
}
}
Loading
Loading