Skip to content

Commit 7bd3ed1

Browse files
feat(digest): per-recipient Slack delivery with UI prefs (PR3)
- Extend SlackDailyDigestService with sendPersonalizedDigests() consuming DigestAssemblyResult - Add DigestPreferenceSeedService to seed preferences from singleton config - Update scheduler to use hybrid mode (per-user when prefs exist, legacy otherwise) - Add digest preferences API endpoints to client.js - Create DigestPreferencesPanel UI component with persona selection - Integrate preferences panel into DigestSection (bell icon) - Add tests for per-recipient delivery and seed/legacy fallback - Update DIGEST_PREFERENCES.md documentation Two users with different personas on same connection get different digests. EXEC persona gets tight 3-bullet executive summary. Cron/config not singleton-only in the delivery path. Depends on: PR1 #103 (per-user digest prefs), PR2 #104 (role-aware assembler) GTM: Same Brain, different lens per role Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent efc7c32 commit 7bd3ed1

14 files changed

Lines changed: 2668 additions & 124 deletions

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

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

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

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

3136
/**
3237
* Get the current user's digest preferences.
@@ -181,4 +186,85 @@ public record UpdatePreferenceRequest(
181186
String cronExpression,
182187
String timezone
183188
) {}
189+
190+
// ─────────────────────────────────────────────────────────────────────────
191+
// Admin: Seed & Status endpoints
192+
// ─────────────────────────────────────────────────────────────────────────
193+
194+
/**
195+
* Preview what preferences would be seeded from singleton config.
196+
* Admin only.
197+
*/
198+
@GetMapping("/admin/seed/preview")
199+
@PreAuthorize("hasRole('ADMIN')")
200+
public ResponseEntity<SeedPreviewResponse> previewSeed() {
201+
DigestPreferenceSeedService.SeedResult result = seedService.previewSeed();
202+
return ResponseEntity.ok(new SeedPreviewResponse(
203+
result.usersProcessed(),
204+
result.preferencesCreated(),
205+
result.skipped(),
206+
result.preferences().stream()
207+
.map(p -> new PreferencePreview(p.getUsername(), p.getConnectionId(),
208+
p.getPersonaTag() != null ? p.getPersonaTag().name() : null))
209+
.toList()
210+
));
211+
}
212+
213+
/**
214+
* Seed preferences for all Slack-linked users from singleton config.
215+
* Admin only. Idempotent: skips users with existing preferences.
216+
*/
217+
@PostMapping("/admin/seed")
218+
@PreAuthorize("hasRole('ADMIN')")
219+
public ResponseEntity<SeedResultResponse> executeSeed() {
220+
DigestPreferenceSeedService.SeedResult result = seedService.executeSeed();
221+
log.info("Admin seeded {} digest preferences for {} users",
222+
result.preferencesCreated(), result.usersProcessed());
223+
return ResponseEntity.ok(new SeedResultResponse(
224+
result.usersProcessed(),
225+
result.preferencesCreated(),
226+
result.skipped()
227+
));
228+
}
229+
230+
/**
231+
* Seed preferences for the current user.
232+
* Available to any authenticated user.
233+
*/
234+
@PostMapping("/seed/me")
235+
public ResponseEntity<SeedResultResponse> seedForCurrentUser() {
236+
String username = accessControlService.requireCurrentUsername();
237+
DigestPreferenceSeedService.SeedResult result = seedService.seedPreferencesForUser(username, false);
238+
return ResponseEntity.ok(new SeedResultResponse(
239+
1,
240+
result.preferencesCreated(),
241+
result.skipped()
242+
));
243+
}
244+
245+
/**
246+
* Get current digest mode info.
247+
*/
248+
@GetMapping("/status")
249+
public ResponseEntity<DigestStatusResponse> getStatus() {
250+
SlackDailyDigestService.DigestModeInfo modeInfo = digestService.getDigestModeInfo();
251+
return ResponseEntity.ok(new DigestStatusResponse(
252+
modeInfo.perUserMode(),
253+
modeInfo.enabledPreferences(),
254+
modeInfo.distinctUsers()
255+
));
256+
}
257+
258+
public record SeedPreviewResponse(
259+
int usersProcessed,
260+
int wouldCreate,
261+
List<String> wouldSkip,
262+
List<PreferencePreview> preferences
263+
) {}
264+
265+
public record PreferencePreview(String username, String connectionId, String personaTag) {}
266+
267+
public record SeedResultResponse(int usersProcessed, int preferencesCreated, List<String> skipped) {}
268+
269+
public record DigestStatusResponse(boolean perUserMode, long enabledPreferences, int distinctUsers) {}
184270
}

backend/src/main/java/com/dbaagent/repository/SlackDigestLogRepository.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,10 @@ SELECT DISTINCT ON (recipient_username) *
9191
* Count personalized vs non-personalized digests since a date.
9292
*/
9393
long countByPersonalizedAndSentAtAfter(boolean personalized, LocalDateTime since);
94+
95+
/**
96+
* Find the most recent digest for a connection (any type).
97+
* Used for determining the window start for new digests.
98+
*/
99+
Optional<SlackDigestLog> findTopByConnectionIdOrderBySentAtDesc(String connectionId);
94100
}

backend/src/main/java/com/dbaagent/repository/SlackUserLinkRepository.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,26 @@
22

33
import com.dbaagent.model.SlackUserLink;
44
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Query;
6+
import org.springframework.data.repository.query.Param;
57

8+
import java.util.List;
69
import java.util.Optional;
710

811
public interface SlackUserLinkRepository extends JpaRepository<SlackUserLink, Long> {
912
Optional<SlackUserLink> findByTeamIdAndSlackUserId(String teamId, String slackUserId);
1013
Optional<SlackUserLink> findByTeamIdAndSlackUserIdAndLinkStatus(String teamId, String slackUserId, String linkStatus);
14+
15+
/**
16+
* Find all linked Slack accounts for a DeepSQL username.
17+
* Returns only LINKED entries (not PENDING or REVOKED).
18+
*/
19+
@Query("SELECT l FROM SlackUserLink l WHERE l.deepsqlUsername = :username AND l.linkStatus = 'LINKED'")
20+
List<SlackUserLink> findLinkedByDeepsqlUsername(@Param("username") String username);
21+
22+
/**
23+
* Find all linked users (for seeding digest preferences).
24+
*/
25+
@Query("SELECT DISTINCT l.deepsqlUsername FROM SlackUserLink l WHERE l.linkStatus = 'LINKED'")
26+
List<String> findAllLinkedDeepsqlUsernames();
1127
}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package com.dbaagent.service;
2+
3+
import com.dbaagent.model.DigestDeliveryMethod;
4+
import com.dbaagent.model.PersonaTag;
5+
import com.dbaagent.model.Role;
6+
import com.dbaagent.model.SlackDigestConfig;
7+
import com.dbaagent.model.User;
8+
import com.dbaagent.model.UserDigestPreference;
9+
import com.dbaagent.repository.SlackDigestConfigRepository;
10+
import com.dbaagent.repository.UserDigestPreferenceRepository;
11+
import com.dbaagent.repository.UserRepository;
12+
import com.dbaagent.service.security.ConnectionAccessService;
13+
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.stereotype.Service;
16+
import org.springframework.transaction.annotation.Transactional;
17+
18+
import java.util.ArrayList;
19+
import java.util.List;
20+
import java.util.Optional;
21+
22+
/**
23+
* Service for seeding digest preferences from singleton config.
24+
*
25+
* <p>Provides migration helpers so existing deployments (like Stayflexi) don't stay
26+
* on legacy broadcast forever after the first per-user preferences appear.
27+
*
28+
* <h3>Seed Strategy</h3>
29+
* <ol>
30+
* <li>Find all users with linked Slack accounts (candidates for DM delivery)</li>
31+
* <li>For each user, create a SLACK_DM preference for each connection they can access</li>
32+
* <li>Persona tag is inferred from the user's role (can be edited later in UI)</li>
33+
* <li>Cron expression defaults to the global singleton config</li>
34+
* </ol>
35+
*/
36+
@Service
37+
@RequiredArgsConstructor
38+
@Slf4j
39+
public class DigestPreferenceSeedService {
40+
41+
private final UserDigestPreferenceRepository preferenceRepository;
42+
private final SlackDigestConfigRepository configRepository;
43+
private final SlackUserLinkService slackUserLinkService;
44+
private final ConnectionAccessService connectionAccessService;
45+
private final UserRepository userRepository;
46+
47+
/**
48+
* Seed digest preferences for all Slack-linked users.
49+
*
50+
* <p>This creates one SLACK_DM preference per connection the user can access.
51+
* Existing preferences are not overwritten.
52+
*
53+
* @param dryRun if true, return what would be created without persisting
54+
* @return summary of seeded preferences
55+
*/
56+
@Transactional
57+
public SeedResult seedPreferencesFromSingleton(boolean dryRun) {
58+
List<String> linkedUsernames = slackUserLinkService.getAllLinkedUsernames();
59+
if (linkedUsernames.isEmpty()) {
60+
log.info("No Slack-linked users found; nothing to seed");
61+
return new SeedResult(0, 0, List.of(), List.of());
62+
}
63+
64+
String globalCron = getGlobalCronExpression();
65+
List<UserDigestPreference> created = new ArrayList<>();
66+
List<String> skipped = new ArrayList<>();
67+
68+
for (String username : linkedUsernames) {
69+
Optional<User> userOpt = userRepository.findByUsernameIgnoreCase(username);
70+
if (userOpt.isEmpty()) {
71+
skipped.add(username + ": user not found");
72+
continue;
73+
}
74+
75+
User user = userOpt.get();
76+
if (!user.isActive()) {
77+
skipped.add(username + ": user inactive");
78+
continue;
79+
}
80+
81+
List<String> connectionIds = connectionAccessService
82+
.getVisibleConnections(username, user.isAdmin())
83+
.stream()
84+
.map(conn -> conn.getId())
85+
.toList();
86+
87+
if (connectionIds.isEmpty()) {
88+
skipped.add(username + ": no accessible connections");
89+
continue;
90+
}
91+
92+
PersonaTag inferredPersona = inferPersonaFromRole(user.getRoleEnum());
93+
94+
for (String connectionId : connectionIds) {
95+
// Check if preference already exists
96+
Optional<UserDigestPreference> existing = preferenceRepository
97+
.findByUsernameAndConnectionIdAndDeliveryMethod(
98+
username, connectionId, DigestDeliveryMethod.SLACK_DM);
99+
100+
if (existing.isPresent()) {
101+
skipped.add(username + "/" + connectionId + ": preference exists");
102+
continue;
103+
}
104+
105+
UserDigestPreference pref = UserDigestPreference.builder()
106+
.username(username)
107+
.connectionId(connectionId)
108+
.enabled(true)
109+
.deliveryMethod(DigestDeliveryMethod.SLACK_DM)
110+
.personaTag(inferredPersona)
111+
.cronExpression(null) // Use global default
112+
.timezone(null) // Use system default
113+
.build();
114+
115+
if (!dryRun) {
116+
preferenceRepository.save(pref);
117+
}
118+
created.add(pref);
119+
}
120+
}
121+
122+
log.info("Digest preference seed: {} users, {} created, {} skipped (dryRun={})",
123+
linkedUsernames.size(), created.size(), skipped.size(), dryRun);
124+
125+
return new SeedResult(linkedUsernames.size(), created.size(), skipped, created);
126+
}
127+
128+
/**
129+
* Seed preferences for a single user.
130+
*/
131+
@Transactional
132+
public SeedResult seedPreferencesForUser(String username, boolean dryRun) {
133+
Optional<User> userOpt = userRepository.findByUsernameIgnoreCase(username);
134+
if (userOpt.isEmpty()) {
135+
return new SeedResult(0, 0, List.of(username + ": user not found"), List.of());
136+
}
137+
138+
User user = userOpt.get();
139+
if (!user.isActive()) {
140+
return new SeedResult(0, 0, List.of(username + ": user inactive"), List.of());
141+
}
142+
143+
// Check if user has Slack linked
144+
List<com.dbaagent.model.SlackUserLink> links = slackUserLinkService.getLinkedSlackAccounts(username);
145+
if (links.isEmpty()) {
146+
return new SeedResult(0, 0, List.of(username + ": not linked to Slack"), List.of());
147+
}
148+
149+
List<String> connectionIds = connectionAccessService
150+
.getVisibleConnections(username, user.isAdmin())
151+
.stream()
152+
.map(conn -> conn.getId())
153+
.toList();
154+
155+
if (connectionIds.isEmpty()) {
156+
return new SeedResult(0, 0, List.of(username + ": no accessible connections"), List.of());
157+
}
158+
159+
PersonaTag inferredPersona = inferPersonaFromRole(user.getRoleEnum());
160+
List<UserDigestPreference> created = new ArrayList<>();
161+
List<String> skipped = new ArrayList<>();
162+
163+
for (String connectionId : connectionIds) {
164+
Optional<UserDigestPreference> existing = preferenceRepository
165+
.findByUsernameAndConnectionIdAndDeliveryMethod(
166+
username, connectionId, DigestDeliveryMethod.SLACK_DM);
167+
168+
if (existing.isPresent()) {
169+
skipped.add(connectionId + ": preference exists");
170+
continue;
171+
}
172+
173+
UserDigestPreference pref = UserDigestPreference.builder()
174+
.username(username)
175+
.connectionId(connectionId)
176+
.enabled(true)
177+
.deliveryMethod(DigestDeliveryMethod.SLACK_DM)
178+
.personaTag(inferredPersona)
179+
.build();
180+
181+
if (!dryRun) {
182+
preferenceRepository.save(pref);
183+
}
184+
created.add(pref);
185+
}
186+
187+
log.info("Seeded {} preferences for user {} (dryRun={})", created.size(), username, dryRun);
188+
189+
return new SeedResult(1, created.size(), skipped, created);
190+
}
191+
192+
/**
193+
* Infer a persona tag from the user's role.
194+
*/
195+
private PersonaTag inferPersonaFromRole(Role role) {
196+
if (role == null) {
197+
return null; // No persona; use role-based prioritization only
198+
}
199+
return switch (role) {
200+
case ADMIN -> null; // Admins often wear multiple hats; let them pick
201+
case DBA -> PersonaTag.DBA;
202+
case DATA_ENGINEER -> PersonaTag.DATA_ENG;
203+
case DEVELOPER -> PersonaTag.APP_ENG;
204+
};
205+
}
206+
207+
private String getGlobalCronExpression() {
208+
return configRepository.findById(1L)
209+
.map(SlackDigestConfig::getCronExpression)
210+
.orElse("0 0 9 * * *");
211+
}
212+
213+
/**
214+
* Get seed preview: what would be created without actually seeding.
215+
*/
216+
public SeedResult previewSeed() {
217+
return seedPreferencesFromSingleton(true);
218+
}
219+
220+
/**
221+
* Execute seed: create preferences for all eligible users.
222+
*/
223+
public SeedResult executeSeed() {
224+
return seedPreferencesFromSingleton(false);
225+
}
226+
227+
public record SeedResult(
228+
int usersProcessed,
229+
int preferencesCreated,
230+
List<String> skipped,
231+
List<UserDigestPreference> preferences
232+
) {
233+
public boolean hasCreations() {
234+
return preferencesCreated > 0;
235+
}
236+
}
237+
}

0 commit comments

Comments
 (0)