Skip to content

Commit c6585bb

Browse files
feat(digest): per-recipient Slack delivery with UI prefs (PR3) (#105)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary **Same Brain, different lens per role.** This PR implements per-recipient Slack delivery for digests, consuming the `DigestAssemblyResult` API from PR2. Two users with different personas on the same connection now get different Slack digests for the same time window. ### Honesty-gap fix (per-user cron) UI schedule presets and `UserDigestPreference.cronExpression` (+ timezone) were stored but ignored: `SlackDailyDigestTaskConfig` only ran the global `slack.daily-digest.cron` and then delivered to every preference. Product Evangelist blocked marketing “your own schedule” until this was fixed. The scheduler now ticks every minute (`slack.daily-digest.tick-cron`, default `0 * * * * *`) via `processDigestTick()`: - **No enabled prefs** → legacy channel broadcast only when the global cron is due (UTC) - **Prefs exist** → each enabled `SLACK_DM` preference is evaluated with Spring `CronExpression` in that user’s timezone; already-logged fire windows are skipped (`SlackDigestLog` idempotency) - Blank preference cron still falls back to `slack.daily-digest.cron` - Manual/admin trigger still uses `sendDailyDigestHybrid()` (force all recipients) ## Changes ### Backend - **`SlackDailyDigestService`**: `sendPersonalizedDigests()` / `sendDailyDigestHybrid()` / **`processDigestTick()`** — per-recipient DMs, EXEC formatting, personalized logs, and per-user cron on the minute tick - **`DigestCronMatcher`**: Spring `CronExpression` due-window helper (timezone-aware) - **`DigestPreferenceSeedService`**: seed from singleton; persona inference; skips inactive users - **`SlackDailyDigestTaskConfig`**: minute tick → `processDigestTick()` - **`DigestPreferenceController`**: seed/status; refuses EMAIL ### Frontend - Digest preferences API + panel (persona, schedule presets, seed) + DigestSection bell icon ### Documentation - `docs/DIGEST_PREFERENCES.md` updated for tick vs global cron and accurate “your own schedule” ## Acceptance Criteria - [x] Two users, different roles/personas, same connection → different Slack digests same window - [x] Cron/config not singleton-only (**per-user cron honored on tick**) - [x] Tests for per-recipient delivery + seed/legacy + due-cron / timezone / idempotency - [x] UI prefs for role/schedule/persona - [x] Docs updated — “your own schedule” is accurate - [x] No fake EMAIL / WhatsApp delivery advertised ## Out of Scope (Follow-ups) 1. Idle-in-transaction insight when Brain has data 2. `join_collapse_limit` cliffs under CONFIG_TUNING 3. DOCUMENTATION_GAPS miner 4. COST_CAPACITY dedicated cost miner for EXEC 5. Hermes/WhatsApp = PR4 ## Dependencies - PR1 #103: per-user digest prefs - PR2 #104: role-aware insight assembler ## Testing ```bash cd backend ./mvnw test -Dtest=PerRecipientDigestDeliveryTest,PerRecipientDigestCronSchedulingTest,DigestCronMatcherTest,DigestPreferenceSeedServiceTest,SlackDailyDigestServiceTest ``` Key scenarios: different crons → only due user; timezone America/New_York 9AM = 13:00 UTC; idempotent window skip; EXEC 3 bullets; EMAIL skipped; seed idempotent; legacy when no enabled prefs. <!-- CURSOR_AGENT_PR_BODY_END --> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent efc7c32 commit c6585bb

21 files changed

Lines changed: 3472 additions & 134 deletions

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

Lines changed: 110 additions & 5 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.
@@ -44,9 +49,23 @@ public ResponseEntity<List<UserDigestPreference>> getMyPreferences() {
4449
public ResponseEntity<UserDigestPreference> createPreference(@RequestBody CreatePreferenceRequest request) {
4550
String username = accessControlService.requireCurrentUsername();
4651

47-
DigestDeliveryMethod method = request.deliveryMethod != null
48-
? DigestDeliveryMethod.fromString(request.deliveryMethod)
49-
: DigestDeliveryMethod.SLACK_DM;
52+
DigestDeliveryMethod method;
53+
if (request.deliveryMethod == null || request.deliveryMethod.isBlank()) {
54+
method = DigestDeliveryMethod.SLACK_DM;
55+
} else {
56+
method = DigestDeliveryMethod.fromString(request.deliveryMethod);
57+
if (method == null) {
58+
throw new IllegalArgumentException("Unknown delivery method: " + request.deliveryMethod);
59+
}
60+
}
61+
62+
if (method == DigestDeliveryMethod.EMAIL) {
63+
throw new IllegalArgumentException("Email digest delivery is not available yet");
64+
}
65+
66+
if (request.connectionId != null && !request.connectionId.isBlank()) {
67+
accessControlService.assertCanReadConnectionContent(request.connectionId);
68+
}
5069

5170
PersonaTag persona = request.personaTag != null
5271
? PersonaTag.fromString(request.personaTag)
@@ -159,10 +178,10 @@ public ResponseEntity<List<Map<String, String>>> getPersonaTags() {
159178
*/
160179
@GetMapping("/delivery-methods")
161180
public ResponseEntity<List<Map<String, String>>> getDeliveryMethods() {
181+
// Only advertise methods this PR actually delivers. EMAIL/WhatsApp are PR4.
162182
List<Map<String, String>> methods = List.of(
163183
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())
184+
Map.of("value", "SLACK_CHANNEL", "label", DigestDeliveryMethod.SLACK_CHANNEL.getDisplayName(), "description", DigestDeliveryMethod.SLACK_CHANNEL.getDescription())
166185
);
167186
return ResponseEntity.ok(methods);
168187
}
@@ -181,4 +200,90 @@ public record UpdatePreferenceRequest(
181200
String cronExpression,
182201
String timezone
183202
) {}
203+
204+
// ─────────────────────────────────────────────────────────────────────────
205+
// Admin: Seed & Status endpoints
206+
// ─────────────────────────────────────────────────────────────────────────
207+
208+
/**
209+
* Preview what preferences would be seeded from singleton config.
210+
* Admin only.
211+
*/
212+
@GetMapping("/admin/seed/preview")
213+
@PreAuthorize("hasRole('ADMIN')")
214+
public ResponseEntity<SeedPreviewResponse> previewSeed() {
215+
DigestPreferenceSeedService.SeedResult result = seedService.previewSeed();
216+
return ResponseEntity.ok(new SeedPreviewResponse(
217+
result.usersProcessed(),
218+
result.preferencesCreated(),
219+
result.skipped(),
220+
result.preferences().stream()
221+
.map(p -> new PreferencePreview(p.getUsername(), p.getConnectionId(),
222+
p.getPersonaTag() != null ? p.getPersonaTag().name() : null))
223+
.toList()
224+
));
225+
}
226+
227+
/**
228+
* Seed preferences for all Slack-linked users from singleton config.
229+
* Admin only. Idempotent: skips users with existing preferences.
230+
*/
231+
@PostMapping("/admin/seed")
232+
@PreAuthorize("hasRole('ADMIN')")
233+
public ResponseEntity<SeedResultResponse> executeSeed() {
234+
DigestPreferenceSeedService.SeedResult result = seedService.executeSeed();
235+
log.info("Admin seeded {} digest preferences for {} users",
236+
result.preferencesCreated(), result.usersProcessed());
237+
return ResponseEntity.ok(new SeedResultResponse(
238+
result.usersProcessed(),
239+
result.preferencesCreated(),
240+
result.skipped()
241+
));
242+
}
243+
244+
/**
245+
* Seed preferences for the current user.
246+
* Available to any authenticated user.
247+
*/
248+
@PostMapping("/seed/me")
249+
public ResponseEntity<SeedResultResponse> seedForCurrentUser() {
250+
String username = accessControlService.requireCurrentUsername();
251+
DigestPreferenceSeedService.SeedResult result = seedService.seedPreferencesForUser(username, false);
252+
return ResponseEntity.ok(new SeedResultResponse(
253+
1,
254+
result.preferencesCreated(),
255+
result.skipped()
256+
));
257+
}
258+
259+
/**
260+
* Get current digest mode info.
261+
*/
262+
@GetMapping("/status")
263+
public ResponseEntity<DigestStatusResponse> getStatus() {
264+
SlackDailyDigestService.DigestModeInfo modeInfo = digestService.getDigestModeInfo();
265+
return ResponseEntity.ok(new DigestStatusResponse(
266+
modeInfo.perUserMode(),
267+
modeInfo.enabledPreferences(),
268+
modeInfo.distinctUsers()
269+
));
270+
}
271+
272+
public record SeedPreviewResponse(
273+
int usersProcessed,
274+
int wouldCreate,
275+
List<String> wouldSkip,
276+
List<PreferencePreview> preferences
277+
) {}
278+
279+
public record PreferencePreview(String username, String connectionId, String personaTag) {}
280+
281+
public record SeedResultResponse(int usersProcessed, int preferencesCreated, List<String> skipped) {}
282+
283+
public record DigestStatusResponse(boolean perUserMode, long enabledPreferences, int distinctUsers) {}
284+
285+
@ExceptionHandler(IllegalArgumentException.class)
286+
public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException e) {
287+
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage() != null ? e.getMessage() : "Bad request"));
288+
}
184289
}

backend/src/main/java/com/dbaagent/model/UserDigestPreference.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public class UserDigestPreference {
103103

104104
/**
105105
* Optional timezone for schedule interpretation.
106-
* When null, uses system default (typically UTC).
106+
* When null or invalid, the digest tick evaluates the cron in UTC.
107107
* Format: IANA timezone ID (e.g., "America/New_York", "Europe/London").
108108
*/
109109
@Column(name = "timezone", length = 64)

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,29 @@ 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);
100+
101+
/**
102+
* Idempotency for per-preference scheduling: true if this preference already
103+
* produced a digest log for the connection at or after the cron fire time.
104+
*/
105+
boolean existsByPreferenceIdAndConnectionIdAndSentAtGreaterThanEqual(
106+
Long preferenceId,
107+
String connectionId,
108+
LocalDateTime sentAt
109+
);
110+
111+
/**
112+
* Fallback idempotency when preferenceId is missing on older rows.
113+
*/
114+
boolean existsByConnectionIdAndRecipientUsernameAndSentAtGreaterThanEqual(
115+
String connectionId,
116+
String recipientUsername,
117+
LocalDateTime sentAt
118+
);
94119
}

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
}

0 commit comments

Comments
 (0)