fix(chronik): surface REPLY events in Für-dich feed via youParticipated #297

Merged
marcel merged 9 commits from feat/issue-295-you-participated into main 2026-04-21 09:00:25 +02:00
10 changed files with 251 additions and 26 deletions

View File

@@ -12,6 +12,7 @@ public interface ActivityFeedRow {
UUID getDocumentId();
Instant getHappenedAt();
boolean isYouMentioned();
boolean isYouParticipated();
int getCount();
Instant getHappenedAtUntil();
}

View File

@@ -70,7 +70,9 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
CASE WHEN COUNT(*) > 1 THEN MAX(s.happened_at) ELSE NULL END AS happened_at_until,
COUNT(*)::int AS count,
BOOL_OR(s.kind = 'MENTION_CREATED'
AND s.payload->>'mentionedUserId' = :currentUserId) AS you_mentioned
AND s.payload->>'mentionedUserId' = :currentUserId) AS you_mentioned,
-- COMMENT_ADDED/MENTION_CREATED always have is_new_session=1, so each group has one row and MIN collapses to that row payload
MIN(s.payload::text)::jsonb AS payload
FROM sessions s
GROUP BY s.kind, s.actor_id, s.document_id, s.session_id
)
@@ -89,6 +91,13 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
ag.document_id AS documentId,
ag.happened_at AS happened_at,
ag.you_mentioned AS youMentioned,
-- payload->>'commentId' matches notifications.reference_id per AuditKind.COMMENT_ADDED contract
EXISTS(
SELECT 1 FROM notifications n
WHERE n.type = 'REPLY'
AND n.recipient_id = CAST(:currentUserId AS uuid)
AND n.reference_id = (ag.payload->>'commentId')::uuid
) AS youParticipated,
ag.count AS count,
ag.happened_at_until AS happenedAtUntil
FROM aggregated ag

View File

@@ -15,6 +15,7 @@ public record ActivityFeedItemDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String documentTitle,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) OffsetDateTime happenedAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youParticipated,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count,
@Nullable OffsetDateTime happenedAtUntil
) {}

View File

@@ -140,6 +140,7 @@ public class DashboardService {
docTitle,
row.getHappenedAt().atOffset(ZoneOffset.UTC),
row.isYouMentioned(),
row.isYouParticipated(),
row.getCount(),
happenedAtUntil
);

View File

@@ -14,9 +14,13 @@ import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -28,9 +32,12 @@ import static org.assertj.core.api.Assertions.assertThat;
class AuditLogQueryRepositoryRolledUpTest {
static final UUID USER_ID = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
static final UUID OTHER_USER_ID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
static final UUID DOC_ID = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
static final UUID OTHER_DOC_ID = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
private static final ObjectMapper MAPPER = new ObjectMapper();
@Autowired AuditLogQueryRepository auditLogQueryRepository;
@Autowired JdbcTemplate jdbcTemplate;
@@ -42,6 +49,9 @@ class AuditLogQueryRepositoryRolledUpTest {
jdbcTemplate.update(
"INSERT INTO users (id, enabled, email, password) VALUES (?, true, ?, 'pw')",
USER_ID, "rollup-" + USER_ID + "@test.com");
jdbcTemplate.update(
"INSERT INTO users (id, enabled, email, password) VALUES (?, true, ?, 'pw')",
OTHER_USER_ID, "rollup-" + OTHER_USER_ID + "@test.com");
jdbcTemplate.update(
"INSERT INTO documents (id, title, original_filename, status) VALUES (?, 'Brief A', 'a.pdf', 'PLACEHOLDER')",
DOC_ID);
@@ -51,17 +61,34 @@ class AuditLogQueryRepositoryRolledUpTest {
}
private void insertAuditEvent(UUID actorId, UUID docId, String kind, Instant happenedAt) {
insertAuditEvent(actorId, docId, kind, happenedAt, Map.of());
}
private void insertAuditEvent(UUID actorId, UUID docId, String kind, Instant happenedAt, Map<String, String> payload) {
String payloadJson;
try {
payloadJson = payload.isEmpty() ? null : MAPPER.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("kind", kind)
.addValue("actor", actorId)
.addValue("doc", docId)
.addValue("t", OffsetDateTime.ofInstant(happenedAt, java.time.ZoneOffset.UTC));
.addValue("t", OffsetDateTime.ofInstant(happenedAt, java.time.ZoneOffset.UTC))
.addValue("payload", payloadJson, java.sql.Types.OTHER);
named().update(
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) "
+ "VALUES (:kind, :actor, :doc, :t)",
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at, payload) "
+ "VALUES (:kind, :actor, :doc, :t, :payload::jsonb)",
params);
}
private void insertReplyNotification(UUID recipientId, UUID docId, UUID commentId) {
jdbcTemplate.update(
"INSERT INTO notifications (recipient_id, type, document_id, reference_id) VALUES (?, 'REPLY', ?, ?)",
recipientId, docId, commentId);
}
@Test
void rolledUpFeed_combines_same_actor_same_doc_within_2h() {
insertUserAndDocs();
@@ -173,4 +200,87 @@ class AuditLogQueryRepositoryRolledUpTest {
assertThat(r.getHappenedAtUntil()).isNull();
});
}
@Test
void youParticipated_is_true_when_user_has_reply_notification_for_comment() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
insertReplyNotification(USER_ID, DOC_ID, commentId);
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
assertThat(rows).anySatisfy(r ->
assertThat(r.isYouParticipated()).isTrue()
);
}
@Test
void youParticipated_is_false_for_comment_with_no_reply_notification() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
assertThat(rows).allSatisfy(r ->
assertThat(r.isYouParticipated()).isFalse()
);
}
@Test
void youParticipated_is_false_when_comment_added_has_no_commentId_in_payload() {
insertUserAndDocs();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of());
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
assertThat(rows).allSatisfy(r ->
assertThat(r.isYouParticipated()).isFalse()
);
}
@Test
void youParticipated_is_false_when_reply_notification_belongs_to_other_user() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
insertReplyNotification(OTHER_USER_ID, DOC_ID, commentId);
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
assertThat(rows).allSatisfy(r ->
assertThat(r.isYouParticipated()).isFalse()
);
}
@Test
void youMentioned_is_true_when_mention_created_payload_matches_current_user() {
insertUserAndDocs();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "MENTION_CREATED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("mentionedUserId", USER_ID.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
assertThat(rows).anySatisfy(r ->
assertThat(r.isYouMentioned()).isTrue()
);
}
@Test
void youMentioned_is_false_when_mention_created_payload_targets_different_user() {
insertUserAndDocs();
insertAuditEvent(USER_ID, DOC_ID, "MENTION_CREATED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("mentionedUserId", OTHER_USER_ID.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40);
assertThat(rows).allSatisfy(r ->
assertThat(r.isYouMentioned()).isFalse()
);
}
}

View File

@@ -104,6 +104,7 @@ class DashboardServiceTest {
public UUID getDocumentId() { return docId; }
public Instant getHappenedAt() { return Instant.now(); }
public boolean isYouMentioned() { return false; }
public boolean isYouParticipated() { return false; }
public int getCount() { return 1; }
public Instant getHappenedAtUntil() { return null; }
};

View File

@@ -2015,6 +2015,7 @@ export interface components {
/** Format: date-time */
happenedAt: string;
youMentioned: boolean;
youParticipated: boolean;
/** Format: int32 */
count: number;
/** Format: date-time */

View File

@@ -11,6 +11,7 @@ import ChronikEmptyState from '$lib/components/chronik/ChronikEmptyState.svelte'
import ChronikErrorCard from '$lib/components/chronik/ChronikErrorCard.svelte';
import type { components } from '$lib/generated/api';
import type { FilterValue } from './+page.server';
import { filterFeed } from './feedFilters';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
@@ -92,28 +93,7 @@ async function onMarkAllRead() {
await notificationStore.markAllRead();
}
const displayFeed = $derived<ActivityFeedItemDTO[]>(
(() => {
const merged = data.activityFeed;
switch (activeFilter) {
case 'alle':
return merged;
case 'fuer-dich':
return merged.filter((i) => i.kind === 'MENTION_CREATED' || i.youMentioned);
case 'hochgeladen':
return merged.filter((i) => i.kind === 'FILE_UPLOADED');
case 'transkription':
return merged.filter(
(i) =>
i.kind === 'TEXT_SAVED' ||
i.kind === 'BLOCK_REVIEWED' ||
i.kind === 'ANNOTATION_CREATED'
);
case 'kommentare':
return merged.filter((i) => i.kind === 'COMMENT_ADDED' || i.kind === 'MENTION_CREATED');
}
})()
);
const displayFeed = $derived(filterFeed(data.activityFeed, activeFilter));
const isEmpty = $derived(displayFeed.length === 0);
const emptyVariant = $derived<'first-run' | 'filter-empty' | 'inbox-zero'>(

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import { filterFeed } from './feedFilters';
import type { components } from '$lib/generated/api';
type Item = components['schemas']['ActivityFeedItemDTO'];
function makeItem(overrides: Partial<Item> = {}): Item {
return {
kind: 'FILE_UPLOADED',
documentId: 'd1',
documentTitle: 'Brief A',
happenedAt: '2026-04-20T10:00:00Z',
youMentioned: false,
youParticipated: false,
count: 1,
actor: null,
happenedAtUntil: null,
...overrides
};
}
describe('filterFeed', () => {
describe('alle', () => {
it('returns all items regardless of kind', () => {
const items = [
makeItem({ kind: 'FILE_UPLOADED' }),
makeItem({ kind: 'COMMENT_ADDED' }),
makeItem({ kind: 'MENTION_CREATED' })
];
expect(filterFeed(items, 'alle')).toHaveLength(3);
});
});
describe('fuer-dich', () => {
it('includes MENTION_CREATED items', () => {
const items = [makeItem({ kind: 'MENTION_CREATED' })];
expect(filterFeed(items, 'fuer-dich')).toHaveLength(1);
});
it('includes items where youMentioned is true', () => {
const items = [makeItem({ kind: 'COMMENT_ADDED', youMentioned: true })];
expect(filterFeed(items, 'fuer-dich')).toHaveLength(1);
});
it('includes items where youParticipated is true', () => {
const items = [makeItem({ kind: 'COMMENT_ADDED', youParticipated: true })];
expect(filterFeed(items, 'fuer-dich')).toHaveLength(1);
});
it('excludes FILE_UPLOADED with no participation', () => {
const items = [makeItem({ kind: 'FILE_UPLOADED' })];
expect(filterFeed(items, 'fuer-dich')).toHaveLength(0);
});
it('excludes COMMENT_ADDED with no mention and no participation', () => {
const items = [makeItem({ kind: 'COMMENT_ADDED' })];
expect(filterFeed(items, 'fuer-dich')).toHaveLength(0);
});
});
describe('hochgeladen', () => {
it('includes only FILE_UPLOADED items', () => {
const items = [
makeItem({ kind: 'FILE_UPLOADED' }),
makeItem({ kind: 'COMMENT_ADDED', youParticipated: true })
];
expect(filterFeed(items, 'hochgeladen')).toHaveLength(1);
expect(filterFeed(items, 'hochgeladen')[0].kind).toBe('FILE_UPLOADED');
});
});
describe('transkription', () => {
it('includes TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED', () => {
const items = [
makeItem({ kind: 'TEXT_SAVED' }),
makeItem({ kind: 'BLOCK_REVIEWED' }),
makeItem({ kind: 'ANNOTATION_CREATED' }),
makeItem({ kind: 'FILE_UPLOADED' })
];
expect(filterFeed(items, 'transkription')).toHaveLength(3);
});
});
describe('kommentare', () => {
it('includes COMMENT_ADDED and MENTION_CREATED', () => {
const items = [
makeItem({ kind: 'COMMENT_ADDED' }),
makeItem({ kind: 'MENTION_CREATED' }),
makeItem({ kind: 'FILE_UPLOADED' })
];
expect(filterFeed(items, 'kommentare')).toHaveLength(2);
});
});
});

View File

@@ -0,0 +1,27 @@
import type { components } from '$lib/generated/api';
import type { FilterValue } from './+page.server';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
export function filterFeed(
items: ActivityFeedItemDTO[],
filter: FilterValue
): ActivityFeedItemDTO[] {
switch (filter) {
case 'alle':
return items;
case 'fuer-dich':
return items.filter(
(i) => i.kind === 'MENTION_CREATED' || i.youMentioned || i.youParticipated
);
case 'hochgeladen':
return items.filter((i) => i.kind === 'FILE_UPLOADED');
case 'transkription':
return items.filter(
(i) =>
i.kind === 'TEXT_SAVED' || i.kind === 'BLOCK_REVIEWED' || i.kind === 'ANNOTATION_CREATED'
);
case 'kommentare':
return items.filter((i) => i.kind === 'COMMENT_ADDED' || i.kind === 'MENTION_CREATED');
}
}