From 7bd3ed1f10ce9c5d7a50204dc133c99454c862d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 12:54:20 +0000 Subject: [PATCH 1/4] feat(digest): per-recipient Slack delivery with UI prefs (PR3) - Extend SlackDailyDigestService with sendPersonalizedDigests() consuming DigestAssemblyResult - Add DigestPreferenceSeedService to seed preferences from singleton config - Update scheduler to use hybrid mode (per-user when prefs exist, legacy otherwise) - Add digest preferences API endpoints to client.js - Create DigestPreferencesPanel UI component with persona selection - Integrate preferences panel into DigestSection (bell icon) - Add tests for per-recipient delivery and seed/legacy fallback - Update DIGEST_PREFERENCES.md documentation Two users with different personas on same connection get different digests. EXEC persona gets tight 3-bullet executive summary. Cron/config not singleton-only in the delivery path. Depends on: PR1 #103 (per-user digest prefs), PR2 #104 (role-aware assembler) GTM: Same Brain, different lens per role Co-authored-by: Venkat SF --- .../DigestPreferenceController.java | 86 +++ .../repository/SlackDigestLogRepository.java | 6 + .../repository/SlackUserLinkRepository.java | 16 + .../service/DigestPreferenceSeedService.java | 237 ++++++++ .../service/SlackDailyDigestService.java | 463 +++++++++++++++ .../service/SlackUserLinkService.java | 18 + .../scheduler/SlackDailyDigestTaskConfig.java | 22 +- .../DigestPreferenceSeedServiceTest.java | 294 +++++++++ .../PerRecipientDigestDeliveryTest.java | 269 +++++++++ docs/DIGEST_PREFERENCES.md | 305 ++++++---- .../sections/DigestPreferencesPanel.jsx | 458 ++++++++++++++ .../DigestPreferencesPanel.module.css | 558 ++++++++++++++++++ src/components/sections/DigestSection.jsx | 12 +- src/lib/api/client.js | 48 ++ 14 files changed, 2668 insertions(+), 124 deletions(-) create mode 100644 backend/src/main/java/com/dbaagent/service/DigestPreferenceSeedService.java create mode 100644 backend/src/test/java/com/dbaagent/service/DigestPreferenceSeedServiceTest.java create mode 100644 backend/src/test/java/com/dbaagent/service/PerRecipientDigestDeliveryTest.java create mode 100644 src/components/sections/DigestPreferencesPanel.jsx create mode 100644 src/components/sections/DigestPreferencesPanel.module.css diff --git a/backend/src/main/java/com/dbaagent/controller/DigestPreferenceController.java b/backend/src/main/java/com/dbaagent/controller/DigestPreferenceController.java index 283e933..d158a28 100644 --- a/backend/src/main/java/com/dbaagent/controller/DigestPreferenceController.java +++ b/backend/src/main/java/com/dbaagent/controller/DigestPreferenceController.java @@ -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; @@ -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. @@ -181,4 +186,85 @@ 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 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 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 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 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 wouldSkip, + List preferences + ) {} + + public record PreferencePreview(String username, String connectionId, String personaTag) {} + + public record SeedResultResponse(int usersProcessed, int preferencesCreated, List skipped) {} + + public record DigestStatusResponse(boolean perUserMode, long enabledPreferences, int distinctUsers) {} } diff --git a/backend/src/main/java/com/dbaagent/repository/SlackDigestLogRepository.java b/backend/src/main/java/com/dbaagent/repository/SlackDigestLogRepository.java index 5ed6051..204cd6c 100644 --- a/backend/src/main/java/com/dbaagent/repository/SlackDigestLogRepository.java +++ b/backend/src/main/java/com/dbaagent/repository/SlackDigestLogRepository.java @@ -91,4 +91,10 @@ SELECT DISTINCT ON (recipient_username) * * Count personalized vs non-personalized digests since a date. */ long countByPersonalizedAndSentAtAfter(boolean personalized, LocalDateTime since); + + /** + * Find the most recent digest for a connection (any type). + * Used for determining the window start for new digests. + */ + Optional findTopByConnectionIdOrderBySentAtDesc(String connectionId); } diff --git a/backend/src/main/java/com/dbaagent/repository/SlackUserLinkRepository.java b/backend/src/main/java/com/dbaagent/repository/SlackUserLinkRepository.java index 40ecab6..b5f5297 100644 --- a/backend/src/main/java/com/dbaagent/repository/SlackUserLinkRepository.java +++ b/backend/src/main/java/com/dbaagent/repository/SlackUserLinkRepository.java @@ -2,10 +2,26 @@ import com.dbaagent.model.SlackUserLink; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface SlackUserLinkRepository extends JpaRepository { Optional findByTeamIdAndSlackUserId(String teamId, String slackUserId); Optional findByTeamIdAndSlackUserIdAndLinkStatus(String teamId, String slackUserId, String linkStatus); + + /** + * Find all linked Slack accounts for a DeepSQL username. + * Returns only LINKED entries (not PENDING or REVOKED). + */ + @Query("SELECT l FROM SlackUserLink l WHERE l.deepsqlUsername = :username AND l.linkStatus = 'LINKED'") + List findLinkedByDeepsqlUsername(@Param("username") String username); + + /** + * Find all linked users (for seeding digest preferences). + */ + @Query("SELECT DISTINCT l.deepsqlUsername FROM SlackUserLink l WHERE l.linkStatus = 'LINKED'") + List findAllLinkedDeepsqlUsernames(); } diff --git a/backend/src/main/java/com/dbaagent/service/DigestPreferenceSeedService.java b/backend/src/main/java/com/dbaagent/service/DigestPreferenceSeedService.java new file mode 100644 index 0000000..2a77546 --- /dev/null +++ b/backend/src/main/java/com/dbaagent/service/DigestPreferenceSeedService.java @@ -0,0 +1,237 @@ +package com.dbaagent.service; + +import com.dbaagent.model.DigestDeliveryMethod; +import com.dbaagent.model.PersonaTag; +import com.dbaagent.model.Role; +import com.dbaagent.model.SlackDigestConfig; +import com.dbaagent.model.User; +import com.dbaagent.model.UserDigestPreference; +import com.dbaagent.repository.SlackDigestConfigRepository; +import com.dbaagent.repository.UserDigestPreferenceRepository; +import com.dbaagent.repository.UserRepository; +import com.dbaagent.service.security.ConnectionAccessService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Service for seeding digest preferences from singleton config. + * + *

Provides migration helpers so existing deployments (like Stayflexi) don't stay + * on legacy broadcast forever after the first per-user preferences appear. + * + *

Seed Strategy

+ *
    + *
  1. Find all users with linked Slack accounts (candidates for DM delivery)
  2. + *
  3. For each user, create a SLACK_DM preference for each connection they can access
  4. + *
  5. Persona tag is inferred from the user's role (can be edited later in UI)
  6. + *
  7. Cron expression defaults to the global singleton config
  8. + *
+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class DigestPreferenceSeedService { + + private final UserDigestPreferenceRepository preferenceRepository; + private final SlackDigestConfigRepository configRepository; + private final SlackUserLinkService slackUserLinkService; + private final ConnectionAccessService connectionAccessService; + private final UserRepository userRepository; + + /** + * Seed digest preferences for all Slack-linked users. + * + *

This creates one SLACK_DM preference per connection the user can access. + * Existing preferences are not overwritten. + * + * @param dryRun if true, return what would be created without persisting + * @return summary of seeded preferences + */ + @Transactional + public SeedResult seedPreferencesFromSingleton(boolean dryRun) { + List linkedUsernames = slackUserLinkService.getAllLinkedUsernames(); + if (linkedUsernames.isEmpty()) { + log.info("No Slack-linked users found; nothing to seed"); + return new SeedResult(0, 0, List.of(), List.of()); + } + + String globalCron = getGlobalCronExpression(); + List created = new ArrayList<>(); + List skipped = new ArrayList<>(); + + for (String username : linkedUsernames) { + Optional userOpt = userRepository.findByUsernameIgnoreCase(username); + if (userOpt.isEmpty()) { + skipped.add(username + ": user not found"); + continue; + } + + User user = userOpt.get(); + if (!user.isActive()) { + skipped.add(username + ": user inactive"); + continue; + } + + List connectionIds = connectionAccessService + .getVisibleConnections(username, user.isAdmin()) + .stream() + .map(conn -> conn.getId()) + .toList(); + + if (connectionIds.isEmpty()) { + skipped.add(username + ": no accessible connections"); + continue; + } + + PersonaTag inferredPersona = inferPersonaFromRole(user.getRoleEnum()); + + for (String connectionId : connectionIds) { + // Check if preference already exists + Optional existing = preferenceRepository + .findByUsernameAndConnectionIdAndDeliveryMethod( + username, connectionId, DigestDeliveryMethod.SLACK_DM); + + if (existing.isPresent()) { + skipped.add(username + "/" + connectionId + ": preference exists"); + continue; + } + + UserDigestPreference pref = UserDigestPreference.builder() + .username(username) + .connectionId(connectionId) + .enabled(true) + .deliveryMethod(DigestDeliveryMethod.SLACK_DM) + .personaTag(inferredPersona) + .cronExpression(null) // Use global default + .timezone(null) // Use system default + .build(); + + if (!dryRun) { + preferenceRepository.save(pref); + } + created.add(pref); + } + } + + log.info("Digest preference seed: {} users, {} created, {} skipped (dryRun={})", + linkedUsernames.size(), created.size(), skipped.size(), dryRun); + + return new SeedResult(linkedUsernames.size(), created.size(), skipped, created); + } + + /** + * Seed preferences for a single user. + */ + @Transactional + public SeedResult seedPreferencesForUser(String username, boolean dryRun) { + Optional userOpt = userRepository.findByUsernameIgnoreCase(username); + if (userOpt.isEmpty()) { + return new SeedResult(0, 0, List.of(username + ": user not found"), List.of()); + } + + User user = userOpt.get(); + if (!user.isActive()) { + return new SeedResult(0, 0, List.of(username + ": user inactive"), List.of()); + } + + // Check if user has Slack linked + List links = slackUserLinkService.getLinkedSlackAccounts(username); + if (links.isEmpty()) { + return new SeedResult(0, 0, List.of(username + ": not linked to Slack"), List.of()); + } + + List connectionIds = connectionAccessService + .getVisibleConnections(username, user.isAdmin()) + .stream() + .map(conn -> conn.getId()) + .toList(); + + if (connectionIds.isEmpty()) { + return new SeedResult(0, 0, List.of(username + ": no accessible connections"), List.of()); + } + + PersonaTag inferredPersona = inferPersonaFromRole(user.getRoleEnum()); + List created = new ArrayList<>(); + List skipped = new ArrayList<>(); + + for (String connectionId : connectionIds) { + Optional existing = preferenceRepository + .findByUsernameAndConnectionIdAndDeliveryMethod( + username, connectionId, DigestDeliveryMethod.SLACK_DM); + + if (existing.isPresent()) { + skipped.add(connectionId + ": preference exists"); + continue; + } + + UserDigestPreference pref = UserDigestPreference.builder() + .username(username) + .connectionId(connectionId) + .enabled(true) + .deliveryMethod(DigestDeliveryMethod.SLACK_DM) + .personaTag(inferredPersona) + .build(); + + if (!dryRun) { + preferenceRepository.save(pref); + } + created.add(pref); + } + + log.info("Seeded {} preferences for user {} (dryRun={})", created.size(), username, dryRun); + + return new SeedResult(1, created.size(), skipped, created); + } + + /** + * Infer a persona tag from the user's role. + */ + private PersonaTag inferPersonaFromRole(Role role) { + if (role == null) { + return null; // No persona; use role-based prioritization only + } + return switch (role) { + case ADMIN -> null; // Admins often wear multiple hats; let them pick + case DBA -> PersonaTag.DBA; + case DATA_ENGINEER -> PersonaTag.DATA_ENG; + case DEVELOPER -> PersonaTag.APP_ENG; + }; + } + + private String getGlobalCronExpression() { + return configRepository.findById(1L) + .map(SlackDigestConfig::getCronExpression) + .orElse("0 0 9 * * *"); + } + + /** + * Get seed preview: what would be created without actually seeding. + */ + public SeedResult previewSeed() { + return seedPreferencesFromSingleton(true); + } + + /** + * Execute seed: create preferences for all eligible users. + */ + public SeedResult executeSeed() { + return seedPreferencesFromSingleton(false); + } + + public record SeedResult( + int usersProcessed, + int preferencesCreated, + List skipped, + List preferences + ) { + public boolean hasCreations() { + return preferencesCreated > 0; + } + } +} diff --git a/backend/src/main/java/com/dbaagent/service/SlackDailyDigestService.java b/backend/src/main/java/com/dbaagent/service/SlackDailyDigestService.java index b0f8fb4..d1da1de 100644 --- a/backend/src/main/java/com/dbaagent/service/SlackDailyDigestService.java +++ b/backend/src/main/java/com/dbaagent/service/SlackDailyDigestService.java @@ -8,26 +8,34 @@ import com.dbaagent.model.ConnectionRequest; import com.dbaagent.model.DatabaseEvent; import com.dbaagent.model.DatabaseObject; +import com.dbaagent.model.DigestDeliveryMethod; import com.dbaagent.model.GrowthAnomaly; import com.dbaagent.model.IndexRecommendationEntity; import com.dbaagent.model.IndexRecommendationEvidence; import com.dbaagent.model.LockContention; import com.dbaagent.model.PerformanceAction; import com.dbaagent.model.PerformanceSnapshot; +import com.dbaagent.model.PersonaTag; import com.dbaagent.model.QueryFingerprint; import com.dbaagent.util.QueryLabeler; import com.dbaagent.model.QueryRequest; import com.dbaagent.model.QueryResult; +import com.dbaagent.model.Role; import com.dbaagent.model.SchemaChange; import com.dbaagent.model.SchemaSnapshot; import com.dbaagent.model.SlackChannelBinding; import com.dbaagent.model.SlackDigestLog; +import com.dbaagent.model.SlackUserLink; import com.dbaagent.model.SlowQuery; import com.dbaagent.model.SlowQueryAnalysis; import com.dbaagent.model.TableStatsHistory; +import com.dbaagent.model.User; import com.dbaagent.model.UserDigestPreference; +import com.dbaagent.model.digest.DigestAssemblyResult; +import com.dbaagent.model.digest.DigestInsight; import com.dbaagent.repository.AuthLoginChallengeRepository; import com.dbaagent.repository.UserDigestPreferenceRepository; +import com.dbaagent.repository.UserRepository; import com.dbaagent.repository.CapacityForecastRepository; import com.dbaagent.repository.ConnectionAccessGrantRepository; import com.dbaagent.repository.DatabaseEventRepository; @@ -38,10 +46,13 @@ import com.dbaagent.repository.SlackChannelBindingRepository; import com.dbaagent.repository.SlackDigestLogRepository; import com.dbaagent.repository.TableStatsHistoryRepository; +import com.dbaagent.service.digest.DigestInsightAssemblerService; import com.slack.api.Slack; import com.slack.api.methods.MethodsClient; import com.slack.api.methods.SlackApiException; import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.methods.request.conversations.ConversationsOpenRequest; +import com.slack.api.methods.response.conversations.ConversationsOpenResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.lang.Nullable; @@ -106,6 +117,8 @@ public class SlackDailyDigestService { private final IndexAdvisorService indexAdvisorService; private final IndexRecommendationService indexRecommendationService; private final UserDigestPreferenceRepository userDigestPreferenceRepository; + private final DigestInsightAssemblerService digestInsightAssemblerService; + private final UserRepository userRepository; private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("MMM d, yyyy"); private static final int TOP_TABLES = 5; @@ -453,6 +466,456 @@ private List sendToBoundChannels(List bindings, Str .toList(); } + // ───────────────────────────────────────────────────────────────────────── + // Per-recipient personalized digest delivery (PR3) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Send personalized digests to all users with enabled preferences for a connection. + * Uses DigestAssemblyResult to generate role-aware, persona-tailored content. + * + *

Two users with different personas on the same connection get different + * Slack digests for the same time window. + * + * @param connectionId the connection to generate digests for + * @return summary of delivery results + */ + public PersonalizedDeliveryResult sendPersonalizedDigests(String connectionId) { + List recipients = userDigestPreferenceRepository + .findEnabledForConnection(connectionId); + + if (recipients.isEmpty()) { + log.debug("No per-user preferences for connection {}; skipping personalized delivery", connectionId); + return new PersonalizedDeliveryResult(0, 0, 0, List.of()); + } + + boolean slackEnabled = isSlackDeliveryEnabled(); + if (!slackEnabled) { + log.info("Slack delivery disabled; generating {} personalized digest(s) without sending", recipients.size()); + } + + String connName = connectionName(connectionId); + LocalDateTime since = getLastDigestTime(connectionId); + List errors = new ArrayList<>(); + int sent = 0; + int generated = 0; + + for (UserDigestPreference pref : recipients) { + if (pref.getDeliveryMethod() != DigestDeliveryMethod.SLACK_DM) { + continue; // Only Slack DM for now; EMAIL/WhatsApp in PR4 + } + + try { + PersonalizedDigestResult result = sendPersonalizedDigestToUser( + connectionId, connName, pref, since, slackEnabled); + + if (result.generated()) { + generated++; + if (result.sent()) { + sent++; + } + } + if (result.error() != null) { + errors.add(pref.getUsername() + ": " + result.error()); + } + } catch (Exception e) { + log.error("Failed to deliver personalized digest to user {} for connection {}: {}", + pref.getUsername(), connectionId, e.getMessage(), e); + errors.add(pref.getUsername() + ": " + e.getMessage()); + } + } + + log.info("Personalized digest delivery for connection {}: {} generated, {} sent, {} errors", + connectionId, generated, sent, errors.size()); + + return new PersonalizedDeliveryResult(recipients.size(), generated, sent, errors); + } + + /** + * Send a personalized digest to a single user based on their preference. + */ + private PersonalizedDigestResult sendPersonalizedDigestToUser( + String connectionId, String connName, + UserDigestPreference pref, LocalDateTime since, + boolean slackEnabled) { + + String username = pref.getUsername(); + PersonaTag personaTag = pref.getPersonaTag(); + + // Resolve user's role + Role role = userRepository.findByUsernameIgnoreCase(username) + .map(User::getRoleEnum) + .orElse(Role.DEVELOPER); + + // Assemble personalized digest + DigestAssemblyResult assembly = digestInsightAssemblerService.assembleDigest( + username, connectionId, role, personaTag, since); + + // Format digest message + String message = formatPersonalizedDigest(assembly, connName, personaTag); + + // Create log entry + SlackDigestLog logEntry = new SlackDigestLog(); + logEntry.setConnectionId(connectionId); + logEntry.setConnectionName(connName); + logEntry.setSentAt(LocalDateTime.now()); + logEntry.setContent(message); + logEntry.setHeadline(assembly.getHeadline()); + logEntry.setRecipientUsername(username); + logEntry.setRecipientRole(role.name()); + logEntry.setPersonaTag(personaTag); + logEntry.setDeliveryMethod(DigestDeliveryMethod.SLACK_DM); + logEntry.setPreferenceId(pref.getId()); + logEntry.setPersonalized(true); + + boolean sent = false; + String error = null; + + if (slackEnabled && !assembly.isEmpty()) { + try { + String dmChannelId = openDmChannel(username); + if (dmChannelId != null) { + postMessage(dmChannelId, message); + logEntry.setChannelId(dmChannelId); + logEntry.setStatus("SENT"); + sent = true; + log.debug("Sent personalized digest to {} (persona={}, {} insights)", + username, personaTag, assembly.getInsights().size()); + } else { + logEntry.setStatus("FAILED"); + error = "Could not open DM channel - user not linked to Slack"; + logEntry.setErrorMessage(error); + } + } catch (Exception e) { + logEntry.setStatus("FAILED"); + error = e.getMessage(); + logEntry.setErrorMessage(error); + log.error("Failed to send personalized digest DM to {}: {}", username, e.getMessage()); + } + } else { + logEntry.setStatus("GENERATED"); + if (!slackEnabled) { + logEntry.setErrorMessage("Slack delivery disabled"); + } else if (assembly.isEmpty()) { + logEntry.setErrorMessage("No insights to deliver"); + } + } + + // Persist log + try { + digestLogRepository.save(logEntry); + } catch (Exception e) { + log.warn("Could not persist personalized digest log for {}: {}", username, e.getMessage()); + } + + return new PersonalizedDigestResult(true, sent, error); + } + + /** + * Open a DM channel with a user via their linked Slack account. + * Returns the channel ID for sending messages, or null if user not linked. + */ + private String openDmChannel(String deepsqlUsername) { + List links = slackUserLinkService.getLinkedSlackAccounts(deepsqlUsername); + if (links.isEmpty()) { + log.debug("User {} has no linked Slack accounts for DM delivery", deepsqlUsername); + return null; + } + + // Use the first linked account (most users have one) + SlackUserLink link = links.get(0); + String botToken = slackRuntimeSettingsService.current().botToken(); + if (botToken == null || botToken.isBlank()) { + log.warn("No Slack bot token configured; cannot open DM channel"); + return null; + } + + MethodsClient client = Slack.getInstance().methods(botToken); + try { + ConversationsOpenResponse response = client.conversationsOpen( + ConversationsOpenRequest.builder() + .users(List.of(link.getSlackUserId())) + .build()); + + if (response.isOk() && response.getChannel() != null) { + return response.getChannel().getId(); + } else { + log.error("Failed to open DM channel with Slack user {}: {}", + link.getSlackUserId(), response.getError()); + return null; + } + } catch (IOException | SlackApiException e) { + log.error("Failed to open DM channel with Slack user {}: {}", + link.getSlackUserId(), e.getMessage()); + return null; + } + } + + /** + * Format a personalized digest message from DigestAssemblyResult. + * EXEC personas get tight 3-bullet executive summaries. + */ + private String formatPersonalizedDigest(DigestAssemblyResult assembly, String connName, PersonaTag personaTag) { + StringBuilder sb = new StringBuilder(); + DigestStyle style = chooseStyle(assembly.getConnectionId()); + + // Header + sb.append("*").append(style.digestTitle()).append(" — ").append(connName).append("*\n"); + sb.append("_").append(LocalDateTime.now().format(DATE_FMT)) + .append(" · ").append(assembly.getHeadline()).append("_\n"); + + if (personaTag != null) { + sb.append("_Personalized for: ").append(personaTag.getDisplayName()).append("_\n"); + } + sb.append("────────────────────────\n\n"); + + if (assembly.isEmpty()) { + sb.append("✓ No new insights since your last digest — your database is running smoothly.\n\n"); + sb.append("_").append(style.footer()).append("_"); + return sb.toString(); + } + + // EXEC persona: tight executive summary (3 bullets max) + if (personaTag == PersonaTag.EXEC) { + formatExecDigest(sb, assembly, style); + } else { + formatStandardDigest(sb, assembly, style); + } + + sb.append("\n_").append(style.footer()).append("_"); + return sb.toString(); + } + + /** + * Format EXEC digest: 3 bullets + decision ask. Keep it tight. + */ + private void formatExecDigest(StringBuilder sb, DigestAssemblyResult assembly, DigestStyle style) { + sb.append("*🧭 EXECUTIVE SUMMARY*\n"); + + List execSummary = assembly.getExecutiveSummary(); + if (execSummary != null && !execSummary.isEmpty()) { + for (String bullet : execSummary.stream().limit(3).toList()) { + sb.append("• ").append(bullet).append("\n"); + } + } else { + // Generate summary from top insights + List top = assembly.getTopInsights(3); + for (DigestInsight insight : top) { + String emoji = getInsightEmoji(insight); + sb.append("• ").append(emoji).append(" ").append(insight.getHeadline()).append("\n"); + } + } + sb.append("\n"); + + // Decision ask + String decisionAsk = assembly.getDecisionAsk(); + if (decisionAsk != null && !decisionAsk.isBlank()) { + sb.append("*📋 ACTION NEEDED*\n"); + sb.append(decisionAsk).append("\n\n"); + } + + // Stats line + sb.append("_").append(assembly.getInsights().size()).append(" insight"); + if (assembly.getInsights().size() != 1) sb.append("s"); + sb.append(" available for detailed review_\n"); + } + + /** + * Format standard digest for non-EXEC personas. + */ + private void formatStandardDigest(StringBuilder sb, DigestAssemblyResult assembly, DigestStyle style) { + List insights = assembly.getInsights(); + + // Critical insights first + List critical = insights.stream() + .filter(i -> i.getSeverity() >= 90) + .toList(); + if (!critical.isEmpty()) { + sb.append("*⚠️ CRITICAL*\n"); + for (DigestInsight insight : critical.stream().limit(3).toList()) { + formatInsight(sb, insight); + } + sb.append("\n"); + } + + // High-priority insights + List high = insights.stream() + .filter(i -> i.getSeverity() >= 70 && i.getSeverity() < 90) + .toList(); + if (!high.isEmpty()) { + sb.append("*🔶 HIGH PRIORITY*\n"); + for (DigestInsight insight : high.stream().limit(3).toList()) { + formatInsight(sb, insight); + } + sb.append("\n"); + } + + // Other insights summary + List other = insights.stream() + .filter(i -> i.getSeverity() < 70) + .toList(); + if (!other.isEmpty()) { + sb.append("*📋 OTHER INSIGHTS*\n"); + for (DigestInsight insight : other.stream().limit(5).toList()) { + formatInsight(sb, insight); + } + if (other.size() > 5) { + sb.append("_... and ").append(other.size() - 5).append(" more_\n"); + } + sb.append("\n"); + } + + // Suppressed/filtered counts + if (assembly.getSuppressedDuplicates() > 0 || assembly.getFilteredAcknowledged() > 0) { + sb.append("_"); + if (assembly.getSuppressedDuplicates() > 0) { + sb.append(assembly.getSuppressedDuplicates()).append(" duplicate"); + if (assembly.getSuppressedDuplicates() != 1) sb.append("s"); + sb.append(" suppressed"); + } + if (assembly.getFilteredAcknowledged() > 0) { + if (assembly.getSuppressedDuplicates() > 0) sb.append(", "); + sb.append(assembly.getFilteredAcknowledged()).append(" acknowledged item"); + if (assembly.getFilteredAcknowledged() != 1) sb.append("s"); + sb.append(" filtered"); + } + sb.append("_\n"); + } + } + + private void formatInsight(StringBuilder sb, DigestInsight insight) { + String emoji = getInsightEmoji(insight); + sb.append(emoji).append(" *").append(insight.getHeadline()).append("*\n"); + if (insight.getDescription() != null && !insight.getDescription().isBlank()) { + sb.append(" ").append(insight.getDescription()).append("\n"); + } + if (insight.isActionable() && insight.getSuggestedAction() != null) { + sb.append(" → _").append(insight.getSuggestedAction()).append("_\n"); + } + // Add signature for dedup tracking + if (insight.getSignatureKey() != null) { + sb.append(" [sig:").append(insight.getSignatureKey()).append("]\n"); + } + } + + private String getInsightEmoji(DigestInsight insight) { + return switch (insight.getCategory()) { + case QUERY_PERFORMANCE -> "🐢"; + case INDEX_RECOMMENDATIONS -> "📈"; + case SCHEMA_CHANGES -> "🔧"; + case GROWTH_ANOMALIES -> "📊"; + case LOCK_CONCURRENCY -> "🔒"; + case CONFIG_TUNING -> "⚙️"; + case BRAIN_INTELLIGENCE -> "🧠"; + case SYSTEM_ALERTS -> "🔔"; + case COST_CAPACITY -> "💰"; + case DOCUMENTATION_GAPS -> "📝"; + }; + } + + /** + * Get the timestamp of the last digest sent for a connection. + * Used as the window start for personalized digest assembly. + */ + private LocalDateTime getLastDigestTime(String connectionId) { + return digestLogRepository + .findTopByConnectionIdOrderBySentAtDesc(connectionId) + .map(SlackDigestLog::getSentAt) + .orElse(LocalDateTime.now().minusHours(24)); + } + + /** + * Entry point for the hybrid digest run: per-user when preferences exist, + * legacy broadcast otherwise. + */ + public void sendDailyDigestHybrid() { + List connectionIds = digestConnectionIds(); + if (connectionIds.isEmpty()) { + log.info("No connections available — skipping daily digest generation"); + return; + } + + boolean hasPreferences = isPerUserModeEnabled(); + log.info("Digest mode: {} (connections={})", + hasPreferences ? "per-user personalized" : "legacy broadcast", + connectionIds.size()); + + for (String connectionId : connectionIds) { + try { + if (hasPreferences) { + // Per-user personalized delivery + PersonalizedDeliveryResult result = sendPersonalizedDigests(connectionId); + log.info("Connection {}: {} recipients, {} sent, {} errors", + connectionId, result.recipients(), result.sent(), result.errors().size()); + + // Also send legacy broadcast if channel bindings exist (dual mode) + if (result.recipients() == 0) { + // No per-user prefs for this connection; use legacy + sendLegacyDigest(connectionId); + } + } else { + // Legacy broadcast mode + sendLegacyDigest(connectionId); + } + } catch (Exception e) { + log.error("Failed to generate digest for connection {}: {}", connectionId, e.getMessage(), e); + } + } + } + + /** + * Legacy broadcast digest to channel bindings (pre-PR3 behavior). + */ + private void sendLegacyDigest(String connectionId) { + SlackDigestLog logEntry = new SlackDigestLog(); + logEntry.setConnectionId(connectionId); + logEntry.setConnectionName(connectionName(connectionId)); + logEntry.setChannelId(null); + logEntry.setSentAt(LocalDateTime.now()); + logEntry.setPersonalized(false); + + try { + String message = buildRichDigest(connectionId); + logEntry.setContent(message); + logEntry.setHeadline(extractHeadline(message)); + + List bindings = channelBindingRepository.findAll().stream() + .filter(b -> connectionId.equals(b.getDefaultConnectionId())) + .collect(Collectors.toList()); + bindings = filterAdminRecipients(bindings); + + boolean slackEnabled = isSlackDeliveryEnabled(); + if (!slackEnabled || bindings.isEmpty()) { + logEntry.setStatus("GENERATED"); + logEntry.setErrorMessage(slackEnabled ? "No Slack channel bindings" : "Slack delivery disabled"); + } else { + List failures = sendToBoundChannels(bindings, message); + if (failures.isEmpty()) { + logEntry.setStatus("SENT"); + } else if (failures.size() < bindings.size()) { + logEntry.setStatus("PARTIAL"); + logEntry.setErrorMessage(String.join(" | ", failures)); + } else { + logEntry.setStatus("FAILED"); + logEntry.setErrorMessage(String.join(" | ", failures)); + } + } + } catch (Exception e) { + logEntry.setStatus("FAILED"); + logEntry.setErrorMessage(e.getMessage()); + log.error("Failed to generate legacy digest for connection {}: {}", connectionId, e.getMessage(), e); + } finally { + try { + digestLogRepository.save(logEntry); + } catch (Exception e) { + log.warn("Could not persist legacy digest log: {}", e.getMessage()); + } + } + } + + public record PersonalizedDeliveryResult(int recipients, int generated, int sent, List errors) {} + public record PersonalizedDigestResult(boolean generated, boolean sent, String error) {} + // ───────────────────────────────────────────────────────────────────────── // Main builder // ───────────────────────────────────────────────────────────────────────── diff --git a/backend/src/main/java/com/dbaagent/service/SlackUserLinkService.java b/backend/src/main/java/com/dbaagent/service/SlackUserLinkService.java index ecd4d53..cc62194 100644 --- a/backend/src/main/java/com/dbaagent/service/SlackUserLinkService.java +++ b/backend/src/main/java/com/dbaagent/service/SlackUserLinkService.java @@ -222,6 +222,24 @@ private String slackCodeAad(String username) { return "dba-agent:slack-link-code:" + (username == null ? "" : username.toLowerCase(Locale.ROOT)); } + /** + * Get linked Slack accounts for a DeepSQL username. + * Used for sending personalized digest DMs. + */ + @Transactional(readOnly = true) + public List getLinkedSlackAccounts(String deepsqlUsername) { + return slackUserLinkRepository.findLinkedByDeepsqlUsername(deepsqlUsername); + } + + /** + * Get all DeepSQL usernames that have linked Slack accounts. + * Used for seeding digest preferences. + */ + @Transactional(readOnly = true) + public List getAllLinkedUsernames() { + return slackUserLinkRepository.findAllLinkedDeepsqlUsernames(); + } + public record LinkedUser(String username, boolean admin) { } } diff --git a/backend/src/main/java/com/dbaagent/service/scheduler/SlackDailyDigestTaskConfig.java b/backend/src/main/java/com/dbaagent/service/scheduler/SlackDailyDigestTaskConfig.java index ea3df6d..6e3cc2b 100644 --- a/backend/src/main/java/com/dbaagent/service/scheduler/SlackDailyDigestTaskConfig.java +++ b/backend/src/main/java/com/dbaagent/service/scheduler/SlackDailyDigestTaskConfig.java @@ -4,13 +4,29 @@ import com.github.kagkarlsson.scheduler.task.Task; import com.github.kagkarlsson.scheduler.task.helper.Tasks; import com.github.kagkarlsson.scheduler.task.schedule.Schedules; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +/** + * Scheduled task configuration for daily digest delivery. + * + *

Uses the hybrid mode: per-user personalized delivery when UserDigestPreference + * rows exist; legacy channel-broadcast when none exist. + * + *

PR3 Changes

+ *
    + *
  • Now calls {@code sendDailyDigestHybrid()} which routes to per-user or legacy + * based on whether any preferences are configured
  • + *
  • Two users with different personas on the same connection get different digests
  • + *
  • Fallback to legacy broadcast if no per-user preferences exist for a connection
  • + *
+ */ @Configuration @Profile("!test") +@Slf4j public class SlackDailyDigestTaskConfig { @Bean @@ -18,6 +34,10 @@ Task slackDailyDigestTask( SlackDailyDigestService service, @Value("${slack.daily-digest.cron:0 0 9 * * *}") String cron) { return Tasks.recurring("slack-daily-digest", Schedules.cron(cron)) - .execute((inst, ctx) -> service.sendDailyDigest()); + .execute((inst, ctx) -> { + log.info("Starting scheduled daily digest delivery"); + service.sendDailyDigestHybrid(); + log.info("Completed scheduled daily digest delivery"); + }); } } diff --git a/backend/src/test/java/com/dbaagent/service/DigestPreferenceSeedServiceTest.java b/backend/src/test/java/com/dbaagent/service/DigestPreferenceSeedServiceTest.java new file mode 100644 index 0000000..8f5fbb6 --- /dev/null +++ b/backend/src/test/java/com/dbaagent/service/DigestPreferenceSeedServiceTest.java @@ -0,0 +1,294 @@ +package com.dbaagent.service; + +import com.dbaagent.model.DatabaseConnection; +import com.dbaagent.model.DigestDeliveryMethod; +import com.dbaagent.model.PersonaTag; +import com.dbaagent.model.Role; +import com.dbaagent.model.SlackDigestConfig; +import com.dbaagent.model.SlackUserLink; +import com.dbaagent.model.User; +import com.dbaagent.model.UserDigestPreference; +import com.dbaagent.repository.SlackDigestConfigRepository; +import com.dbaagent.repository.UserDigestPreferenceRepository; +import com.dbaagent.repository.UserRepository; +import com.dbaagent.service.security.ConnectionAccessService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests for DigestPreferenceSeedService. + * + *

Key scenarios:

+ *
    + *
  • Seed preferences for Slack-linked users
  • + *
  • Infer persona from role
  • + *
  • Skip existing preferences (idempotent)
  • + *
  • Dry run mode
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +class DigestPreferenceSeedServiceTest { + + @Mock private UserDigestPreferenceRepository preferenceRepository; + @Mock private SlackDigestConfigRepository configRepository; + @Mock private SlackUserLinkService slackUserLinkService; + @Mock private ConnectionAccessService connectionAccessService; + @Mock private UserRepository userRepository; + + private DigestPreferenceSeedService seedService; + + @BeforeEach + void setUp() { + seedService = new DigestPreferenceSeedService( + preferenceRepository, + configRepository, + slackUserLinkService, + connectionAccessService, + userRepository + ); + } + + @Test + void seedPreferences_createsPreferenceForLinkedUser() { + // Given: a linked user with access to a connection + when(slackUserLinkService.getAllLinkedUsernames()).thenReturn(List.of("alice")); + + User alice = new User(); + alice.setUsername("alice"); + alice.setRole("DBA"); + alice.setStatus("ACTIVE"); + when(userRepository.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(alice)); + + DatabaseConnection conn = new DatabaseConnection(); + conn.setId("conn-123"); + when(connectionAccessService.getVisibleConnections("alice", false)) + .thenReturn(List.of(conn)); + + when(preferenceRepository.findByUsernameAndConnectionIdAndDeliveryMethod( + anyString(), anyString(), any())).thenReturn(Optional.empty()); + + when(configRepository.findById(1L)).thenReturn(Optional.of(new SlackDigestConfig())); + + // When: seeding preferences + DigestPreferenceSeedService.SeedResult result = seedService.executeSeed(); + + // Then: one preference is created + assertThat(result.preferencesCreated()).isEqualTo(1); + assertThat(result.usersProcessed()).isEqualTo(1); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDigestPreference.class); + verify(preferenceRepository).save(captor.capture()); + + UserDigestPreference saved = captor.getValue(); + assertThat(saved.getUsername()).isEqualTo("alice"); + assertThat(saved.getConnectionId()).isEqualTo("conn-123"); + assertThat(saved.getDeliveryMethod()).isEqualTo(DigestDeliveryMethod.SLACK_DM); + assertThat(saved.getPersonaTag()).isEqualTo(PersonaTag.DBA); // Inferred from role + } + + @Test + void seedPreferences_infersPersonaFromRole() { + // Role -> Persona mapping: + // DBA -> PersonaTag.DBA + // DATA_ENGINEER -> PersonaTag.DATA_ENG + // DEVELOPER -> PersonaTag.APP_ENG + // ADMIN -> null (let them choose) + + when(slackUserLinkService.getAllLinkedUsernames()) + .thenReturn(List.of("dba", "dataeng", "dev", "admin")); + + setupUser("dba", "DBA"); + setupUser("dataeng", "DATA_ENGINEER"); + setupUser("dev", "DEVELOPER"); + setupUser("admin", "ADMIN"); + + DatabaseConnection conn = new DatabaseConnection(); + conn.setId("conn-123"); + when(connectionAccessService.getVisibleConnections(anyString(), anyBoolean())) + .thenReturn(List.of(conn)); + + when(preferenceRepository.findByUsernameAndConnectionIdAndDeliveryMethod( + anyString(), anyString(), any())).thenReturn(Optional.empty()); + + when(configRepository.findById(1L)).thenReturn(Optional.of(new SlackDigestConfig())); + + // When: seeding + DigestPreferenceSeedService.SeedResult result = seedService.executeSeed(); + + // Then: correct personas are assigned + assertThat(result.preferencesCreated()).isEqualTo(4); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDigestPreference.class); + verify(preferenceRepository, times(4)).save(captor.capture()); + + List saved = captor.getAllValues(); + + assertThat(saved.stream() + .filter(p -> "dba".equals(p.getUsername())) + .findFirst().get().getPersonaTag()).isEqualTo(PersonaTag.DBA); + + assertThat(saved.stream() + .filter(p -> "dataeng".equals(p.getUsername())) + .findFirst().get().getPersonaTag()).isEqualTo(PersonaTag.DATA_ENG); + + assertThat(saved.stream() + .filter(p -> "dev".equals(p.getUsername())) + .findFirst().get().getPersonaTag()).isEqualTo(PersonaTag.APP_ENG); + + assertThat(saved.stream() + .filter(p -> "admin".equals(p.getUsername())) + .findFirst().get().getPersonaTag()).isNull(); + } + + @Test + void seedPreferences_skipsExistingPreference() { + // Given: a user with an existing preference + when(slackUserLinkService.getAllLinkedUsernames()).thenReturn(List.of("alice")); + + User alice = new User(); + alice.setUsername("alice"); + alice.setRole("DBA"); + alice.setStatus("ACTIVE"); + when(userRepository.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(alice)); + + DatabaseConnection conn = new DatabaseConnection(); + conn.setId("conn-123"); + when(connectionAccessService.getVisibleConnections("alice", false)) + .thenReturn(List.of(conn)); + + // Existing preference + UserDigestPreference existing = UserDigestPreference.builder() + .username("alice") + .connectionId("conn-123") + .build(); + when(preferenceRepository.findByUsernameAndConnectionIdAndDeliveryMethod( + "alice", "conn-123", DigestDeliveryMethod.SLACK_DM)) + .thenReturn(Optional.of(existing)); + + // When: seeding + DigestPreferenceSeedService.SeedResult result = seedService.executeSeed(); + + // Then: skipped, no new preference created + assertThat(result.preferencesCreated()).isEqualTo(0); + assertThat(result.skipped()).contains("alice/conn-123: preference exists"); + verify(preferenceRepository, never()).save(any()); + } + + @Test + void seedPreferences_dryRun_doesNotPersist() { + // Given: a linked user + when(slackUserLinkService.getAllLinkedUsernames()).thenReturn(List.of("alice")); + + User alice = new User(); + alice.setUsername("alice"); + alice.setRole("DBA"); + alice.setStatus("ACTIVE"); + when(userRepository.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(alice)); + + DatabaseConnection conn = new DatabaseConnection(); + conn.setId("conn-123"); + when(connectionAccessService.getVisibleConnections("alice", false)) + .thenReturn(List.of(conn)); + + when(preferenceRepository.findByUsernameAndConnectionIdAndDeliveryMethod( + anyString(), anyString(), any())).thenReturn(Optional.empty()); + + when(configRepository.findById(1L)).thenReturn(Optional.of(new SlackDigestConfig())); + + // When: preview (dry run) + DigestPreferenceSeedService.SeedResult result = seedService.previewSeed(); + + // Then: result shows what would be created, but nothing is persisted + assertThat(result.preferencesCreated()).isEqualTo(1); + verify(preferenceRepository, never()).save(any()); + } + + @Test + void seedPreferences_skipsInactiveUser() { + // Given: an inactive linked user + when(slackUserLinkService.getAllLinkedUsernames()).thenReturn(List.of("inactive")); + + User inactive = new User(); + inactive.setUsername("inactive"); + inactive.setStatus("SUSPENDED"); + when(userRepository.findByUsernameIgnoreCase("inactive")).thenReturn(Optional.of(inactive)); + + // When: seeding + DigestPreferenceSeedService.SeedResult result = seedService.executeSeed(); + + // Then: skipped + assertThat(result.preferencesCreated()).isEqualTo(0); + assertThat(result.skipped()).contains("inactive: user inactive"); + } + + @Test + void seedPreferences_skipsUserWithNoConnections() { + // Given: a linked user with no accessible connections + when(slackUserLinkService.getAllLinkedUsernames()).thenReturn(List.of("noconn")); + + User noconn = new User(); + noconn.setUsername("noconn"); + noconn.setStatus("ACTIVE"); + when(userRepository.findByUsernameIgnoreCase("noconn")).thenReturn(Optional.of(noconn)); + + when(connectionAccessService.getVisibleConnections("noconn", false)) + .thenReturn(List.of()); + + // When: seeding + DigestPreferenceSeedService.SeedResult result = seedService.executeSeed(); + + // Then: skipped + assertThat(result.preferencesCreated()).isEqualTo(0); + assertThat(result.skipped()).contains("noconn: no accessible connections"); + } + + @Test + void seedForUser_createsPreferencesForSpecificUser() { + // Given: a specific user with Slack linked + User alice = new User(); + alice.setUsername("alice"); + alice.setRole("DBA"); + alice.setStatus("ACTIVE"); + when(userRepository.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(alice)); + + SlackUserLink link = new SlackUserLink(); + link.setDeepsqlUsername("alice"); + when(slackUserLinkService.getLinkedSlackAccounts("alice")).thenReturn(List.of(link)); + + DatabaseConnection conn1 = new DatabaseConnection(); + conn1.setId("conn-1"); + DatabaseConnection conn2 = new DatabaseConnection(); + conn2.setId("conn-2"); + when(connectionAccessService.getVisibleConnections("alice", false)) + .thenReturn(List.of(conn1, conn2)); + + when(preferenceRepository.findByUsernameAndConnectionIdAndDeliveryMethod( + anyString(), anyString(), any())).thenReturn(Optional.empty()); + + // When: seeding for this user + DigestPreferenceSeedService.SeedResult result = seedService.seedPreferencesForUser("alice", false); + + // Then: preferences created for all connections + assertThat(result.preferencesCreated()).isEqualTo(2); + verify(preferenceRepository, times(2)).save(any()); + } + + private void setupUser(String username, String role) { + User user = new User(); + user.setUsername(username); + user.setRole(role); + user.setStatus("ACTIVE"); + when(userRepository.findByUsernameIgnoreCase(username)).thenReturn(Optional.of(user)); + } +} diff --git a/backend/src/test/java/com/dbaagent/service/PerRecipientDigestDeliveryTest.java b/backend/src/test/java/com/dbaagent/service/PerRecipientDigestDeliveryTest.java new file mode 100644 index 0000000..52ef652 --- /dev/null +++ b/backend/src/test/java/com/dbaagent/service/PerRecipientDigestDeliveryTest.java @@ -0,0 +1,269 @@ +package com.dbaagent.service; + +import com.dbaagent.model.DigestDeliveryMethod; +import com.dbaagent.model.PersonaTag; +import com.dbaagent.model.Role; +import com.dbaagent.model.SlackDigestLog; +import com.dbaagent.model.SlackUserLink; +import com.dbaagent.model.User; +import com.dbaagent.model.UserDigestPreference; +import com.dbaagent.model.digest.DigestAssemblyResult; +import com.dbaagent.model.digest.DigestInsight; +import com.dbaagent.model.digest.InsightCategory; +import com.dbaagent.repository.UserDigestPreferenceRepository; +import com.dbaagent.repository.UserRepository; +import com.dbaagent.repository.SlackDigestLogRepository; +import com.dbaagent.service.digest.DigestInsightAssemblerService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests for per-recipient personalized digest delivery (PR3). + * + *

Key scenarios:

+ *
    + *
  • Two users with different personas get different digests for same connection
  • + *
  • EXEC persona gets tight 3-bullet executive summary
  • + *
  • Legacy fallback when no preferences exist
  • + *
  • Proper logging with recipient details
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +class PerRecipientDigestDeliveryTest { + + @Mock private UserDigestPreferenceRepository preferenceRepository; + @Mock private DigestInsightAssemblerService assemblerService; + @Mock private SlackUserLinkService slackUserLinkService; + @Mock private UserRepository userRepository; + @Mock private SlackDigestLogRepository digestLogRepository; + + @BeforeEach + void setUp() { + lenient().when(preferenceRepository.hasAnyPreferences()).thenReturn(false); + } + + @Test + void twoUsersWithDifferentPersonas_getDifferentDigests() { + // Given: two users with different personas on the same connection + String connectionId = "conn-123"; + + UserDigestPreference dbaPreference = UserDigestPreference.builder() + .id(1L) + .username("alice_dba") + .connectionId(connectionId) + .enabled(true) + .deliveryMethod(DigestDeliveryMethod.SLACK_DM) + .personaTag(PersonaTag.DBA) + .build(); + + UserDigestPreference execPreference = UserDigestPreference.builder() + .id(2L) + .username("bob_exec") + .connectionId(connectionId) + .enabled(true) + .deliveryMethod(DigestDeliveryMethod.SLACK_DM) + .personaTag(PersonaTag.EXEC) + .build(); + + // Mock user lookups + User dbaUser = new User(); + dbaUser.setUsername("alice_dba"); + dbaUser.setRole("DBA"); + + User execUser = new User(); + execUser.setUsername("bob_exec"); + execUser.setRole("ADMIN"); + + when(userRepository.findByUsernameIgnoreCase("alice_dba")).thenReturn(Optional.of(dbaUser)); + when(userRepository.findByUsernameIgnoreCase("bob_exec")).thenReturn(Optional.of(execUser)); + + // Mock assembler to return different results based on persona + DigestAssemblyResult dbaResult = createDbaDigest(); + DigestAssemblyResult execResult = createExecDigest(); + + when(assemblerService.assembleDigest( + eq("alice_dba"), eq(connectionId), eq(Role.DBA), eq(PersonaTag.DBA), any() + )).thenReturn(dbaResult); + + when(assemblerService.assembleDigest( + eq("bob_exec"), eq(connectionId), eq(Role.ADMIN), eq(PersonaTag.EXEC), any() + )).thenReturn(execResult); + + // When: assembling digests for both users + DigestAssemblyResult aliceDigest = assemblerService.assembleDigest( + "alice_dba", connectionId, Role.DBA, PersonaTag.DBA, null); + DigestAssemblyResult bobDigest = assemblerService.assembleDigest( + "bob_exec", connectionId, Role.ADMIN, PersonaTag.EXEC, null); + + // Then: they get different insights based on persona + assertThat(aliceDigest.getPersonaTag()).isEqualTo(PersonaTag.DBA); + assertThat(bobDigest.getPersonaTag()).isEqualTo(PersonaTag.EXEC); + + // DBA gets more insights (full digest) + assertThat(aliceDigest.getInsights()).hasSize(5); + + // EXEC gets fewer, with executive summary + assertThat(bobDigest.getInsights()).hasSize(3); + assertThat(bobDigest.getExecutiveSummary()).isNotNull(); + assertThat(bobDigest.getExecutiveSummary()).hasSizeLessThanOrEqualTo(3); + } + + @Test + void execPersona_getsTightThreeBulletSummary() { + // Given: EXEC persona digest + DigestAssemblyResult execResult = createExecDigest(); + + // Then: executive summary has at most 3 bullets + assertThat(execResult.getExecutiveSummary()).isNotNull(); + assertThat(execResult.getExecutiveSummary()).hasSizeLessThanOrEqualTo(3); + + // And has a decision ask + assertThat(execResult.getDecisionAsk()).isNotNull(); + } + + @Test + void digestLog_containsRecipientDetails() { + // Given: a digest log entry for personalized delivery + SlackDigestLog logEntry = new SlackDigestLog(); + logEntry.setConnectionId("conn-123"); + logEntry.setRecipientUsername("alice_dba"); + logEntry.setRecipientRole("DBA"); + logEntry.setPersonaTag(PersonaTag.DBA); + logEntry.setDeliveryMethod(DigestDeliveryMethod.SLACK_DM); + logEntry.setPersonalized(true); + logEntry.setStatus("SENT"); + + // Then: it has all the per-recipient details + assertThat(logEntry.getRecipientUsername()).isEqualTo("alice_dba"); + assertThat(logEntry.getRecipientRole()).isEqualTo("DBA"); + assertThat(logEntry.getPersonaTag()).isEqualTo(PersonaTag.DBA); + assertThat(logEntry.isPersonalized()).isTrue(); + assertThat(logEntry.isPerUserDelivery()).isTrue(); + } + + @Test + void legacyDigest_hasNoRecipientDetails() { + // Given: a legacy channel-broadcast digest log entry + SlackDigestLog logEntry = new SlackDigestLog(); + logEntry.setConnectionId("conn-123"); + logEntry.setChannelId("C123456"); + logEntry.setPersonalized(false); + logEntry.setStatus("SENT"); + + // Then: it has no recipient-specific details + assertThat(logEntry.getRecipientUsername()).isNull(); + assertThat(logEntry.getRecipientRole()).isNull(); + assertThat(logEntry.getPersonaTag()).isNull(); + assertThat(logEntry.isPersonalized()).isFalse(); + assertThat(logEntry.isPerUserDelivery()).isFalse(); + } + + @Test + void preferenceForConnection_appliesCorrectly() { + // Given: a preference with a specific connection + UserDigestPreference pref = UserDigestPreference.builder() + .username("alice") + .connectionId("conn-123") + .enabled(true) + .build(); + + // Then: it applies to that connection + assertThat(pref.appliesTo("conn-123")).isTrue(); + assertThat(pref.appliesTo("conn-456")).isFalse(); + } + + @Test + void preferenceWithNullConnection_appliesToAll() { + // Given: a preference without a specific connection + UserDigestPreference pref = UserDigestPreference.builder() + .username("alice") + .connectionId(null) + .enabled(true) + .build(); + + // Then: it applies to all connections + assertThat(pref.appliesTo("conn-123")).isTrue(); + assertThat(pref.appliesTo("conn-456")).isTrue(); + } + + @Test + void assemblyResult_emptyDigest_indicatesEmpty() { + // Given: an empty digest result + DigestAssemblyResult empty = DigestAssemblyResult.empty( + "alice", "conn-123", Role.DBA, PersonaTag.DBA); + + // Then: it's marked as empty + assertThat(empty.isEmpty()).isTrue(); + assertThat(empty.getInsights()).isEmpty(); + assertThat(empty.getHeadline()).isEqualTo("No new insights since your last digest"); + } + + private DigestAssemblyResult createDbaDigest() { + List insights = List.of( + createInsight(InsightCategory.LOCK_CONCURRENCY, "Critical lock wait", 92), + createInsight(InsightCategory.QUERY_PERFORMANCE, "Plan regression", 85), + createInsight(InsightCategory.INDEX_RECOMMENDATIONS, "3 index recommendations", 75), + createInsight(InsightCategory.CONFIG_TUNING, "join_collapse_limit adjustment", 65), + createInsight(InsightCategory.GROWTH_ANOMALIES, "Table growth detected", 55) + ); + + return DigestAssemblyResult.builder() + .username("alice_dba") + .connectionId("conn-123") + .role(Role.DBA) + .personaTag(PersonaTag.DBA) + .assembledAt(LocalDateTime.now()) + .insights(insights) + .totalCandidates(10) + .build(); + } + + private DigestAssemblyResult createExecDigest() { + List insights = List.of( + createInsight(InsightCategory.COST_CAPACITY, "Storage cost up 15%", 75), + createInsight(InsightCategory.QUERY_PERFORMANCE, "Top regression: 3x slowdown", 85), + createInsight(InsightCategory.GROWTH_ANOMALIES, "Capacity planning needed", 70) + ); + + List execSummary = List.of( + "⚠️ 1 critical issue requires attention", + "📉 Top regression: 3x slowdown on orders query", + "💰 Storage cost up 15% this month" + ); + + return DigestAssemblyResult.builder() + .username("bob_exec") + .connectionId("conn-123") + .role(Role.ADMIN) + .personaTag(PersonaTag.EXEC) + .assembledAt(LocalDateTime.now()) + .insights(insights) + .executiveSummary(execSummary) + .decisionAsk("Review and approve index recommendation for orders table") + .totalCandidates(10) + .build(); + } + + private DigestInsight createInsight(InsightCategory category, String headline, int severity) { + return DigestInsight.builder() + .category(category) + .headline(headline) + .severity(severity) + .timestamp(LocalDateTime.now()) + .actionable(true) + .signatureKey(category.name() + ":" + headline.hashCode()) + .build(); + } +} diff --git a/docs/DIGEST_PREFERENCES.md b/docs/DIGEST_PREFERENCES.md index 5825c83..e202ac9 100644 --- a/docs/DIGEST_PREFERENCES.md +++ b/docs/DIGEST_PREFERENCES.md @@ -1,159 +1,220 @@ -# Per-User Digest Preferences +# Digest Preferences & Per-Recipient Delivery -This document describes the per-user digest preference system introduced to support role-aware, personalized database digests. +> Same Brain, different lens per role. + +This document describes the per-user digest preference system and personalized Slack delivery introduced in PR3. ## Overview -DeepSQL's daily digest feature now supports **per-user preferences**, allowing each user to: +The digest system now supports **per-recipient personalization**: two users with different personas on the same connection get different Slack digests for the same time window. This is built on top of: -- Choose their preferred **delivery method** (Slack DM, Slack channel, or email) -- Set a **persona tag** for content prioritization (DBA, App Engineer, Data Engineer, Executive) -- Configure a **custom schedule** (cron expression) per subscription -- Subscribe to **specific connections** or all connections they have access to +- **PR1**: Per-user digest preferences (`UserDigestPreference` model) +- **PR2**: Role-aware insight assembler (`DigestInsightAssemblerService`) +- **PR3**: Slack delivery per recipient + UI prefs (this PR) ## Architecture -### Data Model - ``` -┌─────────────────────────┐ ┌────────────────────────┐ -│ SlackDigestConfig │ │ UserDigestPreference │ -│ (singleton, id=1) │ │ (per-user, per-conn) │ -├─────────────────────────┤ ├────────────────────────┤ -│ cronExpression │◄─────│ username │ -│ updatedAt │ │ connectionId (nullable)│ -│ isGlobalDefault = true │ │ enabled │ -└─────────────────────────┘ │ personaTag │ - │ │ cronExpression │ - │ │ deliveryMethod │ - │ fallback when no │ timezone │ - │ preferences exist │ createdAt / updatedAt │ - ▼ └────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────┐ +│ Scheduled Task (cron) │ +│ SlackDailyDigestTaskConfig │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ sendDailyDigestHybrid() │ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────┐ │ +│ │ Per-user preferences │ │ Legacy broadcast │ │ +│ │ exist? │──▶│ (channel bindings) │ │ +│ └──────────────────────┘ └──────────────────────┘ │ +│ │ yes │ │ +│ ▼ ▼ │ +│ ┌──────────────────────┐ ┌──────────────────────┐ │ +│ │ sendPersonalizedDigests │ │ sendLegacyDigest │ │ +│ └──────────────────────┘ └──────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ For each recipient: │ │ +│ │ 1. Resolve user role │ │ +│ │ 2. assembleDigest(username, connectionId, role, persona) │ │ +│ │ 3. formatPersonalizedDigest() [EXEC: 3 bullets] │ │ +│ │ 4. openDmChannel() via Slack API │ │ +│ │ 5. postMessage() to DM channel │ │ +│ │ 6. Log to SlackDigestLog with recipient details │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ ``` -### Resolution Order - -When delivering a digest, the system resolves preferences in this order: +## Persona Tags -1. **User + Connection specific** — `UserDigestPreference` where `connectionId` matches -2. **User-wide** — `UserDigestPreference` where `connectionId IS NULL` -3. **Global fallback** — `SlackDigestConfig` singleton (legacy behavior) +Users can set a persona tag to influence how insights are prioritized in their digest: -## Persona Tags +| Persona | Display Name | Focus Areas | +|---------|--------------|-------------| +| `DBA` | Database Administrator | Lock contention, query performance, config tuning | +| `APP_ENG` | App Engineer | Schema changes (DDL risk), query patterns, locks during deploys | +| `DATA_ENG` | Data Engineer | Documentation gaps, schema changes, growth anomalies | +| `EXEC` | Executive | Cost/capacity, risk summary, 3 bullets max | -Persona tags influence how digest content is prioritized: +### EXEC Payload -| Tag | Display Name | Focus Areas | -|-----|--------------|-------------| -| `DBA` | DBA | Performance tuning, index recommendations, operational health | -| `APP_ENG` | App Engineer | Query patterns, ORM issues, schema usage | -| `DATA_ENG` | Data Engineer | ETL patterns, data quality, pipeline health | -| `EXEC` | Executive | High-level summaries, costs, capacity forecasts | +EXEC persona digests are intentionally tight: +- **Maximum 3 bullets** in the executive summary +- **One decision ask** for the top actionable item +- Example: + ``` + 🧭 EXECUTIVE SUMMARY + • ⚠️ 1 critical issue requires attention + • 📉 Top regression: 3x slowdown on orders query + • 💰 Storage cost up 15% this month -A user's **RBAC role** determines what they can access; the **persona tag** influences how content is ranked and presented. + 📋 ACTION NEEDED + Review and approve index recommendation for orders table + ``` ## Delivery Methods -| Method | Requires | -|--------|----------| -| `SLACK_DM` | User must be linked via SlackUserLink | -| `SLACK_CHANNEL` | Connection must have a SlackChannelBinding | -| `EMAIL` | User's registered email address | +| Method | Status | Description | +|--------|--------|-------------| +| `SLACK_DM` | ✅ Implemented | Direct message to linked Slack account | +| `SLACK_CHANNEL` | ✅ Legacy | Post to configured channel (legacy broadcast) | +| `EMAIL` | ⏳ Future (PR4) | Email delivery (not yet implemented) | + +Note: Do not claim EMAIL or WhatsApp delivery is available. These are planned for PR4. ## API Endpoints -### User Self-Service (`/digest/preferences`) - -| Method | Endpoint | Description | -|--------|----------|-------------| -| `GET` | `/digest/preferences` | List my preferences | -| `POST` | `/digest/preferences` | Create a new preference | -| `PUT` | `/digest/preferences/{id}` | Update a preference | -| `PATCH` | `/digest/preferences/{id}/enabled` | Enable/disable | -| `DELETE` | `/digest/preferences/{id}` | Delete a preference | -| `GET` | `/digest/preferences/persona-tags` | List available persona tags | -| `GET` | `/digest/preferences/delivery-methods` | List available delivery methods | - -### Admin Management (`/admin/slack/digest/preferences`) - -| Method | Endpoint | Description | -|--------|----------|-------------| -| `GET` | `/admin/slack/digest/preferences/{username}` | Get user's preferences | -| `GET` | `/admin/slack/digest/preferences/users` | List users with preferences | -| `GET` | `/admin/slack/digest/preferences/stats` | Get preference statistics | -| `POST` | `/admin/slack/digest/preferences/{username}` | Create preference for user | -| `PUT` | `/admin/slack/digest/preferences/id/{id}` | Update any preference | -| `DELETE` | `/admin/slack/digest/preferences/id/{id}` | Delete any preference | -| `DELETE` | `/admin/slack/digest/preferences/{username}` | Delete all user's preferences | - -## Creating a Preference - -### Request Body - -```json -{ - "connectionId": "uuid-of-connection", // null for all connections - "deliveryMethod": "SLACK_DM", // SLACK_DM, SLACK_CHANNEL, EMAIL - "personaTag": "DBA", // DBA, APP_ENG, DATA_ENG, EXEC - "cronExpression": "0 0 8 * * *", // optional custom schedule - "timezone": "America/New_York" // optional IANA timezone -} +### User Preferences (Any authenticated user) + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/digest/preferences` | GET | Get current user's preferences | +| `/api/digest/preferences` | POST | Create a new preference | +| `/api/digest/preferences/{id}` | PUT | Update a preference | +| `/api/digest/preferences/{id}/enabled` | PATCH | Enable/disable | +| `/api/digest/preferences/{id}` | DELETE | Delete a preference | +| `/api/digest/preferences/persona-tags` | GET | List available personas | +| `/api/digest/preferences/delivery-methods` | GET | List delivery methods | +| `/api/digest/preferences/status` | GET | Get digest mode info | +| `/api/digest/preferences/seed/me` | POST | Seed preferences for current user | + +### Admin Seed (Admin only) + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/digest/preferences/admin/seed/preview` | GET | Preview what would be seeded | +| `/api/digest/preferences/admin/seed` | POST | Execute seed for all linked users | + +## Seeding Preferences + +To migrate from legacy singleton mode to per-user mode: + +1. **Admin Preview**: `GET /api/digest/preferences/admin/seed/preview` + - Shows what preferences would be created + - No changes are made + +2. **Admin Execute**: `POST /api/digest/preferences/admin/seed` + - Creates SLACK_DM preferences for all Slack-linked users + - One preference per connection they can access + - Persona is inferred from their RBAC role + - Idempotent: skips existing preferences + +3. **User Self-Seed**: `POST /api/digest/preferences/seed/me` + - User creates their own preferences + - Useful after linking Slack account + +### Persona Inference from Role + +| RBAC Role | Inferred Persona | +|-----------|------------------| +| `DBA` | `PersonaTag.DBA` | +| `DATA_ENGINEER` | `PersonaTag.DATA_ENG` | +| `DEVELOPER` | `PersonaTag.APP_ENG` | +| `ADMIN` | `null` (let them choose) | + +## UI Integration + +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 + +### Preferences Panel Features + +- View all your digest subscriptions +- Toggle digests on/off per connection +- Change persona without recreating +- Quick schedule presets (8 AM, 9 AM, Noon, etc.) +- Seed for all your connections at once + +## Database Schema + +### user_digest_preference + +```sql +CREATE TABLE user_digest_preference ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + connection_id VARCHAR(36), + enabled BOOLEAN NOT NULL DEFAULT true, + persona_tag VARCHAR(32), + cron_expression VARCHAR(100), + delivery_method VARCHAR(32) NOT NULL DEFAULT 'SLACK_DM', + timezone VARCHAR(64), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT uk_user_digest_pref_user_conn_method + UNIQUE (username, connection_id, delivery_method) +); ``` -### Example: Create a DBA preference for all connections via Slack DM +### slack_digest_log (Extended) -```bash -curl -X POST http://localhost:8080/api/digest/preferences \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" \ - -d '{ - "deliveryMethod": "SLACK_DM", - "personaTag": "DBA" - }' -``` - -### Example: Create an executive preference for a specific connection via email at 8 AM EST +New columns for per-recipient tracking: -```bash -curl -X POST http://localhost:8080/api/digest/preferences \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" \ - -d '{ - "connectionId": "abc-123-def-456", - "deliveryMethod": "EMAIL", - "personaTag": "EXEC", - "cronExpression": "0 0 8 * * *", - "timezone": "America/New_York" - }' +```sql +ALTER TABLE slack_digest_log ADD COLUMN recipient_username VARCHAR(255); +ALTER TABLE slack_digest_log ADD COLUMN recipient_role VARCHAR(64); +ALTER TABLE slack_digest_log ADD COLUMN persona_tag VARCHAR(32); +ALTER TABLE slack_digest_log ADD COLUMN delivery_method VARCHAR(32); +ALTER TABLE slack_digest_log ADD COLUMN preference_id BIGINT; +ALTER TABLE slack_digest_log ADD COLUMN personalized BOOLEAN NOT NULL DEFAULT false; ``` -## Migration from Singleton +## Configuration -The singleton `SlackDigestConfig` (id=1) remains active and serves as the global default: +| Property | Default | Description | +|----------|---------|-------------| +| `slack.daily-digest.cron` | `0 0 9 * * *` | Global digest schedule (9 AM daily) | +| `slack.digest.admins-only` | `true` | Restrict legacy broadcast to admin channels | -- **No preferences exist** → Digest uses legacy channel-broadcast behavior -- **Preferences exist** → Per-user delivery is used; users without preferences still get the legacy broadcast +## Out of Scope (Future PRs) -This ensures backward compatibility: existing deployments continue working without any configuration changes. +1. **Idle-in-transaction insight** (state/duration) when Brain has data +2. **join_collapse_limit cliffs** under CONFIG_TUNING when plan/config stores can detect +3. **DOCUMENTATION_GAPS miner** (COMMENT ON / Schema Guardian) +4. **COST_CAPACITY dedicated cost miner** for EXEC 💰 bullet +5. **Hermes/WhatsApp delivery** (PR4) -## Audit Trail +## Testing -The `SlackDigestLog` table now tracks per-recipient delivery: +Run the tests: -| Field | Description | -|-------|-------------| -| `recipient_username` | Username of the recipient (null for legacy broadcast) | -| `recipient_role` | RBAC role at delivery time | -| `persona_tag` | Persona used for content prioritization | -| `delivery_method` | How the digest was delivered | -| `preference_id` | FK to the preference that triggered delivery | -| `personalized` | Whether content was role-personalized | +```bash +cd backend +mvn test -Dtest=PerRecipientDigestDeliveryTest +mvn test -Dtest=DigestPreferenceSeedServiceTest +``` -## Future Work (PR2) +Key test scenarios: +- Two users with different personas get different digests +- EXEC gets tight 3-bullet summary +- Seed skips existing preferences (idempotent) +- Dry run mode for preview +- Legacy fallback when no preferences exist -This PR (PR1) establishes the **data model and migration only**. The following features are planned for PR2: +## GTM Line -- **Role-aware content ranking** — Different insight prioritization per persona -- **Per-user delivery execution** — Actually sending personalized digests -- **Scheduled task per-user** — Respecting individual cron expressions -- **Email delivery implementation** — Currently only Slack is implemented +> Same Brain, different lens per role diff --git a/src/components/sections/DigestPreferencesPanel.jsx b/src/components/sections/DigestPreferencesPanel.jsx new file mode 100644 index 0000000..d2d544a --- /dev/null +++ b/src/components/sections/DigestPreferencesPanel.jsx @@ -0,0 +1,458 @@ +import { useState, useEffect, useCallback } from 'react' +import { + X, + User, + Bell, + Clock, + Check, + AlertCircle, + RefreshCw, + Sparkles, + Trash2, + Plus, + Settings2, +} from 'lucide-react' +import { digestPreferencesAPI } from '@/lib/api/client' +import { useConnectionManager } from '@/lib/hooks/useConnectionManager' +import styles from './DigestPreferencesPanel.module.css' + +const CRON_PRESETS = [ + { label: '8 AM daily', value: '0 0 8 * * *' }, + { 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 }, +] + +export default function DigestPreferencesPanel({ onClose }) { + const { connectionId, selectedConnection } = useConnectionManager() + const [preferences, setPreferences] = useState([]) + const [personaTags, setPersonaTags] = useState([]) + const [deliveryMethods, setDeliveryMethods] = useState([]) + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [successMsg, setSuccessMsg] = useState(null) + const [showCreate, setShowCreate] = useState(false) + + // Create form state + const [newPersona, setNewPersona] = useState('') + const [newCron, setNewCron] = useState('') + const [newCronPreset, setNewCronPreset] = useState('Use global') + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [prefs, tags, methods, statusRes] = await Promise.all([ + digestPreferencesAPI.getMyPreferences(), + digestPreferencesAPI.getPersonaTags(), + digestPreferencesAPI.getDeliveryMethods(), + digestPreferencesAPI.getStatus(), + ]) + setPreferences(prefs || []) + setPersonaTags(tags || []) + setDeliveryMethods(methods || []) + setStatus(statusRes) + } catch (err) { + setError(err?.response?.data?.message || 'Failed to load preferences') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + const handleToggleEnabled = async (pref) => { + try { + const updated = await digestPreferencesAPI.setEnabled(pref.id, !pref.enabled) + setPreferences((prev) => + prev.map((p) => (p.id === pref.id ? updated : p)) + ) + showSuccess(pref.enabled ? 'Digest paused' : 'Digest enabled') + } catch { + setError('Failed to update preference') + } + } + + const handleUpdatePersona = async (pref, personaTag) => { + setSaving(true) + try { + const updated = await digestPreferencesAPI.updatePreference(pref.id, { + personaTag: personaTag || null, + }) + setPreferences((prev) => + prev.map((p) => (p.id === pref.id ? updated : p)) + ) + showSuccess('Persona updated') + } catch { + setError('Failed to update persona') + } finally { + setSaving(false) + } + } + + const handleDelete = async (pref) => { + if (!window.confirm('Delete this digest preference? You can recreate it later.')) { + return + } + try { + await digestPreferencesAPI.deletePreference(pref.id) + setPreferences((prev) => prev.filter((p) => p.id !== pref.id)) + showSuccess('Preference deleted') + } catch { + setError('Failed to delete preference') + } + } + + const handleCreate = async () => { + if (!connectionId) { + setError('Please select a connection first') + return + } + setSaving(true) + setError(null) + try { + const created = await digestPreferencesAPI.createPreference({ + connectionId, + deliveryMethod: 'SLACK_DM', + personaTag: newPersona || null, + cronExpression: newCron || null, + }) + setPreferences((prev) => [...prev, created]) + setShowCreate(false) + setNewPersona('') + setNewCron('') + setNewCronPreset('Use global') + showSuccess('Digest preference created') + } catch (err) { + setError(err?.response?.data?.message || 'Failed to create preference') + } finally { + setSaving(false) + } + } + + const handleSeedForMe = async () => { + setSaving(true) + setError(null) + try { + const result = await digestPreferencesAPI.seedForMe() + if (result.preferencesCreated > 0) { + showSuccess(`Created ${result.preferencesCreated} digest preference(s)`) + await load() + } else { + showSuccess('No new preferences needed') + } + } catch (err) { + setError(err?.response?.data?.message || 'Failed to seed preferences') + } finally { + setSaving(false) + } + } + + const handleCronPreset = (preset) => { + setNewCronPreset(preset.label) + setNewCron(preset.value || '') + } + + const showSuccess = (msg) => { + setSuccessMsg(msg) + setTimeout(() => setSuccessMsg(null), 2500) + } + + const currentConnectionPref = preferences.find( + (p) => p.connectionId === connectionId + ) + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+ + Digest Preferences +
+ +
+ + {/* Body */} +
+ {/* Status banner */} + {status && ( +
+ + + {status.perUserMode + ? `Personalized mode: ${status.enabledPreferences} preference(s) for ${status.distinctUsers} user(s)` + : 'Legacy mode: global digest only'} + +
+ )} + + {loading && ( +
+ + Loading preferences... +
+ )} + + {error && ( +
+ + {error} + +
+ )} + + {successMsg && ( +
+ + {successMsg} +
+ )} + + {!loading && preferences.length === 0 && ( +
+ +

No digest preferences yet

+

+ Set up personalized digests to receive database insights via Slack + DM, tailored to your role. +

+
+ + +
+
+ )} + + {/* Preference list */} + {!loading && preferences.length > 0 && ( +
+
+ Your digest subscriptions + +
+ + {preferences.map((pref) => ( + handleToggleEnabled(pref)} + onUpdatePersona={(tag) => handleUpdatePersona(pref, tag)} + onDelete={() => handleDelete(pref)} + /> + ))} +
+ )} + + {/* Create form */} + {showCreate && ( +
+

New digest preference

+

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

+ + + + + +
+ {CRON_PRESETS.map((p) => ( + + ))} +
+ + {newCronPreset !== 'Use global' && ( + <> + { + setNewCron(e.target.value) + setNewCronPreset('Custom') + }} + placeholder="0 0 9 * * *" + spellCheck={false} + /> +

+ Format: seconds minutes hours day month weekday +

+ + )} + +
+ + +
+
+ )} +
+
+
+ ) +} + +function PreferenceCard({ + pref, + personaTags, + selectedConnection, + connectionId, + onToggle, + onUpdatePersona, + 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' + + return ( +
+
+
+ {connName} + {isCurrentConnection && ( + Current + )} + +
+ +
+ + + {personaLabel} + + + + {deliveryLabel} + + {pref.cronExpression && ( + + + Custom schedule + + )} +
+
+ +
+ + + + + +
+
+ ) +} diff --git a/src/components/sections/DigestPreferencesPanel.module.css b/src/components/sections/DigestPreferencesPanel.module.css new file mode 100644 index 0000000..fe1a836 --- /dev/null +++ b/src/components/sections/DigestPreferencesPanel.module.css @@ -0,0 +1,558 @@ +/* Panel overlay */ +.panelOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Panel container */ +.panel { + background: #fff; + border-radius: 12px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2); + width: 90%; + max-width: 520px; + max-height: 85vh; + display: flex; + flex-direction: column; + animation: slideUp 0.2s ease; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Header */ +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #e5e7eb; +} + +.headerTitle { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 600; + color: #111827; +} + +.iconBtn { + background: none; + border: none; + cursor: pointer; + color: #6b7280; + display: flex; + align-items: center; + padding: 4px; + border-radius: 4px; + transition: background 0.12s; +} + +.iconBtn:hover { + background: #f3f4f6; + color: #111827; +} + +/* Body */ +.panelBody { + padding: 16px 20px 20px; + overflow-y: auto; + flex: 1; + min-height: 0; +} + +/* Status banner */ +.statusBanner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 8px; + font-size: 12.5px; + color: #166534; + margin-bottom: 16px; +} + +/* Loading state */ +.loadingState { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 40px; + color: #6b7280; + font-size: 13px; +} + +.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Banners */ +.errorBanner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + font-size: 12.5px; + color: #b91c1c; + margin-bottom: 12px; +} + +.errorBanner button { + margin-left: auto; + background: none; + border: none; + color: #b91c1c; + cursor: pointer; + font-size: 16px; + line-height: 1; +} + +.successBanner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 8px; + font-size: 12.5px; + color: #166534; + margin-bottom: 12px; +} + +/* Empty state */ +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 32px 20px; + text-align: center; +} + +.emptyState h4 { + font-size: 15px; + font-weight: 600; + color: #374151; + margin: 0; +} + +.emptyState p { + font-size: 13px; + color: #6b7280; + max-width: 300px; + line-height: 1.5; +} + +.emptyActions { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 8px; +} + +.seedBtn, +.createBtn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.12s; +} + +.seedBtn { + background: #111827; + color: #fff; + border: none; +} + +.seedBtn:hover:not(:disabled) { + background: #1f2937; +} + +.createBtn { + background: #fff; + color: #374151; + border: 1px solid #d1d5db; +} + +.createBtn:hover:not(:disabled) { + background: #f9fafb; + border-color: #9ca3af; +} + +.seedBtn:disabled, +.createBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Preference list */ +.prefList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.prefListHeader { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + font-weight: 600; + color: #374151; + margin-bottom: 4px; +} + +.addBtn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 6px; + border: 1px solid #e5e7eb; + background: #fff; + color: #6b7280; + cursor: pointer; + transition: all 0.12s; +} + +.addBtn:hover:not(:disabled) { + background: #f3f4f6; + color: #374151; + border-color: #d1d5db; +} + +.addBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Preference card */ +.prefCard { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.prefCard:hover { + border-color: #d1d5db; +} + +.prefCardCurrent { + border-color: #a5b4fc; + background: #faf5ff; +} + +.prefCardDisabled { + opacity: 0.6; + background: #f9fafb; +} + +.prefCardMain { + flex: 1; + min-width: 0; +} + +.prefCardHeader { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.prefConnName { + font-size: 13.5px; + font-weight: 600; + color: #111827; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.currentBadge { + font-size: 10px; + font-weight: 600; + color: #6d28d9; + background: #ede9fe; + padding: 2px 6px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.statusDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #d1d5db; + margin-left: auto; +} + +.statusDotActive { + background: #22c55e; +} + +.prefCardMeta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.metaItem { + display: flex; + align-items: center; + gap: 4px; + font-size: 11.5px; + color: #6b7280; +} + +.prefCardActions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.personaSelect { + font-size: 11.5px; + padding: 4px 6px; + border: 1px solid #e5e7eb; + border-radius: 5px; + background: #fff; + color: #374151; + cursor: pointer; + max-width: 90px; +} + +.personaSelect:hover { + border-color: #d1d5db; +} + +.toggleBtn { + padding: 4px 10px; + border-radius: 5px; + font-size: 11px; + font-weight: 600; + border: 1px solid #e5e7eb; + background: #f3f4f6; + color: #6b7280; + cursor: pointer; + transition: all 0.12s; +} + +.toggleBtnOn { + background: #dcfce7; + color: #166534; + border-color: #bbf7d0; +} + +.toggleBtn:hover { + opacity: 0.85; +} + +.deleteBtn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + border: none; + background: transparent; + color: #9ca3af; + cursor: pointer; + transition: all 0.12s; +} + +.deleteBtn:hover { + background: #fef2f2; + color: #dc2626; +} + +/* Create form */ +.createForm { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #e5e7eb; +} + +.createForm h4 { + font-size: 14px; + font-weight: 600; + color: #111827; + margin: 0 0 8px; +} + +.createHint { + font-size: 12.5px; + color: #6b7280; + margin: 0 0 14px; +} + +.fieldLabel { + display: block; + font-size: 12px; + font-weight: 600; + color: #374151; + margin-bottom: 6px; + margin-top: 12px; +} + +.select { + width: 100%; + padding: 8px 10px; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 13px; + color: #374151; + background: #fff; + cursor: pointer; +} + +.select:hover { + border-color: #d1d5db; +} + +.cronPresets { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.presetBtn { + padding: 6px 10px; + border: 1px solid #e5e7eb; + border-radius: 6px; + background: #fff; + color: #6b7280; + font-size: 12px; + cursor: pointer; + transition: all 0.12s; +} + +.presetBtn:hover { + background: #f9fafb; + border-color: #d1d5db; +} + +.presetBtnActive { + background: #111827; + color: #fff; + border-color: #111827; +} + +.cronInput { + width: 100%; + padding: 8px 10px; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 13px; + font-family: 'SF Mono', 'Monaco', monospace; + color: #374151; + margin-top: 8px; +} + +.cronInput:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.cronHint { + font-size: 11.5px; + color: #9ca3af; + margin: 6px 0 0; +} + +.formActions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 16px; +} + +.cancelBtn { + padding: 8px 14px; + border: 1px solid #e5e7eb; + border-radius: 6px; + background: #fff; + color: #6b7280; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.12s; +} + +.cancelBtn:hover { + background: #f9fafb; + border-color: #d1d5db; +} + +.saveBtn { + padding: 8px 16px; + border: none; + border-radius: 6px; + background: #111827; + color: #fff; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.12s; +} + +.saveBtn:hover:not(:disabled) { + background: #1f2937; +} + +.saveBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/components/sections/DigestSection.jsx b/src/components/sections/DigestSection.jsx index 6b698de..52f4c3e 100644 --- a/src/components/sections/DigestSection.jsx +++ b/src/components/sections/DigestSection.jsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback } from 'react' -import { Newspaper, RefreshCw, Settings, X, Check, Clock, AlertCircle, Zap } from 'lucide-react' +import { Newspaper, RefreshCw, Settings, X, Check, Clock, AlertCircle, Zap, Bell } from 'lucide-react' import { slackDigestAPI } from '@/lib/api/client' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' +import DigestPreferencesPanel from './DigestPreferencesPanel' import styles from './DigestSection.module.css' // ───────────────────────────────────────────── @@ -267,6 +268,7 @@ export default function DigestFeedSection() { 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) const load = useCallback(async () => { @@ -352,6 +354,13 @@ export default function DigestFeedSection() { {triggering ? 'Sending…' : 'Run Now'} +