Skip to content

Commit 800d213

Browse files
feat: add per-user digest preferences with role-aware personalization
PR1 for role-aware digests theme. This change migrates from singleton SlackDigestConfig to per-user UserDigestPreference with: Data Model: - PersonaTag enum: DBA, APP_ENG, DATA_ENG, EXEC for content prioritization - DigestDeliveryMethod enum: SLACK_DM, SLACK_CHANNEL, EMAIL - UserDigestPreference entity with per-user/per-connection preferences - Extended SlackDigestLog with recipient tracking fields Services: - UserDigestPreferenceService for CRUD and preference resolution - Minimal hooks in SlackDailyDigestService (isPerUserModeEnabled, getDigestRecipients) API: - /digest/preferences/* for user self-service management - /admin/slack/digest/preferences/* for admin management - Backward compatible: singleton still works when no prefs exist Migration: - V120 creates user_digest_preference table - Extends slack_digest_log with recipient/persona/delivery fields - Adds is_global_default to slack_digest_config Tests: - PersonaTagTest, DigestDeliveryMethodTest for enum parsing - UserDigestPreferenceTest for entity behavior - UserDigestPreferenceServiceTest for service logic Docs: - docs/DIGEST_PREFERENCES.md with API usage examples Full role-aware ranking/delivery is PR2 (this is data model + migration only). Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent 819c286 commit 800d213

19 files changed

Lines changed: 1934 additions & 3 deletions
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package com.dbaagent.controller;
2+
3+
import com.dbaagent.model.DigestDeliveryMethod;
4+
import com.dbaagent.model.PersonaTag;
5+
import com.dbaagent.model.UserDigestPreference;
6+
import com.dbaagent.service.security.AccessControlService;
7+
import com.dbaagent.service.UserDigestPreferenceService;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.http.ResponseEntity;
11+
import org.springframework.web.bind.annotation.*;
12+
13+
import java.util.List;
14+
import java.util.Map;
15+
16+
/**
17+
* REST controller for managing per-user digest preferences.
18+
*
19+
* <p>Users can manage their own digest subscriptions. Admins can manage any user's
20+
* preferences via the admin endpoints in {@link SlackAdminController}.
21+
*/
22+
@RestController
23+
@RequestMapping("/digest/preferences")
24+
@RequiredArgsConstructor
25+
@Slf4j
26+
public class DigestPreferenceController {
27+
28+
private final UserDigestPreferenceService preferenceService;
29+
private final AccessControlService accessControlService;
30+
31+
/**
32+
* Get the current user's digest preferences.
33+
*/
34+
@GetMapping
35+
public ResponseEntity<List<UserDigestPreference>> getMyPreferences() {
36+
String username = accessControlService.requireCurrentUsername();
37+
return ResponseEntity.ok(preferenceService.getPreferencesForUser(username));
38+
}
39+
40+
/**
41+
* Create a new digest preference for the current user.
42+
*/
43+
@PostMapping
44+
public ResponseEntity<UserDigestPreference> createPreference(@RequestBody CreatePreferenceRequest request) {
45+
String username = accessControlService.requireCurrentUsername();
46+
47+
DigestDeliveryMethod method = request.deliveryMethod != null
48+
? DigestDeliveryMethod.fromString(request.deliveryMethod)
49+
: DigestDeliveryMethod.SLACK_DM;
50+
51+
PersonaTag persona = request.personaTag != null
52+
? PersonaTag.fromString(request.personaTag)
53+
: null;
54+
55+
UserDigestPreference preference = preferenceService.createPreference(
56+
username,
57+
request.connectionId,
58+
method,
59+
persona,
60+
request.cronExpression,
61+
request.timezone
62+
);
63+
64+
return ResponseEntity.ok(preference);
65+
}
66+
67+
/**
68+
* Update an existing preference.
69+
*/
70+
@PutMapping("/{id}")
71+
public ResponseEntity<UserDigestPreference> updatePreference(
72+
@PathVariable Long id,
73+
@RequestBody UpdatePreferenceRequest request) {
74+
75+
String username = accessControlService.requireCurrentUsername();
76+
77+
UserDigestPreference existing = preferenceService.getPreference(id)
78+
.orElseThrow(() -> new IllegalArgumentException("Preference not found: " + id));
79+
80+
if (!existing.getUsername().equals(username) && !accessControlService.isCurrentUserAdmin()) {
81+
throw new IllegalArgumentException("Cannot update another user's preference");
82+
}
83+
84+
PersonaTag persona = request.personaTag != null
85+
? PersonaTag.fromString(request.personaTag)
86+
: null;
87+
88+
UserDigestPreference updated = preferenceService.updatePreference(
89+
id,
90+
request.enabled,
91+
persona,
92+
request.cronExpression,
93+
request.timezone
94+
);
95+
96+
return ResponseEntity.ok(updated);
97+
}
98+
99+
/**
100+
* Enable or disable a preference.
101+
*/
102+
@PatchMapping("/{id}/enabled")
103+
public ResponseEntity<UserDigestPreference> setEnabled(
104+
@PathVariable Long id,
105+
@RequestBody Map<String, Boolean> body) {
106+
107+
String username = accessControlService.requireCurrentUsername();
108+
109+
UserDigestPreference existing = preferenceService.getPreference(id)
110+
.orElseThrow(() -> new IllegalArgumentException("Preference not found: " + id));
111+
112+
if (!existing.getUsername().equals(username) && !accessControlService.isCurrentUserAdmin()) {
113+
throw new IllegalArgumentException("Cannot update another user's preference");
114+
}
115+
116+
Boolean enabled = body.get("enabled");
117+
if (enabled == null) {
118+
throw new IllegalArgumentException("Missing 'enabled' field");
119+
}
120+
121+
UserDigestPreference updated = preferenceService.setEnabled(id, enabled);
122+
return ResponseEntity.ok(updated);
123+
}
124+
125+
/**
126+
* Delete a preference.
127+
*/
128+
@DeleteMapping("/{id}")
129+
public ResponseEntity<Void> deletePreference(@PathVariable Long id) {
130+
String username = accessControlService.requireCurrentUsername();
131+
132+
UserDigestPreference existing = preferenceService.getPreference(id)
133+
.orElseThrow(() -> new IllegalArgumentException("Preference not found: " + id));
134+
135+
if (!existing.getUsername().equals(username) && !accessControlService.isCurrentUserAdmin()) {
136+
throw new IllegalArgumentException("Cannot delete another user's preference");
137+
}
138+
139+
preferenceService.deletePreference(id);
140+
return ResponseEntity.noContent().build();
141+
}
142+
143+
/**
144+
* Get available persona tags.
145+
*/
146+
@GetMapping("/persona-tags")
147+
public ResponseEntity<List<Map<String, String>>> getPersonaTags() {
148+
List<Map<String, String>> tags = List.of(
149+
Map.of("value", "DBA", "label", PersonaTag.DBA.getDisplayName(), "description", PersonaTag.DBA.getDescription()),
150+
Map.of("value", "APP_ENG", "label", PersonaTag.APP_ENG.getDisplayName(), "description", PersonaTag.APP_ENG.getDescription()),
151+
Map.of("value", "DATA_ENG", "label", PersonaTag.DATA_ENG.getDisplayName(), "description", PersonaTag.DATA_ENG.getDescription()),
152+
Map.of("value", "EXEC", "label", PersonaTag.EXEC.getDisplayName(), "description", PersonaTag.EXEC.getDescription())
153+
);
154+
return ResponseEntity.ok(tags);
155+
}
156+
157+
/**
158+
* Get available delivery methods.
159+
*/
160+
@GetMapping("/delivery-methods")
161+
public ResponseEntity<List<Map<String, String>>> getDeliveryMethods() {
162+
List<Map<String, String>> methods = List.of(
163+
Map.of("value", "SLACK_DM", "label", DigestDeliveryMethod.SLACK_DM.getDisplayName(), "description", DigestDeliveryMethod.SLACK_DM.getDescription()),
164+
Map.of("value", "SLACK_CHANNEL", "label", DigestDeliveryMethod.SLACK_CHANNEL.getDisplayName(), "description", DigestDeliveryMethod.SLACK_CHANNEL.getDescription()),
165+
Map.of("value", "EMAIL", "label", DigestDeliveryMethod.EMAIL.getDisplayName(), "description", DigestDeliveryMethod.EMAIL.getDescription())
166+
);
167+
return ResponseEntity.ok(methods);
168+
}
169+
170+
public record CreatePreferenceRequest(
171+
String connectionId,
172+
String deliveryMethod,
173+
String personaTag,
174+
String cronExpression,
175+
String timezone
176+
) {}
177+
178+
public record UpdatePreferenceRequest(
179+
Boolean enabled,
180+
String personaTag,
181+
String cronExpression,
182+
String timezone
183+
) {}
184+
}

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

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package com.dbaagent.controller;
22

3+
import com.dbaagent.model.DigestDeliveryMethod;
4+
import com.dbaagent.model.PersonaTag;
35
import com.dbaagent.model.SlackDigestConfig;
46
import com.dbaagent.model.SlackDigestLog;
7+
import com.dbaagent.model.UserDigestPreference;
58
import com.dbaagent.repository.SlackDigestConfigRepository;
69
import com.dbaagent.repository.SlackDigestLogRepository;
710
import com.dbaagent.service.SlackBotService;
811
import com.dbaagent.service.SlackDailyDigestService;
12+
import com.dbaagent.service.UserDigestPreferenceService;
913
import lombok.RequiredArgsConstructor;
1014
import org.springframework.data.domain.Page;
1115
import org.springframework.data.domain.PageRequest;
@@ -14,8 +18,14 @@
1418
import org.springframework.web.bind.annotation.*;
1519

1620
import java.time.LocalDateTime;
21+
import java.util.List;
1722
import java.util.Map;
1823

24+
/**
25+
* Admin endpoints for Slack bot and digest management.
26+
*
27+
* <p>Includes both global digest configuration and per-user preference management.
28+
*/
1929
@RestController
2030
@RequestMapping("/admin/slack")
2131
@RequiredArgsConstructor
@@ -26,6 +36,7 @@ public class SlackAdminController {
2636
private final SlackDailyDigestService slackDailyDigestService;
2737
private final SlackDigestLogRepository digestLogRepository;
2838
private final SlackDigestConfigRepository digestConfigRepository;
39+
private final UserDigestPreferenceService preferenceService;
2940

3041
@GetMapping("/status")
3142
public ResponseEntity<Map<String, Object>> status() {
@@ -77,4 +88,123 @@ public ResponseEntity<SlackDigestConfig> updateConfig(@RequestBody Map<String, S
7788
config.setUpdatedAt(LocalDateTime.now());
7889
return ResponseEntity.ok(digestConfigRepository.save(config));
7990
}
91+
92+
// ==================== PER-USER PREFERENCE MANAGEMENT ====================
93+
94+
/**
95+
* Get digest preferences for a specific user.
96+
*/
97+
@GetMapping("/digest/preferences/{username}")
98+
public ResponseEntity<List<UserDigestPreference>> getUserPreferences(@PathVariable String username) {
99+
return ResponseEntity.ok(preferenceService.getPreferencesForUser(username));
100+
}
101+
102+
/**
103+
* Get all users with enabled digest preferences.
104+
*/
105+
@GetMapping("/digest/preferences/users")
106+
public ResponseEntity<List<String>> getUsersWithPreferences() {
107+
return ResponseEntity.ok(preferenceService.getUsersWithEnabledPreferences());
108+
}
109+
110+
/**
111+
* Get digest preference statistics.
112+
*/
113+
@GetMapping("/digest/preferences/stats")
114+
public ResponseEntity<Map<String, Object>> getPreferenceStats() {
115+
boolean hasPreferences = preferenceService.hasAnyPreferences();
116+
long enabledCount = preferenceService.countEnabled();
117+
List<String> users = preferenceService.getUsersWithEnabledPreferences();
118+
119+
return ResponseEntity.ok(Map.of(
120+
"hasAnyPreferences", hasPreferences,
121+
"enabledCount", enabledCount,
122+
"usersWithPreferences", users.size(),
123+
"mode", hasPreferences ? "per-user" : "singleton"
124+
));
125+
}
126+
127+
/**
128+
* Create a digest preference for a user (admin can create for any user).
129+
*/
130+
@PostMapping("/digest/preferences/{username}")
131+
public ResponseEntity<UserDigestPreference> createUserPreference(
132+
@PathVariable String username,
133+
@RequestBody CreatePreferenceRequest request) {
134+
135+
DigestDeliveryMethod method = request.deliveryMethod != null
136+
? DigestDeliveryMethod.fromString(request.deliveryMethod)
137+
: DigestDeliveryMethod.SLACK_DM;
138+
139+
PersonaTag persona = request.personaTag != null
140+
? PersonaTag.fromString(request.personaTag)
141+
: null;
142+
143+
UserDigestPreference preference = preferenceService.createPreference(
144+
username,
145+
request.connectionId,
146+
method,
147+
persona,
148+
request.cronExpression,
149+
request.timezone
150+
);
151+
152+
return ResponseEntity.ok(preference);
153+
}
154+
155+
/**
156+
* Update a user's digest preference.
157+
*/
158+
@PutMapping("/digest/preferences/id/{id}")
159+
public ResponseEntity<UserDigestPreference> updateUserPreference(
160+
@PathVariable Long id,
161+
@RequestBody UpdatePreferenceRequest request) {
162+
163+
PersonaTag persona = request.personaTag != null
164+
? PersonaTag.fromString(request.personaTag)
165+
: null;
166+
167+
UserDigestPreference updated = preferenceService.updatePreference(
168+
id,
169+
request.enabled,
170+
persona,
171+
request.cronExpression,
172+
request.timezone
173+
);
174+
175+
return ResponseEntity.ok(updated);
176+
}
177+
178+
/**
179+
* Delete a user's digest preference.
180+
*/
181+
@DeleteMapping("/digest/preferences/id/{id}")
182+
public ResponseEntity<Void> deleteUserPreference(@PathVariable Long id) {
183+
preferenceService.deletePreference(id);
184+
return ResponseEntity.noContent().build();
185+
}
186+
187+
/**
188+
* Delete all digest preferences for a user.
189+
*/
190+
@DeleteMapping("/digest/preferences/{username}")
191+
public ResponseEntity<Void> deleteAllUserPreferences(@PathVariable String username) {
192+
preferenceService.deleteAllPreferencesForUser(username);
193+
return ResponseEntity.noContent().build();
194+
}
195+
196+
public record CreatePreferenceRequest(
197+
String connectionId,
198+
String deliveryMethod,
199+
String personaTag,
200+
String cronExpression,
201+
String timezone
202+
) {}
203+
204+
public record UpdatePreferenceRequest(
205+
Boolean enabled,
206+
String personaTag,
207+
String cronExpression,
208+
String timezone
209+
) {}
80210
}

0 commit comments

Comments
 (0)