Skip to content

Commit 38ad33a

Browse files
fix(digest): Run Now / triggerDigest uses hybrid personalized path (#109)
## Summary - Wire admin **Run Now** / `POST /api/admin/slack/digest/trigger` through `sendDailyDigestAsync()` → `sendDailyDigestHybrid()` (per-user prefs / Slack DMs when present; legacy broadcast otherwise) instead of legacy-only `sendDailyDigest()`. - Keep trigger response messaging honest: when prefs exist, mention personalized DMs and that channel bindings are not required for DMs. - Add focused unit tests in `DigestTriggerHybridTest`. Stayflexi GTM needs Run Now to DM per-user prefs (e.g. EXEC on ec2-replica). Tiny follow-up; does not mix with Digests prefs UX (#108). ## Test plan - [x] `./mvnw -Dtest=DigestTriggerHybridTest test` - [ ] Admin Digests → Run Now with per-user prefs and no channel bindings → personalized DM path runs; toast does not claim “only appear in the app” - [ ] Legacy (no prefs) with no bindings → prior channel-binding message still shown
1 parent 8b8a100 commit 38ad33a

2 files changed

Lines changed: 176 additions & 6 deletions

File tree

backend/src/main/java/com/dbaagent/service/SlackDailyDigestService.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,12 @@ private record SchemaDigestData(
271271

272272
public record TriggerResult(boolean triggered, String message) {}
273273

274-
/** Runs the digest in a virtual thread so the HTTP trigger returns immediately. */
274+
/**
275+
* Runs the hybrid digest in a virtual thread so the HTTP trigger returns immediately.
276+
* Uses per-user personalized delivery when prefs exist; otherwise legacy broadcast.
277+
*/
275278
public void sendDailyDigestAsync() {
276-
Thread.ofVirtual().name("digest-send").start(this::sendDailyDigest);
279+
Thread.ofVirtual().name("digest-send").start(this::sendDailyDigestHybrid);
277280
}
278281

279282
public TriggerResult triggerDigest() {
@@ -283,17 +286,21 @@ public TriggerResult triggerDigest() {
283286
}
284287

285288
boolean slackEnabled = isSlackDeliveryEnabled();
289+
boolean perUserMode = isPerUserModeEnabled();
286290
long bindingCount = channelBindingRepository.count();
291+
292+
sendDailyDigestAsync();
293+
287294
if (!slackEnabled) {
288-
sendDailyDigestAsync();
289295
return new TriggerResult(true, "Digest generation started. Slack delivery is disabled in this environment.");
290296
}
297+
if (perUserMode) {
298+
return new TriggerResult(true,
299+
"Personalized digest delivery started (Slack DMs per user prefs when linked). Channel bindings are not required for DMs.");
300+
}
291301
if (bindingCount == 0) {
292-
sendDailyDigestAsync();
293302
return new TriggerResult(true, "Digest generation started. No Slack channel bindings found, so it will only appear in the app.");
294303
}
295-
296-
sendDailyDigestAsync();
297304
return new TriggerResult(true, "Digest generation and Slack delivery are running in the background.");
298305
}
299306

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package com.dbaagent.service;
2+
3+
import com.dbaagent.model.DatabaseConnection;
4+
import com.dbaagent.repository.AuthLoginChallengeRepository;
5+
import com.dbaagent.repository.CapacityForecastRepository;
6+
import com.dbaagent.repository.ConnectionAccessGrantRepository;
7+
import com.dbaagent.repository.DatabaseEventRepository;
8+
import com.dbaagent.repository.GrowthAnomalyRepository;
9+
import com.dbaagent.repository.LockContentionRepository;
10+
import com.dbaagent.repository.QueryFingerprintRepository;
11+
import com.dbaagent.repository.SchemaChangeRepository;
12+
import com.dbaagent.repository.SlackChannelBindingRepository;
13+
import com.dbaagent.repository.SlackDigestLogRepository;
14+
import com.dbaagent.repository.TableStatsHistoryRepository;
15+
import com.dbaagent.repository.UserDigestPreferenceRepository;
16+
import com.dbaagent.repository.UserRepository;
17+
import com.dbaagent.service.digest.DigestInsightAssemblerService;
18+
import org.junit.jupiter.api.BeforeEach;
19+
import org.junit.jupiter.api.Test;
20+
import org.junit.jupiter.api.extension.ExtendWith;
21+
import org.mockito.Mock;
22+
import org.mockito.junit.jupiter.MockitoExtension;
23+
24+
import java.util.List;
25+
26+
import static org.assertj.core.api.Assertions.assertThat;
27+
import static org.mockito.Mockito.doNothing;
28+
import static org.mockito.Mockito.never;
29+
import static org.mockito.Mockito.spy;
30+
import static org.mockito.Mockito.timeout;
31+
import static org.mockito.Mockito.verify;
32+
import static org.mockito.Mockito.when;
33+
34+
/**
35+
* Admin Run Now / triggerDigest must use the hybrid personalized path,
36+
* not legacy-only sendDailyDigest().
37+
*/
38+
@ExtendWith(MockitoExtension.class)
39+
class DigestTriggerHybridTest {
40+
41+
@Mock private SlackRuntimeSettingsService slackRuntimeSettingsService;
42+
@Mock private SlackChannelBindingRepository channelBindingRepository;
43+
@Mock private CredentialService credentialService;
44+
@Mock private ConnectionService connectionService;
45+
@Mock private PerformanceInsightsService performanceInsightsService;
46+
@Mock private SlowQueryService slowQueryService;
47+
@Mock private SlowQueryHistoryService slowQueryHistoryService;
48+
@Mock private SlowQueryInsightsService slowQueryInsightsService;
49+
@Mock private SlowQueryAnalyticsService slowQueryAnalyticsService;
50+
@Mock private PerformanceActionAggregatorService actionAggregatorService;
51+
@Mock private EnhancedSqlParserService sqlParserService;
52+
@Mock private QueryExecutorService queryExecutorService;
53+
@Mock private TableGrowthMonitoringService tableGrowthMonitoringService;
54+
@Mock private SchemaChangeTrackingService schemaChangeTrackingService;
55+
@Mock private TableStatsHistoryRepository tableStatsHistoryRepository;
56+
@Mock private GrowthAnomalyRepository growthAnomalyRepository;
57+
@Mock private CapacityForecastRepository capacityForecastRepository;
58+
@Mock private SchemaChangeRepository schemaChangeRepository;
59+
@Mock private SlackDigestLogRepository digestLogRepository;
60+
@Mock private SlackUserLinkService slackUserLinkService;
61+
@Mock private LockContentionRepository lockContentionRepository;
62+
@Mock private QueryFingerprintRepository queryFingerprintRepository;
63+
@Mock private DatabaseEventRepository databaseEventRepository;
64+
@Mock private ConnectionAccessGrantRepository connectionAccessGrantRepository;
65+
@Mock private AuthLoginChallengeRepository authLoginChallengeRepository;
66+
@Mock private IndexAdvisorService indexAdvisorService;
67+
@Mock private IndexRecommendationService indexRecommendationService;
68+
@Mock private UserDigestPreferenceRepository preferenceRepository;
69+
@Mock private DigestInsightAssemblerService assemblerService;
70+
@Mock private UserRepository userRepository;
71+
72+
private SlackDailyDigestService service;
73+
74+
@BeforeEach
75+
void setUp() {
76+
service = new SlackDailyDigestService(
77+
slackRuntimeSettingsService,
78+
channelBindingRepository,
79+
credentialService,
80+
connectionService,
81+
performanceInsightsService,
82+
slowQueryService,
83+
slowQueryHistoryService,
84+
slowQueryInsightsService,
85+
slowQueryAnalyticsService,
86+
actionAggregatorService,
87+
sqlParserService,
88+
queryExecutorService,
89+
tableGrowthMonitoringService,
90+
schemaChangeTrackingService,
91+
tableStatsHistoryRepository,
92+
growthAnomalyRepository,
93+
capacityForecastRepository,
94+
schemaChangeRepository,
95+
digestLogRepository,
96+
slackUserLinkService,
97+
lockContentionRepository,
98+
queryFingerprintRepository,
99+
databaseEventRepository,
100+
connectionAccessGrantRepository,
101+
authLoginChallengeRepository,
102+
indexAdvisorService,
103+
indexRecommendationService,
104+
preferenceRepository,
105+
assemblerService,
106+
userRepository
107+
);
108+
}
109+
110+
@Test
111+
void sendDailyDigestAsync_invokesHybridNotLegacy() {
112+
SlackDailyDigestService spyService = spy(service);
113+
doNothing().when(spyService).sendDailyDigestHybrid();
114+
115+
spyService.sendDailyDigestAsync();
116+
117+
verify(spyService, timeout(3000)).sendDailyDigestHybrid();
118+
verify(spyService, never()).sendDailyDigest();
119+
}
120+
121+
@Test
122+
void triggerDigest_withPrefsAndNoChannelBindings_mentionsPersonalizedDm() {
123+
DatabaseConnection connection = new DatabaseConnection();
124+
connection.setId("ec2-replica");
125+
when(credentialService.getAllConnections()).thenReturn(List.of(connection));
126+
when(channelBindingRepository.findAll()).thenReturn(List.of());
127+
when(preferenceRepository.hasAnyPreferences()).thenReturn(true);
128+
when(slackRuntimeSettingsService.current()).thenReturn(
129+
new SlackRuntimeSettingsService.SlackRuntimeConfig(true, false, null, "xoxb-test", null, null));
130+
131+
SlackDailyDigestService spyService = spy(service);
132+
doNothing().when(spyService).sendDailyDigestAsync();
133+
134+
SlackDailyDigestService.TriggerResult result = spyService.triggerDigest();
135+
136+
assertThat(result.triggered()).isTrue();
137+
assertThat(result.message()).containsIgnoringCase("personalized");
138+
assertThat(result.message()).containsIgnoringCase("DM");
139+
assertThat(result.message()).doesNotContain("only appear in the app");
140+
verify(spyService).sendDailyDigestAsync();
141+
}
142+
143+
@Test
144+
void triggerDigest_legacyWithoutBindings_stillMentionsChannelBindings() {
145+
DatabaseConnection connection = new DatabaseConnection();
146+
connection.setId("conn-1");
147+
when(credentialService.getAllConnections()).thenReturn(List.of(connection));
148+
when(channelBindingRepository.findAll()).thenReturn(List.of());
149+
when(channelBindingRepository.count()).thenReturn(0L);
150+
when(preferenceRepository.hasAnyPreferences()).thenReturn(false);
151+
when(slackRuntimeSettingsService.current()).thenReturn(
152+
new SlackRuntimeSettingsService.SlackRuntimeConfig(true, false, null, "xoxb-test", null, null));
153+
154+
SlackDailyDigestService spyService = spy(service);
155+
doNothing().when(spyService).sendDailyDigestAsync();
156+
157+
SlackDailyDigestService.TriggerResult result = spyService.triggerDigest();
158+
159+
assertThat(result.triggered()).isTrue();
160+
assertThat(result.message()).contains("No Slack channel bindings");
161+
assertThat(result.message()).doesNotContainIgnoringCase("personalized");
162+
}
163+
}

0 commit comments

Comments
 (0)