fix(chronik): surface REPLY events in Für-dich feed via youParticipated #297
@@ -12,6 +12,7 @@ public interface ActivityFeedRow {
|
||||
UUID getDocumentId();
|
||||
Instant getHappenedAt();
|
||||
boolean isYouMentioned();
|
||||
boolean isYouParticipated();
|
||||
int getCount();
|
||||
Instant getHappenedAtUntil();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
@@ -140,6 +140,7 @@ public class DashboardService {
|
||||
docTitle,
|
||||
row.getHappenedAt().atOffset(ZoneOffset.UTC),
|
||||
row.isYouMentioned(),
|
||||
row.isYouParticipated(),
|
||||
row.getCount(),
|
||||
happenedAtUntil
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
|
||||
@@ -2015,6 +2015,7 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
happenedAt: string;
|
||||
youMentioned: boolean;
|
||||
youParticipated: boolean;
|
||||
/** Format: int32 */
|
||||
count: number;
|
||||
/** Format: date-time */
|
||||
|
||||
@@ -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'>(
|
||||
|
||||
94
frontend/src/routes/chronik/feedFilters.test.ts
Normal file
94
frontend/src/routes/chronik/feedFilters.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
27
frontend/src/routes/chronik/feedFilters.ts
Normal file
27
frontend/src/routes/chronik/feedFilters.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user