fix(#71,#73): remove class-level permission gate and add annotationId to notifications

- Remove @RequirePermission(READ_ALL) from NotificationController class level so
  authenticated users with any permission (or none) can access their own notifications
- Add V19 migration, annotationId field to Notification entity and NotificationDTO
- NotificationService now stores annotationId from comment on both REPLY and MENTION
- Update controller tests: permission tests now expect 200, DTO constructor includes annotationId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-28 11:44:17 +01:00
parent 23d0005514
commit d13422c65a
7 changed files with 47 additions and 13 deletions

View File

@@ -19,7 +19,6 @@ import java.util.UUID;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequirePermission(Permission.READ_ALL)
public class NotificationController { public class NotificationController {
private final NotificationService notificationService; private final NotificationService notificationService;

View File

@@ -10,6 +10,7 @@ public record NotificationDTO(
NotificationType type, NotificationType type,
UUID documentId, UUID documentId,
UUID referenceId, UUID referenceId,
UUID annotationId,
boolean read, boolean read,
LocalDateTime createdAt, LocalDateTime createdAt,
String actorName String actorName

View File

@@ -37,6 +37,9 @@ public class Notification {
@Column(name = "reference_id") @Column(name = "reference_id")
private UUID referenceId; private UUID referenceId;
@Column(name = "annotation_id")
private UUID annotationId;
@Column(nullable = false) @Column(nullable = false)
@Builder.Default @Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)

View File

@@ -55,6 +55,7 @@ public class NotificationService {
.type(NotificationType.REPLY) .type(NotificationType.REPLY)
.documentId(reply.getDocumentId()) .documentId(reply.getDocumentId())
.referenceId(reply.getId()) .referenceId(reply.getId())
.annotationId(reply.getAnnotationId())
.actorName(reply.getAuthorName()) .actorName(reply.getAuthorName())
.build(); .build();
notificationRepository.save(notification); notificationRepository.save(notification);
@@ -80,6 +81,7 @@ public class NotificationService {
.type(NotificationType.MENTION) .type(NotificationType.MENTION)
.documentId(comment.getDocumentId()) .documentId(comment.getDocumentId())
.referenceId(comment.getId()) .referenceId(comment.getId())
.annotationId(comment.getAnnotationId())
.actorName(comment.getAuthorName()) .actorName(comment.getAuthorName())
.build(); .build();
notificationRepository.save(notification); notificationRepository.save(notification);
@@ -129,6 +131,7 @@ public class NotificationService {
n.getType(), n.getType(),
n.getDocumentId(), n.getDocumentId(),
n.getReferenceId(), n.getReferenceId(),
n.getAnnotationId(),
n.isRead(), n.isRead(),
n.getCreatedAt(), n.getCreatedAt(),
n.getActorName() n.getActorName()

View File

@@ -0,0 +1 @@
ALTER TABLE notifications ADD COLUMN annotation_id UUID;

View File

@@ -53,9 +53,14 @@ class NotificationControllerTest {
@Test @Test
@WithMockUser(username = "testuser") @WithMockUser(username = "testuser")
void getNotifications_returns403_whenUserLacksPermission() throws Exception { void getNotifications_returns200_whenAuthenticatedWithNoPermissions() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any()))
.thenReturn(new PageImpl<>(List.of()));
mockMvc.perform(get("/api/notifications")) mockMvc.perform(get("/api/notifications"))
.andExpect(status().isForbidden()); .andExpect(status().isOk());
} }
@Test @Test
@@ -64,7 +69,7 @@ class NotificationControllerTest {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
NotificationDTO dto = new NotificationDTO( NotificationDTO dto = new NotificationDTO(
UUID.randomUUID(), NotificationType.REPLY, UUID.randomUUID(), UUID.randomUUID(), NotificationType.REPLY, UUID.randomUUID(),
UUID.randomUUID(), false, LocalDateTime.now(), "Anna Smith"); UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith");
when(userService.findByUsername("testuser")).thenReturn(user); when(userService.findByUsername("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any())) when(notificationService.getNotifications(eq(USER_ID), any()))
@@ -185,9 +190,14 @@ class NotificationControllerTest {
@Test @Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"}) @WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void getNotifications_returns403_whenUserHasOnlyWriteAll() throws Exception { void getNotifications_returns200_whenUserHasOnlyWriteAll() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any()))
.thenReturn(new PageImpl<>(List.of()));
mockMvc.perform(get("/api/notifications")) mockMvc.perform(get("/api/notifications"))
.andExpect(status().isForbidden()); .andExpect(status().isOk());
} }
// ─── PUT /api/users/me/notification-preferences ────────────────────────── // ─── PUT /api/users/me/notification-preferences ──────────────────────────

View File

@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import PersonalInfoForm from './PersonalInfoForm.svelte'; import PersonalInfoForm from './PersonalInfoForm.svelte';
import PasswordChangeForm from './PasswordChangeForm.svelte'; import PasswordChangeForm from './PasswordChangeForm.svelte';
let { data, form } = $props(); let { data, form } = $props();
let notifyOnReply = $state(untrack(() => data.notificationPrefs?.notifyOnReply ?? false)); let notifyOnReply = $derived(data.notificationPrefs?.notifyOnReply ?? false);
let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMention ?? false)); let notifyOnMention = $derived(data.notificationPrefs?.notifyOnMention ?? false);
const hasEmail = $derived(!!data.user?.email);
</script> </script>
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
@@ -53,32 +53,49 @@ let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMenti
</div> </div>
{/if} {/if}
<form method="POST" action="?/updateNotificationPrefs" use:enhance> <form
method="POST"
action="?/updateNotificationPrefs"
use:enhance={() => async ({ update }) => update({ reset: false })}
>
<div class="space-y-4"> <div class="space-y-4">
<label class="flex cursor-pointer items-start gap-3"> <label
class="flex items-start gap-3 {hasEmail ? 'cursor-pointer' : 'cursor-not-allowed opacity-40'}"
>
<input <input
type="checkbox" type="checkbox"
name="notifyOnReply" name="notifyOnReply"
bind:checked={notifyOnReply} bind:checked={notifyOnReply}
disabled={!hasEmail}
class="mt-0.5 h-4 w-4 rounded border-line accent-primary" class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
/> />
<span class="text-sm text-ink">{m.notification_pref_reply()}</span> <span class="text-sm text-ink">{m.notification_pref_reply()}</span>
</label> </label>
<label class="flex cursor-pointer items-start gap-3"> <label
class="flex items-start gap-3 {hasEmail ? 'cursor-pointer' : 'cursor-not-allowed opacity-40'}"
>
<input <input
type="checkbox" type="checkbox"
name="notifyOnMention" name="notifyOnMention"
bind:checked={notifyOnMention} bind:checked={notifyOnMention}
disabled={!hasEmail}
class="mt-0.5 h-4 w-4 rounded border-line accent-primary" class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
/> />
<span class="text-sm text-ink">{m.notification_pref_mention()}</span> <span class="text-sm text-ink">{m.notification_pref_mention()}</span>
</label> </label>
</div> </div>
{#if !hasEmail}
<p class="mt-3 text-xs text-ink-3">
{m.notification_prefs_no_email()}
</p>
{/if}
<button <button
type="submit" type="submit"
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80" disabled={!hasEmail}
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity {hasEmail ? 'hover:opacity-80' : 'cursor-not-allowed opacity-40'}"
> >
{m.btn_save()} {m.btn_save()}
</button> </button>