Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -44,9 +49,23 @@ public ResponseEntity<List<UserDigestPreference>> getMyPreferences() {
public ResponseEntity<UserDigestPreference> 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)
Expand Down Expand Up @@ -159,10 +178,10 @@ public ResponseEntity<List<Map<String, String>>> getPersonaTags() {
*/
@GetMapping("/delivery-methods")
public ResponseEntity<List<Map<String, String>>> getDeliveryMethods() {
// Only advertise methods this PR actually delivers. EMAIL/WhatsApp are PR4.
List<Map<String, String>> 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);
}
Expand All @@ -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<SeedPreviewResponse> 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<SeedResultResponse> 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<SeedResultResponse> 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<DigestStatusResponse> 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<String> wouldSkip,
List<PreferencePreview> preferences
) {}

public record PreferencePreview(String username, String connectionId, String personaTag) {}

public record SeedResultResponse(int usersProcessed, int preferencesCreated, List<String> skipped) {}

public record DigestStatusResponse(boolean perUserMode, long enabledPreferences, int distinctUsers) {}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage() != null ? e.getMessage() : "Bad request"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SlackDigestLog> 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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<SlackUserLink, Long> {
Optional<SlackUserLink> findByTeamIdAndSlackUserId(String teamId, String slackUserId);
Optional<SlackUserLink> 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<SlackUserLink> 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<String> findAllLinkedDeepsqlUsernames();
}
Loading
Loading