Real message content in notifications (+ content suppression) - #1741
Real message content in notifications (+ content suppression)#1741hryhorii-chuhuievets wants to merge 9 commits into
Conversation
📝 WalkthroughWalkthroughThe change adds tenant-wide notification content suppression. It persists suppression state, redacts notification descriptions in GraphQL and NATS payloads, exposes authorized GraphQL settings updates, and adds a reusable plain-text excerpt utility with character and UTF-8 byte limits. ChangesNotification content suppression
Plain text excerpt utility
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant GraphQLClient
participant NotificationSettingsDataFetcher
participant NotificationSettingsService
participant PolicyRepository
participant NotificationContentRedactor
GraphQLClient->>NotificationSettingsDataFetcher: updateNotificationContentSuppression(suppressed)
NotificationSettingsDataFetcher->>NotificationSettingsService: updateContentSuppression(userId, suppressed)
NotificationSettingsService->>PolicyRepository: setContentSuppressed(suppressed)
NotificationSettingsService->>NotificationContentRedactor: invalidate()
NotificationSettingsService-->>GraphQLClient: refreshed NotificationSettings
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@openframe-api-service-core/src/test/java/com/openframe/api/datafetcher/NotificationSettingsDataFetcherTest.java`:
- Around line 64-77: Enable Spring method security in the production
configuration and ensure NotificationSettingsDataFetcher is registered in the
test application context. Add a proxy-level test invoking
updateNotificationContentSuppression with a principal lacking ADMIN and OWNER
authorities, asserting access is denied while preserving the existing direct
unit tests.
In `@openframe-core/src/main/java/com/openframe/core/util/PlainTextExcerpt.java`:
- Line 15: Add a reference-image pattern before REFERENCE_LINK in
PlainTextExcerpt so inputs like ![disk chart][ref] are removed without leaving
the leading exclamation mark; preserve existing reference-link sanitization and
add a regression test covering this image input and expected plain-text result.
- Around line 64-70: Update PlainTextExcerpt.excerpt to return an empty string
for non-null input when maxChars is nonpositive, while preserving null handling.
In PlainTextExcerpt.excerptToBytes, return an empty string only when maxBytes is
less than the UTF-8 byte size of ELLIPSIS, and return ELLIPSIS when the limit
exactly matches its size.
In
`@openframe-data-mongo-sync/src/main/java/com/openframe/data/service/notification/NotificationContentRedactor.java`:
- Line 45: Update the suppression lookup in NotificationContentRedactor to
resolve the category once, return SUPPRESSED_DEFAULT when it is null, and only
then read SUPPRESSED_BY_CATEGORY with getOrDefault. Add a test covering both a
null method argument and null notification.getCategory(), verifying the default
suppression value.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 78c4c7fa-bc9e-41e5-9b53-fdc503976d98
📒 Files selected for processing (17)
openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/NotificationSettingsDataFetcher.javaopenframe-api-service-core/src/main/java/com/openframe/api/dto/NotificationSettingsView.javaopenframe-api-service-core/src/main/java/com/openframe/api/mapper/GraphQLNotificationMapper.javaopenframe-api-service-core/src/main/java/com/openframe/api/service/NotificationSettingsService.javaopenframe-api-service-core/src/main/resources/schema/notification-settings.graphqlsopenframe-api-service-core/src/test/java/com/openframe/api/datafetcher/NotificationSettingsDataFetcherTest.javaopenframe-api-service-core/src/test/java/com/openframe/api/service/NotificationSettingsServiceTest.javaopenframe-core/src/main/java/com/openframe/core/util/PlainTextExcerpt.javaopenframe-core/src/test/java/com/openframe/core/util/PlainTextExcerptTest.javaopenframe-data-mongo-common/src/main/java/com/openframe/data/document/notification/NotificationContentPolicy.javaopenframe-data-mongo-sync/src/main/java/com/openframe/data/repository/notification/CustomNotificationContentPolicyRepository.javaopenframe-data-mongo-sync/src/main/java/com/openframe/data/repository/notification/NotificationContentPolicyRepository.javaopenframe-data-mongo-sync/src/main/java/com/openframe/data/repository/notification/impl/CustomNotificationContentPolicyRepositoryImpl.javaopenframe-data-mongo-sync/src/main/java/com/openframe/data/service/notification/NotificationContentRedactor.javaopenframe-data-mongo-sync/src/test/java/com/openframe/data/service/notification/NotificationContentRedactorTest.javaopenframe-data-nats/src/main/java/com/openframe/data/nats/publisher/NotificationNatsPublisher.javaopenframe-data-nats/src/test/java/com/openframe/data/nats/publisher/NotificationNatsPublisherTest.java
| assertThatThrownBy(() -> fetcher.updateNotificationContentSuppression(true, agent)) | ||
| .isInstanceOf(UnauthorizedException.class); | ||
| verifyNoInteractions(service); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("updateNotificationContentSuppression delegates the tenant-wide flag") | ||
| void suppression_update_delegates() { | ||
| when(service.updateContentSuppression("user-1", true)) | ||
| .thenReturn(new NotificationSettingsView(true, List.of(), true)); | ||
|
|
||
| assertThat(fetcher.updateNotificationContentSuppression(true, principal("user-1")).isContentSuppressed()) | ||
| .isTrue(); | ||
| verify(service).updateContentSuppression("user-1", true); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
rg -n -C3 '`@EnableMethodSecurity`|`@EnableGlobalMethodSecurity`|MethodSecurity' \
--glob '*.java'
rg -n -C3 'updateNotificationContentSuppression|hasAnyAuthority' \
openframe-api-service-core --glob '*.java'Repository: flamingo-stack/openframe-oss-lib
Length of output: 170
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- candidate test ---'
cat -n openframe-api-service-core/src/test/java/com/openframe/api/datafetcher/NotificationSettingsDataFetcherTest.java
printf '%s\n' '--- fetcher and related security configuration ---'
rg -n -C5 'class NotificationSettingsDataFetcher|updateNotificationContentSuppression|PreAuthorize|EnableMethodSecurity|EnableGlobalMethodSecurity|MethodSecurity|hasAnyAuthority|ADMIN|OWNER' \
openframe-api-service-core --glob '*.java' || true
printf '%s\n' '--- project-wide security references ---'
rg -n -C3 'PreAuthorize|EnableMethodSecurity|EnableGlobalMethodSecurity|MethodSecurity|hasAnyAuthority|hasRole|SecurityFilterChain|DgsComponent' \
--glob '*.java' . || trueRepository: flamingo-stack/openframe-oss-lib
Length of output: 50389
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- target files ---'
git ls-files | rg 'NotificationSettingsDataFetcher|NotificationSettings|Security|security' | head -200
printf '%s\n' '--- exact symbols across tracked files ---'
rg -n -F 'NotificationSettingsDataFetcher' --glob '!**/build/**' --glob '!**/target/**' . || true
rg -n -F 'updateNotificationContentSuppression' --glob '!**/build/**' --glob '!**/target/**' . || true
rg -n '`@PreAuthorize`|`@EnableMethodSecurity`|`@EnableGlobalMethodSecurity`|`@Secured`|`@RolesAllowed`' \
--glob '*.java' --glob '*.kt' --glob '*.groovy' . || true
printf '%s\n' '--- relevant build dependencies ---'
rg -n -C2 'spring-security|method-security|spring-boot-starter-security|dgs' \
--glob 'pom.xml' --glob 'build.gradle' --glob 'build.gradle.kts' --glob '*.gradle' . || trueRepository: flamingo-stack/openframe-oss-lib
Length of output: 28755
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- fetcher ---'
cat -n openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/NotificationSettingsDataFetcher.java
printf '%s\n' '--- integration test application ---'
cat -n openframe-api-service-core/src/test/java/com/openframe/api/integration/support/GraphQlIntegrationTestApplication.java
printf '%s\n' '--- integration tests and security test helpers ---'
rg -l 'GraphQlIntegrationTestApplication|GraphQlTester|SecurityMockMvcRequestPostProcessors|`@WithMockUser`|`@SpringBootTest`|AuthPrincipal' \
openframe-api-service-core/src/test --glob '*.java' |
head -80
printf '%s\n' '--- authentication classes and security config ---'
cat -n openframe-security-core/src/main/java/com/openframe/security/authentication/AuthPrincipal.java
cat -n openframe-security-core/src/main/java/com/openframe/security/authentication/AuthPrincipalArgumentResolver.java
cat -n openframe-api-service-core/src/main/java/com/openframe/api/config/SecurityConfig.javaRepository: flamingo-stack/openframe-oss-lib
Length of output: 19104
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
fetcher = Path("openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/NotificationSettingsDataFetcher.java").read_text()
security = Path("openframe-api-service-core/src/main/java/com/openframe/api/config/SecurityConfig.java").read_text()
test_app = Path("openframe-api-service-core/src/test/java/com/openframe/api/integration/support/GraphQlIntegrationTestApplication.java").read_text()
print("fetcher_has_pre_authorize:",
'`@PreAuthorize`("hasAnyAuthority(\'ADMIN\', \'OWNER\')")' in fetcher)
print("production_security_enables_method_security:",
"`@EnableMethodSecurity`" in security or "`@EnableGlobalMethodSecurity`" in security)
print("test_app_enables_method_security:",
"`@EnableMethodSecurity`" in test_app)
print("test_app_imports_notification_settings_fetcher:",
"NotificationSettingsDataFetcher.class" in test_app)
PYRepository: flamingo-stack/openframe-oss-lib
Length of output: 346
Enable and test method security for NotificationSettingsDataFetcher.
updateNotificationContentSuppression has @PreAuthorize("hasAnyAuthority('ADMIN', 'OWNER')"), but the unit test calls the class directly. No production configuration enables method security, and the test application does not import NotificationSettingsDataFetcher. Enable method security in production and add a proxy-level test that denies a principal without ADMIN or OWNER.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@openframe-api-service-core/src/test/java/com/openframe/api/datafetcher/NotificationSettingsDataFetcherTest.java`
around lines 64 - 77, Enable Spring method security in the production
configuration and ensure NotificationSettingsDataFetcher is registered in the
test application context. Add a proxy-level test invoking
updateNotificationContentSuppression with a principal lacking ADMIN and OWNER
authorities, asserting access is denied while preserving the existing direct
unit tests.
| private static final Pattern MARKDOWN_IMAGE = Pattern.compile("!\\[([^\\]]*)]\\([^)]*\\)"); | ||
| private static final Pattern MARKDOWN_LINK = Pattern.compile("\\[([^\\]]*)]\\([^)]*\\)"); | ||
| private static final Pattern AUTOLINK = Pattern.compile("<((?:https?|mailto):[^>]+)>"); | ||
| private static final Pattern REFERENCE_LINK = Pattern.compile("\\[([^\\]]*)]\\[[^\\]]*]"); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Strip reference-style image markers.
REFERENCE_LINK matches ![disk chart][ref] after the leading !. The sanitized result becomes !disk chart.
Add a reference-image pattern before REFERENCE_LINK. Add a regression test for this input.
Proposed fix
+ private static final Pattern REFERENCE_IMAGE = Pattern.compile("!\\[([^\\]]*)]\\[[^\\]]*]");
private static final Pattern REFERENCE_LINK = Pattern.compile("\\[([^\\]]*)]\\[[^\\]]*]");
...
+ text = REFERENCE_IMAGE.matcher(text).replaceAll("$1");
text = REFERENCE_LINK.matcher(text).replaceAll("$1");Also applies to: 41-43
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@openframe-core/src/main/java/com/openframe/core/util/PlainTextExcerpt.java`
at line 15, Add a reference-image pattern before REFERENCE_LINK in
PlainTextExcerpt so inputs like ![disk chart][ref] are removed without leaving
the leading exclamation mark; preserve existing reference-link sanitization and
add a regression test covering this image input and expected plain-text result.
| public static String excerpt(String value, int maxChars) { | ||
| if (value == null || maxChars <= 0 || value.length() <= maxChars) { | ||
| return value; | ||
| } | ||
| int budget = maxChars - ELLIPSIS.length(); | ||
| if (budget <= 0) { | ||
| return ELLIPSIS; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Enforce nonpositive maximum values.
Both methods return the full input when the maximum is zero or negative. This exceeds the requested maximum. excerptToBytes also returns an empty string when maxBytes equals the UTF-8 size of ELLIPSIS, although the ellipsis fits.
openframe-core/src/main/java/com/openframe/core/util/PlainTextExcerpt.java#L64-L70: Return an empty string for non-null input whenmaxChars <= 0.openframe-core/src/main/java/com/openframe/core/util/PlainTextExcerpt.java#L85-L91: Return an empty string only whenmaxBytesis smaller than the ellipsis byte size. ReturnELLIPSISwhen it exactly fits.
Proposed fix
public static String excerpt(String value, int maxChars) {
- if (value == null || maxChars <= 0 || value.length() <= maxChars) {
+ if (value == null) {
return value;
}
+ if (maxChars <= 0) {
+ return "";
+ }
+ if (value.length() <= maxChars) {
+ return value;
+ } public static String excerptToBytes(String value, int maxBytes) {
- if (value == null || maxBytes <= 0 || utf8Length(value) <= maxBytes) {
+ if (value == null) {
return value;
}
+ if (maxBytes <= 0) {
+ return "";
+ }
+ if (utf8Length(value) <= maxBytes) {
+ return value;
+ }
...
- if (budget <= 0) {
+ if (budget < 0) {
return "";
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public static String excerpt(String value, int maxChars) { | |
| if (value == null || maxChars <= 0 || value.length() <= maxChars) { | |
| return value; | |
| } | |
| int budget = maxChars - ELLIPSIS.length(); | |
| if (budget <= 0) { | |
| return ELLIPSIS; | |
| public static String excerpt(String value, int maxChars) { | |
| if (value == null) { | |
| return value; | |
| } | |
| if (maxChars <= 0) { | |
| return ""; | |
| } | |
| if (value.length() <= maxChars) { | |
| return value; | |
| } | |
| int budget = maxChars - ELLIPSIS.length(); | |
| if (budget <= 0) { | |
| return ELLIPSIS; |
| public static String excerpt(String value, int maxChars) { | |
| if (value == null || maxChars <= 0 || value.length() <= maxChars) { | |
| return value; | |
| } | |
| int budget = maxChars - ELLIPSIS.length(); | |
| if (budget <= 0) { | |
| return ELLIPSIS; | |
| public static String excerptToBytes(String value, int maxBytes) { | |
| if (value == null) { | |
| return value; | |
| } | |
| if (maxBytes <= 0) { | |
| return ""; | |
| } | |
| if (utf8Length(value) <= maxBytes) { | |
| return value; | |
| } | |
| int budget = maxBytes - utf8Length(ELLIPSIS); | |
| if (budget < 0) { | |
| return ""; |
📍 Affects 1 file
openframe-core/src/main/java/com/openframe/core/util/PlainTextExcerpt.java#L64-L70(this comment)openframe-core/src/main/java/com/openframe/core/util/PlainTextExcerpt.java#L85-L91
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@openframe-core/src/main/java/com/openframe/core/util/PlainTextExcerpt.java`
around lines 64 - 70, Update PlainTextExcerpt.excerpt to return an empty string
for non-null input when maxChars is nonpositive, while preserving null handling.
In PlainTextExcerpt.excerptToBytes, return an empty string only when maxBytes is
less than the UTF-8 byte size of ELLIPSIS, and return ELLIPSIS when the limit
exactly matches its size.
| if (!contentSuppressed()) { | ||
| return notification.getDescription(); | ||
| } | ||
| return SUPPRESSED_BY_CATEGORY.getOrDefault(resolveCategory(notification, category), SUPPRESSED_DEFAULT); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Handle notifications with no category before reading the fallback map.
If both category values are null, resolveCategory returns null. Map.of(...) rejects null keys, so this call throws instead of returning SUPPRESSED_DEFAULT. This can fail GraphQL mapping and NATS publishing while suppression is enabled.
Add a null check before getOrDefault. Add a test where both the method argument and notification.getCategory() are null.
Proposed fix
- return SUPPRESSED_BY_CATEGORY.getOrDefault(resolveCategory(notification, category), SUPPRESSED_DEFAULT);
+ NotificationCategory resolvedCategory = resolveCategory(notification, category);
+ if (resolvedCategory == null) {
+ return SUPPRESSED_DEFAULT;
+ }
+ return SUPPRESSED_BY_CATEGORY.getOrDefault(resolvedCategory, SUPPRESSED_DEFAULT);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| return SUPPRESSED_BY_CATEGORY.getOrDefault(resolveCategory(notification, category), SUPPRESSED_DEFAULT); | |
| NotificationCategory resolvedCategory = resolveCategory(notification, category); | |
| if (resolvedCategory == null) { | |
| return SUPPRESSED_DEFAULT; | |
| } | |
| return SUPPRESSED_BY_CATEGORY.getOrDefault(resolvedCategory, SUPPRESSED_DEFAULT); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@openframe-data-mongo-sync/src/main/java/com/openframe/data/service/notification/NotificationContentRedactor.java`
at line 45, Update the suppression lookup in NotificationContentRedactor to
resolve the category once, return SUPPRESSED_DEFAULT when it is null, and only
then read SUPPRESSED_BY_CATEGORY with getOrDefault. Add a test covering both a
null method argument and null notification.getCategory(), verifying the default
suppression value.
| @Slf4j | ||
| public class NotificationContentRedactor { | ||
|
|
||
| private static final Map<NotificationCategory, String> SUPPRESSED_BY_CATEGORY = Map.of( |
There was a problem hiding this comment.
Isn't @semen-flamingo work in it ?
He did it in polymorphic way.
There was a problem hiding this comment.
pls at least SUPPRESSED_DESCRIPTION_BY_CATEGORY
|
|
||
| @DgsMutation | ||
| @PreAuthorize("hasAnyAuthority('ADMIN', 'OWNER')") | ||
| public NotificationSettingsView updateNotificationContentSuppression( |
There was a problem hiding this comment.
Is it only for the settings page or part of some other logic.
Does it make sense for @semen-flamingo to use this data as part of real time notification object?
| @Service | ||
| @RequiredArgsConstructor | ||
| @Slf4j | ||
| public class NotificationContentRedactor { |
There was a problem hiding this comment.
Should be part of notification lib.
There was a problem hiding this comment.
Whould be done in scope of next ticket https://app.clickup.com/t/9013925967/86ak05pm4
| @Service | ||
| @RequiredArgsConstructor | ||
| @Slf4j | ||
| public class NotificationBroadcaster { |
There was a problem hiding this comment.
Notification lib please.
There was a problem hiding this comment.
Whould be done in scope of next ticket https://app.clickup.com/t/9013925967/86ak05pm4
| @Builder | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| public class NotificationContentPolicy implements TenantScoped { |
There was a problem hiding this comment.
can it bu just a part of notification settings?
| text = THEMATIC_BREAK.matcher(text).replaceAll(" "); | ||
| text = SETEXT_UNDERLINE.matcher(text).replaceAll(" "); | ||
| text = AUTOLINK.matcher(text).replaceAll("$1"); | ||
| text = HTML_TAG.matcher(text).replaceAll(" "); |
There was a problem hiding this comment.
I dont think we have htm here at all. Pls review and throw away whats not needed
| import java.nio.charset.StandardCharsets; | ||
| import java.util.regex.Pattern; | ||
|
|
||
| public final class PlainTextExcerpt { |
There was a problem hiding this comment.
Pls rename the class )
| * | ||
| * <p>Fail-open: a lookup that breaks must not blank out every notification in the tenant. | ||
| */ | ||
| public boolean contentSuppressed() { |
There was a problem hiding this comment.
this method should not be a part of redactor imho
| } | ||
| String topic = format(MACHINE_TOPIC_TEMPLATE, machineId); | ||
| publish(topic, notification, category, eventType); | ||
| publish(topic, notification, category, eventType, contentSuppressed); |
There was a problem hiding this comment.
should we suppress any content to machine channel?
| } | ||
|
|
||
| @DgsMutation | ||
| @PreAuthorize("hasAnyAuthority('ADMIN', 'OWNER')") |
There was a problem hiding this comment.
What is this for? Pls check common practice and remove if not needed. Im not sure but it may not even work r n
Summary by CodeRabbit
New Features
Bug Fixes
Tests