diff --git a/backend/src/main/java/com/dbaagent/controller/DigestPreferenceController.java b/backend/src/main/java/com/dbaagent/controller/DigestPreferenceController.java index 283e933..837f35d 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. @@ -44,9 +49,23 @@ public ResponseEntity> getMyPreferences() { public ResponseEntity 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) @@ -159,10 +178,10 @@ public ResponseEntity>> getPersonaTags() { */ @GetMapping("/delivery-methods") public ResponseEntity>> getDeliveryMethods() { + // Only advertise methods this PR actually delivers. EMAIL/WhatsApp are PR4. List> 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); } @@ -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 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) {} + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleBadRequest(IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage() != null ? e.getMessage() : "Bad request")); + } } diff --git a/backend/src/main/java/com/dbaagent/model/UserDigestPreference.java b/backend/src/main/java/com/dbaagent/model/UserDigestPreference.java index 11d255a..acb9aee 100644 --- a/backend/src/main/java/com/dbaagent/model/UserDigestPreference.java +++ b/backend/src/main/java/com/dbaagent/model/UserDigestPreference.java @@ -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) diff --git a/backend/src/main/java/com/dbaagent/repository/SlackDigestLogRepository.java b/backend/src/main/java/com/dbaagent/repository/SlackDigestLogRepository.java index 5ed6051..b3a50b5 100644 --- a/backend/src/main/java/com/dbaagent/repository/SlackDigestLogRepository.java +++ b/backend/src/main/java/com/dbaagent/repository/SlackDigestLogRepository.java @@ -91,4 +91,29 @@ 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); + + /** + * Idempotency for per-preference scheduling: true if this preference already + * produced a digest log for the connection at or after the cron fire time. + */ + boolean existsByPreferenceIdAndConnectionIdAndSentAtGreaterThanEqual( + Long preferenceId, + String connectionId, + LocalDateTime sentAt + ); + + /** + * Fallback idempotency when preferenceId is missing on older rows. + */ + boolean existsByConnectionIdAndRecipientUsernameAndSentAtGreaterThanEqual( + String connectionId, + String recipientUsername, + LocalDateTime sentAt + ); } 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..170b945 --- /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.isActiveAccount()) { + 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.isActiveAccount()) { + 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..4945acb 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,14 @@ import com.dbaagent.repository.SlackChannelBindingRepository; import com.dbaagent.repository.SlackDigestLogRepository; import com.dbaagent.repository.TableStatsHistoryRepository; +import com.dbaagent.service.digest.DigestCronMatcher; +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; @@ -49,6 +61,9 @@ import java.io.IOException; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.Instant; +import java.time.Duration; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; @@ -78,6 +93,14 @@ public class SlackDailyDigestService { @Value("${slack.digest.admins-only:true}") private boolean digestAdminsOnly; + /** + * Global / legacy digest schedule. Also the default when a preference leaves + * {@code cronExpression} blank. Interpreted in UTC for legacy; per-user prefs + * use their own timezone. + */ + @Value("${slack.daily-digest.cron:0 0 9 * * *}") + private String globalDigestCron; + private final SlackRuntimeSettingsService slackRuntimeSettingsService; private final SlackChannelBindingRepository channelBindingRepository; private final CredentialService credentialService; @@ -106,6 +129,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 +478,600 @@ 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 built-in role. Custom roles have no Role enum — rank as + // DEVELOPER rather than NPE on role.name(). Persist the stored role code + // on the log so the audit trail still names ANALYST, not a silent fallback. + User recipient = userRepository.findByUsernameIgnoreCase(username).orElse(null); + Role role = recipient != null && recipient.getRoleEnum() != null + ? recipient.getRoleEnum() + : Role.DEVELOPER; + String roleCode = recipient != null ? recipient.getRoleCode() : Role.DEVELOPER.name(); + + // 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(roleCode); + 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)); + } + + private static final Duration DIGEST_TICK_LOOKBACK = Duration.ofMinutes(1); + + /** + * Minute-tick entry point used by {@code SlackDailyDigestTaskConfig}. + * + *

When no enabled preferences exist, runs the legacy channel broadcast + * only if the global {@code slack.daily-digest.cron} is due (UTC). + * When preferences exist, delivers only to preferences whose cron matches + * in that user's timezone and that have not already been logged for this + * fire window. + */ + public void processDigestTick() { + processDigestTick(Instant.now()); + } + + /** + * Testable overload of {@link #processDigestTick()}. + */ + public void processDigestTick(Instant now) { + if (now == null) { + now = Instant.now(); + } + + long enabledCount = userDigestPreferenceRepository.countByEnabledTrue(); + if (enabledCount == 0) { + if (DigestCronMatcher.isDue(globalDigestCron, ZoneId.of("UTC"), now, DIGEST_TICK_LOOKBACK)) { + log.info("Digest tick: no enabled preferences; running legacy broadcast (global cron due)"); + runLegacyBroadcastForAllConnections(); + } else { + log.debug("Digest tick: no enabled preferences; global cron not due"); + } + return; + } + + String globalCron = (globalDigestCron == null || globalDigestCron.isBlank()) + ? "0 0 9 * * *" + : globalDigestCron; + + List enabledPrefs = userDigestPreferenceRepository + .findByEnabledTrueAndDeliveryMethod(DigestDeliveryMethod.SLACK_DM); + + int dueCount = 0; + int delivered = 0; + for (UserDigestPreference pref : enabledPrefs) { + String cron = pref.getEffectiveCronExpression(globalCron); + ZoneId zone = DigestCronMatcher.resolveZone(pref.getTimezone()); + var window = DigestCronMatcher.dueWindowStart(cron, zone, now, DIGEST_TICK_LOOKBACK); + if (window.isEmpty()) { + continue; + } + dueCount++; + Instant fireInstant = window.get(); + List connectionIds = resolvePreferenceConnections(pref); + for (String connectionId : connectionIds) { + if (alreadyDeliveredForWindow(pref, connectionId, fireInstant)) { + log.debug("Skipping digest for {} / {} — already delivered this window", + pref.getUsername(), connectionId); + continue; + } + try { + boolean slackEnabled = isSlackDeliveryEnabled(); + String connName = connectionName(connectionId); + LocalDateTime since = getLastDigestTime(connectionId); + PersonalizedDigestResult result = sendPersonalizedDigestToUser( + connectionId, connName, pref, since, slackEnabled); + if (result.generated() || result.sent()) { + delivered++; + } + } catch (Exception e) { + log.error("Failed due-preference digest for user {} connection {}: {}", + pref.getUsername(), connectionId, e.getMessage(), e); + } + } + } + + log.info("Digest tick (per-user): {} due preference(s), {} delivery attempt(s)", + dueCount, delivered); + + // Connections with no recipients still get legacy broadcast when the global cron fires. + if (DigestCronMatcher.isDue(globalCron, ZoneId.of("UTC"), now, DIGEST_TICK_LOOKBACK)) { + for (String connectionId : digestConnectionIds()) { + if (userDigestPreferenceRepository.findEnabledForConnection(connectionId).isEmpty()) { + try { + sendLegacyDigest(connectionId); + } catch (Exception e) { + log.error("Legacy fallback digest failed for {}: {}", connectionId, e.getMessage(), e); + } + } + } + } + } + + private void runLegacyBroadcastForAllConnections() { + List connectionIds = digestConnectionIds(); + if (connectionIds.isEmpty()) { + log.info("No connections available — skipping legacy digest"); + return; + } + for (String connectionId : connectionIds) { + try { + sendLegacyDigest(connectionId); + } catch (Exception e) { + log.error("Failed legacy digest for connection {}: {}", connectionId, e.getMessage(), e); + } + } + } + + private List resolvePreferenceConnections(UserDigestPreference pref) { + if (pref.getConnectionId() != null && !pref.getConnectionId().isBlank()) { + return List.of(pref.getConnectionId()); + } + return digestConnectionIds(); + } + + /** + * True when SlackDigestLog already has a row for this preference/connection + * at or after the cron fire time (idempotent minute-tick). + */ + private boolean alreadyDeliveredForWindow( + UserDigestPreference pref, + String connectionId, + Instant fireInstant) { + // sentAt is LocalDateTime.now() (JVM default zone) — compare in that zone. + LocalDateTime since = LocalDateTime.ofInstant(fireInstant, ZoneId.systemDefault()); + if (pref.getId() != null) { + if (digestLogRepository.existsByPreferenceIdAndConnectionIdAndSentAtGreaterThanEqual( + pref.getId(), connectionId, since)) { + return true; + } + } + if (pref.getUsername() != null && !pref.getUsername().isBlank()) { + return digestLogRepository.existsByConnectionIdAndRecipientUsernameAndSentAtGreaterThanEqual( + connectionId, pref.getUsername(), since); + } + return false; + } + + /** + * Entry point for the hybrid digest run: per-user when preferences exist, + * legacy broadcast otherwise. + * + *

Used by manual/admin triggers. The scheduled tick uses + * {@link #processDigestTick()} so per-user crons are honored. + */ + 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/digest/DigestCronMatcher.java b/backend/src/main/java/com/dbaagent/service/digest/DigestCronMatcher.java new file mode 100644 index 0000000..672fd3b --- /dev/null +++ b/backend/src/main/java/com/dbaagent/service/digest/DigestCronMatcher.java @@ -0,0 +1,84 @@ +package com.dbaagent.service.digest; + +import org.springframework.scheduling.support.CronExpression; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Optional; + +/** + * Helpers for deciding whether a Spring 6-field cron expression is due + * within a recent lookback window, interpreted in a given timezone. + * + *

Used by the digest minute-tick scheduler so each + * {@code UserDigestPreference.cronExpression} fires in that user's timezone + * without registering a separate db-scheduler task per preference. + */ +public final class DigestCronMatcher { + + private DigestCronMatcher() {} + + /** + * Resolve an IANA timezone id; blank/invalid values fall back to UTC. + */ + public static ZoneId resolveZone(String timezone) { + if (timezone == null || timezone.isBlank()) { + return ZoneId.of("UTC"); + } + try { + return ZoneId.of(timezone.trim()); + } catch (Exception e) { + return ZoneId.of("UTC"); + } + } + + /** + * Returns the fire instant for the cron if it falls in {@code (now - lookback, now]}, + * interpreted in {@code zone}. Empty when not due or the cron is invalid. + * + * @param cronExpression Spring 6-field cron (sec min hour dom month dow) + * @param zone timezone used to evaluate the cron + * @param now current instant (typically clock.instant()) + * @param lookback how far back to search for a matching fire (e.g. 1 minute for a minute tick) + */ + public static Optional dueWindowStart( + String cronExpression, + ZoneId zone, + Instant now, + Duration lookback) { + if (cronExpression == null || cronExpression.isBlank() || zone == null || now == null) { + return Optional.empty(); + } + if (lookback == null || lookback.isNegative() || lookback.isZero()) { + lookback = Duration.ofMinutes(1); + } + + final CronExpression cron; + try { + cron = CronExpression.parse(cronExpression.trim()); + } catch (IllegalArgumentException ex) { + return Optional.empty(); + } + + ZonedDateTime zonedNow = now.atZone(zone); + ZonedDateTime from = zonedNow.minus(lookback); + ZonedDateTime next = cron.next(from); + if (next != null && !next.isAfter(zonedNow)) { + return Optional.of(next.toInstant()); + } + return Optional.empty(); + } + + /** + * Convenience: true when {@link #dueWindowStart} is present. + */ + public static boolean isDue( + String cronExpression, + ZoneId zone, + Instant now, + Duration lookback) { + return dueWindowStart(cronExpression, zone, now, lookback).isPresent(); + } +} 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..cd4df5c 100644 --- a/backend/src/main/java/com/dbaagent/service/scheduler/SlackDailyDigestTaskConfig.java +++ b/backend/src/main/java/com/dbaagent/service/scheduler/SlackDailyDigestTaskConfig.java @@ -4,20 +4,41 @@ 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 digest delivery. + * + *

Ticks frequently (default: every minute) and delegates to + * {@link SlackDailyDigestService#processDigestTick()} which: + *

    + *
  • Honors each enabled {@code UserDigestPreference.cronExpression} in that + * user's timezone when preferences exist
  • + *
  • Falls back to the legacy singleton broadcast gated by + * {@code slack.daily-digest.cron} when no enabled preferences exist
  • + *
+ * + *

The global property {@code slack.daily-digest.cron} remains the default + * schedule for preferences that leave {@code cronExpression} blank, and the + * legacy broadcast schedule when the system is still in singleton mode. + */ @Configuration @Profile("!test") +@Slf4j public class SlackDailyDigestTaskConfig { @Bean 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()); + @Value("${slack.daily-digest.tick-cron:0 * * * * *}") String tickCron) { + return Tasks.recurring("slack-daily-digest", Schedules.cron(tickCron)) + .execute((inst, ctx) -> { + log.debug("Digest scheduler tick"); + service.processDigestTick(); + }); } } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index befdb2a..23c5996 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -321,6 +321,9 @@ slack.bot-token=${SLACK_BOT_TOKEN:} slack.signing-secret=${SLACK_SIGNING_SECRET:} slack.deepsql-bot-username=${SLACK_DEEPSQL_BOT_USERNAME:} slack.daily-digest.cron=${SLACK_DAILY_DIGEST_CRON:0 0 9 * * *} +# Minute tick that evaluates per-user UserDigestPreference.cronExpression (+ timezone). +# Legacy/global cron above gates singleton broadcast when no enabled prefs exist. +slack.daily-digest.tick-cron=${SLACK_DAILY_DIGEST_TICK_CRON:0 * * * * *} # Brain understanding defaults brain.profile.max-columns-per-table=40 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..bf4437c --- /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.setAccountStatus("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.setAccountStatus("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.setAccountStatus("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.setAccountStatus("DISABLED"); + 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.setAccountStatus("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.setAccountStatus("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.setAccountStatus("ACTIVE"); + when(userRepository.findByUsernameIgnoreCase(username)).thenReturn(Optional.of(user)); + } +} diff --git a/backend/src/test/java/com/dbaagent/service/PerRecipientDigestCronSchedulingTest.java b/backend/src/test/java/com/dbaagent/service/PerRecipientDigestCronSchedulingTest.java new file mode 100644 index 0000000..e5ef3f1 --- /dev/null +++ b/backend/src/test/java/com/dbaagent/service/PerRecipientDigestCronSchedulingTest.java @@ -0,0 +1,267 @@ +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.User; +import com.dbaagent.model.UserDigestPreference; +import com.dbaagent.model.digest.DigestAssemblyResult; +import com.dbaagent.repository.AuthLoginChallengeRepository; +import com.dbaagent.repository.CapacityForecastRepository; +import com.dbaagent.repository.ConnectionAccessGrantRepository; +import com.dbaagent.repository.DatabaseEventRepository; +import com.dbaagent.repository.GrowthAnomalyRepository; +import com.dbaagent.repository.LockContentionRepository; +import com.dbaagent.repository.QueryFingerprintRepository; +import com.dbaagent.repository.SchemaChangeRepository; +import com.dbaagent.repository.SlackChannelBindingRepository; +import com.dbaagent.repository.SlackDigestLogRepository; +import com.dbaagent.repository.TableStatsHistoryRepository; +import com.dbaagent.repository.UserDigestPreferenceRepository; +import com.dbaagent.repository.UserRepository; +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 org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Scheduling honesty: the minute tick must honor per-user cron expressions + * (and timezones) rather than blasting every preference on the global cron. + */ +@ExtendWith(MockitoExtension.class) +class PerRecipientDigestCronSchedulingTest { + + @Mock private SlackRuntimeSettingsService slackRuntimeSettingsService; + @Mock private SlackChannelBindingRepository channelBindingRepository; + @Mock private CredentialService credentialService; + @Mock private ConnectionService connectionService; + @Mock private PerformanceInsightsService performanceInsightsService; + @Mock private SlowQueryService slowQueryService; + @Mock private SlowQueryHistoryService slowQueryHistoryService; + @Mock private SlowQueryInsightsService slowQueryInsightsService; + @Mock private SlowQueryAnalyticsService slowQueryAnalyticsService; + @Mock private PerformanceActionAggregatorService actionAggregatorService; + @Mock private EnhancedSqlParserService sqlParserService; + @Mock private QueryExecutorService queryExecutorService; + @Mock private TableGrowthMonitoringService tableGrowthMonitoringService; + @Mock private SchemaChangeTrackingService schemaChangeTrackingService; + @Mock private TableStatsHistoryRepository tableStatsHistoryRepository; + @Mock private GrowthAnomalyRepository growthAnomalyRepository; + @Mock private CapacityForecastRepository capacityForecastRepository; + @Mock private SchemaChangeRepository schemaChangeRepository; + @Mock private SlackDigestLogRepository digestLogRepository; + @Mock private SlackUserLinkService slackUserLinkService; + @Mock private LockContentionRepository lockContentionRepository; + @Mock private QueryFingerprintRepository queryFingerprintRepository; + @Mock private DatabaseEventRepository databaseEventRepository; + @Mock private ConnectionAccessGrantRepository connectionAccessGrantRepository; + @Mock private AuthLoginChallengeRepository authLoginChallengeRepository; + @Mock private IndexAdvisorService indexAdvisorService; + @Mock private IndexRecommendationService indexRecommendationService; + @Mock private UserDigestPreferenceRepository preferenceRepository; + @Mock private DigestInsightAssemblerService assemblerService; + @Mock private UserRepository userRepository; + + private SlackDailyDigestService service; + + @BeforeEach + void setUp() { + service = new SlackDailyDigestService( + slackRuntimeSettingsService, + channelBindingRepository, + credentialService, + connectionService, + performanceInsightsService, + slowQueryService, + slowQueryHistoryService, + slowQueryInsightsService, + slowQueryAnalyticsService, + actionAggregatorService, + sqlParserService, + queryExecutorService, + tableGrowthMonitoringService, + schemaChangeTrackingService, + tableStatsHistoryRepository, + growthAnomalyRepository, + capacityForecastRepository, + schemaChangeRepository, + digestLogRepository, + slackUserLinkService, + lockContentionRepository, + queryFingerprintRepository, + databaseEventRepository, + connectionAccessGrantRepository, + authLoginChallengeRepository, + indexAdvisorService, + indexRecommendationService, + preferenceRepository, + assemblerService, + userRepository + ); + + ReflectionTestUtils.setField(service, "globalDigestCron", "0 0 9 * * *"); + ReflectionTestUtils.setField(service, "digestAdminsOnly", true); + + lenient().when(slackRuntimeSettingsService.current()).thenReturn( + new SlackRuntimeSettingsService.SlackRuntimeConfig(false, false, null, null, null, null)); + lenient().when(digestLogRepository.findTopByConnectionIdOrderBySentAtDesc(anyString())) + .thenReturn(Optional.empty()); + lenient().when(digestLogRepository.save(any(SlackDigestLog.class))) + .thenAnswer(inv -> inv.getArgument(0)); + lenient().when(digestLogRepository.existsByPreferenceIdAndConnectionIdAndSentAtGreaterThanEqual( + any(), anyString(), any())).thenReturn(false); + lenient().when(digestLogRepository.existsByConnectionIdAndRecipientUsernameAndSentAtGreaterThanEqual( + anyString(), anyString(), any())).thenReturn(false); + lenient().when(credentialService.getAllConnections()).thenReturn(List.of()); + lenient().when(channelBindingRepository.findAll()).thenReturn(List.of()); + } + + @Test + void differentCrons_onlyDueUserIsDelivered() { + Instant atEightUtc = ZonedDateTime.of(2026, 9, 8, 8, 0, 0, 0, ZoneId.of("UTC")).toInstant(); + + UserDigestPreference eightAm = UserDigestPreference.builder() + .id(1L) + .username("alice") + .connectionId("conn-1") + .enabled(true) + .deliveryMethod(DigestDeliveryMethod.SLACK_DM) + .personaTag(PersonaTag.DBA) + .cronExpression("0 0 8 * * *") + .timezone("UTC") + .build(); + + UserDigestPreference nineAm = UserDigestPreference.builder() + .id(2L) + .username("bob") + .connectionId("conn-1") + .enabled(true) + .deliveryMethod(DigestDeliveryMethod.SLACK_DM) + .personaTag(PersonaTag.EXEC) + .cronExpression("0 0 9 * * *") + .timezone("UTC") + .build(); + + when(preferenceRepository.countByEnabledTrue()).thenReturn(2L); + when(preferenceRepository.findByEnabledTrueAndDeliveryMethod(DigestDeliveryMethod.SLACK_DM)) + .thenReturn(List.of(eightAm, nineAm)); + + stubUser("alice", "DBA"); + stubUser("bob", "ADMIN"); + + when(assemblerService.assembleDigest(eq("alice"), eq("conn-1"), eq(Role.DBA), eq(PersonaTag.DBA), any())) + .thenReturn(DigestAssemblyResult.empty("alice", "conn-1", Role.DBA, PersonaTag.DBA)); + + service.processDigestTick(atEightUtc); + + verify(assemblerService, times(1)).assembleDigest( + eq("alice"), eq("conn-1"), eq(Role.DBA), eq(PersonaTag.DBA), any()); + verify(assemblerService, never()).assembleDigest( + eq("bob"), anyString(), any(), any(), any()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SlackDigestLog.class); + verify(digestLogRepository, times(1)).save(captor.capture()); + assertThat(captor.getValue().getRecipientUsername()).isEqualTo("alice"); + } + + @Test + void legacyWithoutPrefs_skipsPerUserPath() { + Instant atTen = ZonedDateTime.of(2026, 9, 8, 10, 0, 0, 0, ZoneId.of("UTC")).toInstant(); + + when(preferenceRepository.countByEnabledTrue()).thenReturn(0L); + + service.processDigestTick(atTen); + + verify(preferenceRepository, never()).findByEnabledTrueAndDeliveryMethod(any()); + verify(assemblerService, never()).assembleDigest(anyString(), anyString(), any(), any(), any()); + verify(digestLogRepository, never()).save(any()); + } + + @Test + void timezoneHandling_firesInUserZoneNotUtc() { + // 09:00 America/New_York in Sep = 13:00 UTC + Instant utcThirteen = ZonedDateTime.of(2026, 9, 8, 13, 0, 0, 0, ZoneId.of("UTC")).toInstant(); + + UserDigestPreference nyNine = UserDigestPreference.builder() + .id(3L) + .username("carol") + .connectionId("conn-ny") + .enabled(true) + .deliveryMethod(DigestDeliveryMethod.SLACK_DM) + .personaTag(PersonaTag.APP_ENG) + .cronExpression("0 0 9 * * *") + .timezone("America/New_York") + .build(); + + when(preferenceRepository.countByEnabledTrue()).thenReturn(1L); + when(preferenceRepository.findByEnabledTrueAndDeliveryMethod(DigestDeliveryMethod.SLACK_DM)) + .thenReturn(List.of(nyNine)); + // Global cron 09:00 UTC is not due at 13:00 UTC + lenient().when(preferenceRepository.findEnabledForConnection(anyString())).thenReturn(List.of(nyNine)); + + stubUser("carol", "DEVELOPER"); + when(assemblerService.assembleDigest( + eq("carol"), eq("conn-ny"), eq(Role.DEVELOPER), eq(PersonaTag.APP_ENG), any())) + .thenReturn(DigestAssemblyResult.empty("carol", "conn-ny", Role.DEVELOPER, PersonaTag.APP_ENG)); + + service.processDigestTick(utcThirteen); + + verify(assemblerService).assembleDigest( + eq("carol"), eq("conn-ny"), eq(Role.DEVELOPER), eq(PersonaTag.APP_ENG), any()); + } + + @Test + void alreadyDeliveredThisWindow_isSkipped() { + Instant atNine = ZonedDateTime.of(2026, 9, 8, 9, 0, 0, 0, ZoneId.of("UTC")).toInstant(); + + UserDigestPreference pref = UserDigestPreference.builder() + .id(4L) + .username("dave") + .connectionId("conn-1") + .enabled(true) + .deliveryMethod(DigestDeliveryMethod.SLACK_DM) + .cronExpression("0 0 9 * * *") + .timezone("UTC") + .build(); + + when(preferenceRepository.countByEnabledTrue()).thenReturn(1L); + when(preferenceRepository.findByEnabledTrueAndDeliveryMethod(DigestDeliveryMethod.SLACK_DM)) + .thenReturn(List.of(pref)); + when(digestLogRepository.existsByPreferenceIdAndConnectionIdAndSentAtGreaterThanEqual( + eq(4L), eq("conn-1"), any(LocalDateTime.class))).thenReturn(true); + + service.processDigestTick(atNine); + + verify(assemblerService, never()).assembleDigest(anyString(), anyString(), any(), any(), any()); + verify(digestLogRepository, never()).save(any()); + } + + private void stubUser(String username, String role) { + User user = new User(); + user.setUsername(username); + user.setRole(role); + lenient().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..e11df12 --- /dev/null +++ b/backend/src/test/java/com/dbaagent/service/PerRecipientDigestDeliveryTest.java @@ -0,0 +1,416 @@ +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.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.AuthLoginChallengeRepository; +import com.dbaagent.repository.CapacityForecastRepository; +import com.dbaagent.repository.ConnectionAccessGrantRepository; +import com.dbaagent.repository.DatabaseEventRepository; +import com.dbaagent.repository.GrowthAnomalyRepository; +import com.dbaagent.repository.LockContentionRepository; +import com.dbaagent.repository.QueryFingerprintRepository; +import com.dbaagent.repository.SchemaChangeRepository; +import com.dbaagent.repository.SlackChannelBindingRepository; +import com.dbaagent.repository.SlackDigestLogRepository; +import com.dbaagent.repository.TableStatsHistoryRepository; +import com.dbaagent.repository.UserDigestPreferenceRepository; +import com.dbaagent.repository.UserRepository; +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 org.springframework.test.util.ReflectionTestUtils; + +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.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 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
  • + *
  • EMAIL prefs are skipped (not a fake delivery path)
  • + *
  • Proper logging with recipient details
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +class PerRecipientDigestDeliveryTest { + + @Mock private SlackRuntimeSettingsService slackRuntimeSettingsService; + @Mock private SlackChannelBindingRepository channelBindingRepository; + @Mock private CredentialService credentialService; + @Mock private ConnectionService connectionService; + @Mock private PerformanceInsightsService performanceInsightsService; + @Mock private SlowQueryService slowQueryService; + @Mock private SlowQueryHistoryService slowQueryHistoryService; + @Mock private SlowQueryInsightsService slowQueryInsightsService; + @Mock private SlowQueryAnalyticsService slowQueryAnalyticsService; + @Mock private PerformanceActionAggregatorService actionAggregatorService; + @Mock private EnhancedSqlParserService sqlParserService; + @Mock private QueryExecutorService queryExecutorService; + @Mock private TableGrowthMonitoringService tableGrowthMonitoringService; + @Mock private SchemaChangeTrackingService schemaChangeTrackingService; + @Mock private TableStatsHistoryRepository tableStatsHistoryRepository; + @Mock private GrowthAnomalyRepository growthAnomalyRepository; + @Mock private CapacityForecastRepository capacityForecastRepository; + @Mock private SchemaChangeRepository schemaChangeRepository; + @Mock private SlackDigestLogRepository digestLogRepository; + @Mock private SlackUserLinkService slackUserLinkService; + @Mock private LockContentionRepository lockContentionRepository; + @Mock private QueryFingerprintRepository queryFingerprintRepository; + @Mock private DatabaseEventRepository databaseEventRepository; + @Mock private ConnectionAccessGrantRepository connectionAccessGrantRepository; + @Mock private AuthLoginChallengeRepository authLoginChallengeRepository; + @Mock private IndexAdvisorService indexAdvisorService; + @Mock private IndexRecommendationService indexRecommendationService; + @Mock private UserDigestPreferenceRepository preferenceRepository; + @Mock private DigestInsightAssemblerService assemblerService; + @Mock private UserRepository userRepository; + + private SlackDailyDigestService service; + + @BeforeEach + void setUp() { + service = new SlackDailyDigestService( + slackRuntimeSettingsService, + channelBindingRepository, + credentialService, + connectionService, + performanceInsightsService, + slowQueryService, + slowQueryHistoryService, + slowQueryInsightsService, + slowQueryAnalyticsService, + actionAggregatorService, + sqlParserService, + queryExecutorService, + tableGrowthMonitoringService, + schemaChangeTrackingService, + tableStatsHistoryRepository, + growthAnomalyRepository, + capacityForecastRepository, + schemaChangeRepository, + digestLogRepository, + slackUserLinkService, + lockContentionRepository, + queryFingerprintRepository, + databaseEventRepository, + connectionAccessGrantRepository, + authLoginChallengeRepository, + indexAdvisorService, + indexRecommendationService, + preferenceRepository, + assemblerService, + userRepository + ); + + // Slack disabled: generate + log without opening DMs (keeps this unit-level). + lenient().when(slackRuntimeSettingsService.current()).thenReturn( + new SlackRuntimeSettingsService.SlackRuntimeConfig(false, false, null, null, null, null)); + lenient().when(digestLogRepository.findTopByConnectionIdOrderBySentAtDesc(anyString())) + .thenReturn(Optional.empty()); + lenient().when(digestLogRepository.save(any(SlackDigestLog.class))) + .thenAnswer(inv -> inv.getArgument(0)); + lenient().when(credentialService.getAllConnections()).thenReturn(List.of()); + } + + @Test + void twoUsersWithDifferentPersonas_getDifferentDigests() { + 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(); + + when(preferenceRepository.findEnabledForConnection(connectionId)) + .thenReturn(List.of(dbaPreference, execPreference)); + + 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)); + + 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); + + SlackDailyDigestService.PersonalizedDeliveryResult result = + service.sendPersonalizedDigests(connectionId); + + assertThat(result.recipients()).isEqualTo(2); + assertThat(result.generated()).isEqualTo(2); + // Slack is disabled in this test, so nothing is posted — only generated + logged. + assertThat(result.sent()).isEqualTo(0); + + verify(assemblerService).assembleDigest( + eq("alice_dba"), eq(connectionId), eq(Role.DBA), eq(PersonaTag.DBA), any()); + verify(assemblerService).assembleDigest( + eq("bob_exec"), eq(connectionId), eq(Role.ADMIN), eq(PersonaTag.EXEC), any()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SlackDigestLog.class); + verify(digestLogRepository, org.mockito.Mockito.times(2)).save(captor.capture()); + + List logs = captor.getAllValues(); + SlackDigestLog aliceLog = logs.stream() + .filter(l -> "alice_dba".equals(l.getRecipientUsername())).findFirst().orElseThrow(); + SlackDigestLog bobLog = logs.stream() + .filter(l -> "bob_exec".equals(l.getRecipientUsername())).findFirst().orElseThrow(); + + assertThat(aliceLog.isPersonalized()).isTrue(); + assertThat(aliceLog.getPersonaTag()).isEqualTo(PersonaTag.DBA); + assertThat(aliceLog.getRecipientRole()).isEqualTo("DBA"); + assertThat(aliceLog.getDeliveryMethod()).isEqualTo(DigestDeliveryMethod.SLACK_DM); + assertThat(aliceLog.getContent()).contains("CRITICAL"); + + assertThat(bobLog.isPersonalized()).isTrue(); + assertThat(bobLog.getPersonaTag()).isEqualTo(PersonaTag.EXEC); + assertThat(bobLog.getRecipientRole()).isEqualTo("ADMIN"); + assertThat(bobLog.getContent()).contains("EXECUTIVE SUMMARY"); + assertThat(bobLog.getContent()).contains("ACTION NEEDED"); + assertThat(bobLog.getContent().lines().filter(line -> line.startsWith("• ")).count()) + .isLessThanOrEqualTo(3); + } + + @Test + void execPersona_getsTightThreeBulletSummary() { + DigestAssemblyResult execResult = createExecDigest(); + + String message = ReflectionTestUtils.invokeMethod( + service, "formatPersonalizedDigest", execResult, "prod-db", PersonaTag.EXEC); + + assertThat(message).contains("EXECUTIVE SUMMARY"); + assertThat(message).contains("ACTION NEEDED"); + assertThat(message).contains("Review and approve index recommendation"); + assertThat(message.lines().filter(line -> line.startsWith("• ")).count()) + .isLessThanOrEqualTo(3); + // EXEC payload stays short — no full insight dump. + assertThat(message).doesNotContain("[sig:"); + } + + @Test + void emailPreference_isSkippedNotDelivered() { + UserDigestPreference emailPref = UserDigestPreference.builder() + .id(9L) + .username("carol") + .connectionId("conn-123") + .enabled(true) + .deliveryMethod(DigestDeliveryMethod.EMAIL) + .personaTag(PersonaTag.EXEC) + .build(); + + when(preferenceRepository.findEnabledForConnection("conn-123")) + .thenReturn(List.of(emailPref)); + + SlackDailyDigestService.PersonalizedDeliveryResult result = + service.sendPersonalizedDigests("conn-123"); + + assertThat(result.recipients()).isEqualTo(1); + assertThat(result.generated()).isEqualTo(0); + verify(assemblerService, never()).assembleDigest(anyString(), anyString(), any(), any(), any()); + verify(digestLogRepository, never()).save(any()); + } + + @Test + void customRole_doesNotNpe_andLogsStoredRoleCode() { + UserDigestPreference pref = UserDigestPreference.builder() + .id(3L) + .username("analyst") + .connectionId("conn-123") + .enabled(true) + .deliveryMethod(DigestDeliveryMethod.SLACK_DM) + .personaTag(PersonaTag.DATA_ENG) + .build(); + + when(preferenceRepository.findEnabledForConnection("conn-123")) + .thenReturn(List.of(pref)); + + User custom = new User(); + custom.setUsername("analyst"); + custom.setRole("ANALYST"); // not a built-in Role + when(userRepository.findByUsernameIgnoreCase("analyst")).thenReturn(Optional.of(custom)); + + when(assemblerService.assembleDigest( + eq("analyst"), eq("conn-123"), eq(Role.DEVELOPER), eq(PersonaTag.DATA_ENG), any() + )).thenReturn(DigestAssemblyResult.empty("analyst", "conn-123", Role.DEVELOPER, PersonaTag.DATA_ENG)); + + service.sendPersonalizedDigests("conn-123"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SlackDigestLog.class); + verify(digestLogRepository).save(captor.capture()); + assertThat(captor.getValue().getRecipientRole()).isEqualTo("ANALYST"); + assertThat(captor.getValue().isPersonalized()).isTrue(); + } + + @Test + void digestLog_containsRecipientDetails() { + 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"); + + 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() { + SlackDigestLog logEntry = new SlackDigestLog(); + logEntry.setConnectionId("conn-123"); + logEntry.setChannelId("C123456"); + logEntry.setPersonalized(false); + logEntry.setStatus("SENT"); + + assertThat(logEntry.getRecipientUsername()).isNull(); + assertThat(logEntry.getRecipientRole()).isNull(); + assertThat(logEntry.getPersonaTag()).isNull(); + assertThat(logEntry.isPersonalized()).isFalse(); + assertThat(logEntry.isPerUserDelivery()).isFalse(); + } + + @Test + void preferenceForConnection_appliesCorrectly() { + UserDigestPreference pref = UserDigestPreference.builder() + .username("alice") + .connectionId("conn-123") + .enabled(true) + .build(); + + assertThat(pref.appliesTo("conn-123")).isTrue(); + assertThat(pref.appliesTo("conn-456")).isFalse(); + } + + @Test + void preferenceWithNullConnection_appliesToAll() { + UserDigestPreference pref = UserDigestPreference.builder() + .username("alice") + .connectionId(null) + .enabled(true) + .build(); + + assertThat(pref.appliesTo("conn-123")).isTrue(); + assertThat(pref.appliesTo("conn-456")).isTrue(); + } + + @Test + void assemblyResult_emptyDigest_indicatesEmpty() { + DigestAssemblyResult empty = DigestAssemblyResult.empty( + "alice", "conn-123", Role.DBA, PersonaTag.DBA); + + 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/backend/src/test/java/com/dbaagent/service/SlackDailyDigestDemoPrintTest.java b/backend/src/test/java/com/dbaagent/service/SlackDailyDigestDemoPrintTest.java index ead71ba..82e415c 100644 --- a/backend/src/test/java/com/dbaagent/service/SlackDailyDigestDemoPrintTest.java +++ b/backend/src/test/java/com/dbaagent/service/SlackDailyDigestDemoPrintTest.java @@ -73,6 +73,8 @@ class SlackDailyDigestDemoPrintTest { @Mock private IndexAdvisorService indexAdvisorService; @Mock private IndexRecommendationService indexRecommendationService; @Mock private com.dbaagent.repository.UserDigestPreferenceRepository userDigestPreferenceRepository; + @Mock private com.dbaagent.service.digest.DigestInsightAssemblerService digestInsightAssemblerService; + @Mock private com.dbaagent.repository.UserRepository userRepository; private SlackDailyDigestService service; @@ -88,7 +90,8 @@ void setUp() { slackUserLinkService, lockContentionRepository, queryFingerprintRepository, databaseEventRepository, connectionAccessGrantRepository, authLoginChallengeRepository, indexAdvisorService, - indexRecommendationService, userDigestPreferenceRepository + indexRecommendationService, userDigestPreferenceRepository, + digestInsightAssemblerService, userRepository ); } diff --git a/backend/src/test/java/com/dbaagent/service/SlackDailyDigestServiceTest.java b/backend/src/test/java/com/dbaagent/service/SlackDailyDigestServiceTest.java index cf68450..030fe33 100644 --- a/backend/src/test/java/com/dbaagent/service/SlackDailyDigestServiceTest.java +++ b/backend/src/test/java/com/dbaagent/service/SlackDailyDigestServiceTest.java @@ -70,6 +70,8 @@ class SlackDailyDigestServiceTest { @Mock private IndexAdvisorService indexAdvisorService; @Mock private IndexRecommendationService indexRecommendationService; @Mock private com.dbaagent.repository.UserDigestPreferenceRepository userDigestPreferenceRepository; + @Mock private com.dbaagent.service.digest.DigestInsightAssemblerService digestInsightAssemblerService; + @Mock private com.dbaagent.repository.UserRepository userRepository; private SlackDailyDigestService service; @@ -104,7 +106,9 @@ void setUp() { authLoginChallengeRepository, indexAdvisorService, indexRecommendationService, - userDigestPreferenceRepository + userDigestPreferenceRepository, + digestInsightAssemblerService, + userRepository ); } @@ -492,7 +496,8 @@ void newcomersSection_surfacesQueriesFirstSeenInWindow() { service, "appendNewcomersSection", sb, style, "conn-1", since); String out = sb.toString(); - assertThat(out).contains("new_pricing_table"); + assertThat(out).containsIgnoringCase("pricing table"); + assertThat(out).contains("abc"); assertThat(out).doesNotContain("SELECT 1"); } diff --git a/backend/src/test/java/com/dbaagent/service/digest/DigestCronMatcherTest.java b/backend/src/test/java/com/dbaagent/service/digest/DigestCronMatcherTest.java new file mode 100644 index 0000000..34b9bc9 --- /dev/null +++ b/backend/src/test/java/com/dbaagent/service/digest/DigestCronMatcherTest.java @@ -0,0 +1,86 @@ +package com.dbaagent.service.digest; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class DigestCronMatcherTest { + + private static final Duration ONE_MINUTE = Duration.ofMinutes(1); + private static final ZoneId UTC = ZoneId.of("UTC"); + private static final ZoneId NY = ZoneId.of("America/New_York"); + + @Test + void isDue_whenNowMatchesCronMinute_returnsTrue() { + // 09:00:00 UTC on a weekday — matches "0 0 9 * * *" + Instant now = ZonedDateTime.of(2026, 9, 8, 9, 0, 0, 0, UTC).toInstant(); + + assertThat(DigestCronMatcher.isDue("0 0 9 * * *", UTC, now, ONE_MINUTE)).isTrue(); + Optional window = DigestCronMatcher.dueWindowStart("0 0 9 * * *", UTC, now, ONE_MINUTE); + assertThat(window).isPresent(); + assertThat(window.get()).isEqualTo(now); + } + + @Test + void isDue_oneMinuteAfterFire_returnsFalse() { + Instant now = ZonedDateTime.of(2026, 9, 8, 9, 1, 0, 0, UTC).toInstant(); + + assertThat(DigestCronMatcher.isDue("0 0 9 * * *", UTC, now, ONE_MINUTE)).isFalse(); + } + + @Test + void isDue_differentCron_onlyMatchesItsOwnHour() { + Instant atEight = ZonedDateTime.of(2026, 9, 8, 8, 0, 0, 0, UTC).toInstant(); + Instant atNine = ZonedDateTime.of(2026, 9, 8, 9, 0, 0, 0, UTC).toInstant(); + + assertThat(DigestCronMatcher.isDue("0 0 8 * * *", UTC, atEight, ONE_MINUTE)).isTrue(); + assertThat(DigestCronMatcher.isDue("0 0 9 * * *", UTC, atEight, ONE_MINUTE)).isFalse(); + + assertThat(DigestCronMatcher.isDue("0 0 8 * * *", UTC, atNine, ONE_MINUTE)).isFalse(); + assertThat(DigestCronMatcher.isDue("0 0 9 * * *", UTC, atNine, ONE_MINUTE)).isTrue(); + } + + @Test + void isDue_honorsTimezone() { + // 09:00 America/New_York in September = 13:00 UTC + Instant utcThirteen = ZonedDateTime.of(2026, 9, 8, 13, 0, 0, 0, UTC).toInstant(); + Instant utcNine = ZonedDateTime.of(2026, 9, 8, 9, 0, 0, 0, UTC).toInstant(); + + assertThat(DigestCronMatcher.isDue("0 0 9 * * *", NY, utcThirteen, ONE_MINUTE)).isTrue(); + assertThat(DigestCronMatcher.isDue("0 0 9 * * *", NY, utcNine, ONE_MINUTE)).isFalse(); + assertThat(DigestCronMatcher.isDue("0 0 9 * * *", UTC, utcNine, ONE_MINUTE)).isTrue(); + } + + @Test + void resolveZone_blankOrInvalid_fallsBackToUtc() { + assertThat(DigestCronMatcher.resolveZone(null)).isEqualTo(UTC); + assertThat(DigestCronMatcher.resolveZone("")).isEqualTo(UTC); + assertThat(DigestCronMatcher.resolveZone("Not/AZone")).isEqualTo(UTC); + assertThat(DigestCronMatcher.resolveZone("Asia/Kolkata")).isEqualTo(ZoneId.of("Asia/Kolkata")); + } + + @Test + void dueWindowStart_invalidCron_returnsEmpty() { + Instant now = Instant.parse("2026-09-08T09:00:00Z"); + assertThat(DigestCronMatcher.dueWindowStart("not a cron", UTC, now, ONE_MINUTE)).isEmpty(); + assertThat(DigestCronMatcher.dueWindowStart(null, UTC, now, ONE_MINUTE)).isEmpty(); + } + + @Test + void dueWindowStart_withinLookback_capturesFireTime() { + // Tick at 09:00:30 — fire was at 09:00:00, still within 1-minute lookback + ZonedDateTime fire = ZonedDateTime.of(2026, 9, 8, 9, 0, 0, 0, UTC); + Instant tick = fire.plusSeconds(30).toInstant(); + + Optional window = DigestCronMatcher.dueWindowStart( + "0 0 9 * * *", UTC, tick, ONE_MINUTE); + assertThat(window).contains(fire.toInstant()); + } +} diff --git a/docs/DIGEST_PREFERENCES.md b/docs/DIGEST_PREFERENCES.md index 5825c83..9290417 100644 --- a/docs/DIGEST_PREFERENCES.md +++ b/docs/DIGEST_PREFERENCES.md @@ -1,159 +1,227 @@ -# 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 │ - ▼ └────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────┐ +│ Minute tick (slack.daily-digest.tick-cron) │ +│ SlackDailyDigestTaskConfig │ +│ processDigestTick() │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ┌─────────────────┴─────────────────┐ + ▼ ▼ +┌────────────────────────────┐ ┌────────────────────────────────┐ +│ No enabled preferences │ │ Enabled preferences exist │ +│ Global cron due (UTC)? │ │ For each SLACK_DM preference: │ +│ yes → legacy broadcast │ │ • effective cron (pref or │ +│ no → skip │ │ global default) │ +└────────────────────────────┘ │ • evaluate in pref timezone │ + │ • skip if already logged this │ + │ fire window (SlackDigestLog)│ + │ • else per-recipient delivery │ + └────────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────┐ + │ assembleDigest → format → DM │ + │ → SlackDigestLog (personalized)│ + └────────────────────────────────┘ + +Manual/admin trigger still uses sendDailyDigestHybrid() (force all recipients). ``` -### 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.) — stored on the preference and honored by the minute-tick scheduler +- 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" - }' +New columns for per-recipient tracking: + +```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; ``` -### Example: Create an executive preference for a specific connection via email at 8 AM EST +## Configuration -```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" - }' -``` +| Property | Default | Description | +|----------|---------|-------------| +| `slack.daily-digest.tick-cron` | `0 * * * * *` | Minute tick that evaluates due preferences | +| `slack.daily-digest.cron` | `0 0 9 * * *` | Global/legacy schedule (UTC); also default when a preference leaves `cronExpression` blank | +| `slack.digest.admins-only` | `true` | Restrict legacy broadcast to admin channels | -## Migration from Singleton +### Per-user schedules -The singleton `SlackDigestConfig` (id=1) remains active and serves as the global default: +Each `UserDigestPreference` may set `cronExpression` (Spring 6-field) and an IANA `timezone`. +The minute tick uses Spring's `CronExpression` API to decide who is due. Delivery is +idempotent per preference + connection for that fire window via `SlackDigestLog`. +UI schedule presets (8 AM / 9 AM / Noon / …) write these fields — they are honored by +the scheduler, not display-only. -- **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 +./mvnw test -Dtest=PerRecipientDigestDeliveryTest,PerRecipientDigestCronSchedulingTest,DigestCronMatcherTest,DigestPreferenceSeedServiceTest,SlackDailyDigestServiceTest +``` -## Future Work (PR2) +Key test scenarios: +- Two users with different personas get different digests +- Different crons → only the due user is delivered on a tick +- Timezone: `0 0 9 * * *` in `America/New_York` fires at 13:00 UTC (EDT) +- Idempotent: already-logged fire window is skipped +- EXEC gets tight 3-bullet summary +- Seed skips existing preferences (idempotent) +- Dry run mode for preview +- Legacy fallback when no preferences exist (gated by global cron) -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..cfb098a --- /dev/null +++ b/src/components/sections/DigestPreferencesPanel.jsx @@ -0,0 +1,455 @@ +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 [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, statusRes] = await Promise.all([ + digestPreferencesAPI.getMyPreferences(), + digestPreferencesAPI.getPersonaTags(), + digestPreferencesAPI.getStatus(), + ]) + setPreferences(prefs || []) + setPersonaTags(tags || []) + 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'} +