Skip to content

Commit d9331db

Browse files
az108claude
andcommitted
Development: Match comment ratings to their author by id
The comment section paired each comment with a rating by comparing display names, so two reviewers called "Max Mustermann" would show each other's score. Names are not unique and never were, and the client had nothing else to match on: neither the comment nor the rating carried the id of the user behind it. Both DTOs now expose one, named after the flat variants that already do this: RatingDTO gains fromUserId and InternalCommentDTO gains authorUserId. The client keys the lookup on those instead. This also makes the ratings distinct on the server. RatingOverviewDTO holds a Set, so before this two reviewers sharing a name who happened to give the same score collapsed into a single entry. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 57bb491 commit d9331db

10 files changed

Lines changed: 123 additions & 29 deletions

File tree

openapi/openapi.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3544,6 +3544,7 @@ components:
35443544
type: object
35453545
properties:
35463546
author: {type: string}
3547+
authorUserId: {type: string, format: uuid}
35473548
canEdit: {type: boolean}
35483549
commentId: {type: string, format: uuid}
35493550
createdAt: {type: string, format: date-time}
@@ -4071,6 +4072,7 @@ components:
40714072
type: object
40724073
properties:
40734074
from: {type: string}
4075+
fromUserId: {type: string, format: uuid}
40744076
rating: {type: integer, format: int32}
40754077
RatingOverviewDTO:
40764078
type: object

src/main/java/de/tum/cit/aet/evaluation/dto/InternalCommentDTO.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import java.util.UUID;
88
import lombok.NonNull;
99

10-
public record InternalCommentDTO(UUID commentId, String author, String message, Instant createdAt, boolean canEdit) {
10+
public record InternalCommentDTO(UUID commentId, UUID authorUserId, String author, String message, Instant createdAt, boolean canEdit) {
1111
/**
1212
* Creates a DTO representation of an internal comment for the given user context.
1313
*
@@ -22,6 +22,7 @@ public static InternalCommentDTO from(@NonNull InternalComment comment, @NonNull
2222
User author = comment.getCreatedBy();
2323
return new InternalCommentDTO(
2424
comment.getInternalCommentId(),
25+
author.getUserId(),
2526
author.getFirstName() + " " + author.getLastName(),
2627
comment.getMessage(),
2728
comment.getCreatedAt().toInstant(ZoneOffset.UTC),
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
package de.tum.cit.aet.evaluation.dto;
22

33
import de.tum.cit.aet.evaluation.domain.Rating;
4+
import de.tum.cit.aet.usermanagement.domain.User;
5+
import java.util.UUID;
46

5-
public record RatingDTO(String from, int rating) {
7+
public record RatingDTO(UUID fromUserId, String from, int rating) {
68
/**
79
* Creates a {@link RatingDTO} from a given {@link Rating} entity.
810
*
911
* @param rating the {@link Rating} entity to convert; must not be {@code null}
10-
* @return a {@link RatingDTO} containing the rater's full name and rating value
12+
* @return a {@link RatingDTO} containing the rater's id, full name and rating value
1113
*/
1214
public static RatingDTO from(Rating rating) {
13-
return new RatingDTO(rating.getFrom().getFirstName() + " " + rating.getFrom().getLastName(), rating.getRating());
15+
User rater = rating.getFrom();
16+
return new RatingDTO(rater.getUserId(), rater.getFirstName() + " " + rater.getLastName(), rating.getRating());
1417
}
1518
}

src/main/webapp/app/generated/model/internal-comment-dto.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
export interface InternalCommentDTO {
1313
readonly author?: string;
14+
readonly authorUserId?: string;
1415
readonly canEdit?: boolean;
1516
readonly commentId?: string;
1617
readonly createdAt?: string;

src/main/webapp/app/generated/model/rating-dto.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@
1111

1212
export interface RatingDTO {
1313
readonly from?: string;
14+
readonly fromUserId?: string;
1415
readonly rating?: number;
1516
}

src/main/webapp/app/shared/components/molecules/comment-section/comment-section.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
[canEdit]="c.canEdit ?? false"
1313
[isCreate]="false"
1414
[text]="c.message ?? ''"
15-
[rating]="ratingByAuthor().get(c.author ?? '')"
15+
[rating]="ratingByAuthorId().get(c.authorUserId ?? '')"
1616
[editingId]="editingId()"
1717
(enterEdit)="editingId.set(c.commentId)"
1818
(exitEdit)="editingId.set(undefined)"

src/main/webapp/app/shared/components/molecules/comment-section/comment-section.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export class CommentSection {
2626
protected comments = signal<InternalCommentDTO[]>([]);
2727
protected createDraft = signal<string>('');
2828
protected currentUser = this.accountService.loadedUser()?.name ?? '';
29+
protected currentUserId = this.accountService.loadedUser()?.id ?? '';
2930
protected editingId = signal<string | undefined>(undefined);
3031

3132
protected readonly _loadCommentsEffect = effect(() => {
@@ -38,20 +39,21 @@ export class CommentSection {
3839
}
3940
});
4041

41-
protected readonly ratingByAuthor = computed<Map<string, number>>(() => {
42+
/** Ratings keyed by the id of the reviewer who gave them, since display names are not unique. */
43+
protected readonly ratingByAuthorId = computed<Map<string, number>>(() => {
4244
const map = new Map<string, number>();
4345
const overview = this.ratings();
4446
if (overview === undefined) {
4547
return map;
4648
}
4749

4850
const currentRating = overview.currentUserRating;
49-
if (this.currentUser !== '' && currentRating !== undefined) {
50-
map.set(this.currentUser, currentRating);
51+
if (this.currentUserId !== '' && currentRating !== undefined) {
52+
map.set(this.currentUserId, currentRating);
5153
}
5254
for (const r of overview.otherRatings ?? []) {
53-
if (r.from !== undefined && r.rating !== undefined) {
54-
map.set(r.from, r.rating);
55+
if (r.fromUserId !== undefined && r.rating !== undefined) {
56+
map.set(r.fromUserId, r.rating);
5557
}
5658
}
5759
return map;

src/test/java/de/tum/cit/aet/evaluation/web/rest/InternalCommentResourceTest.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,23 @@ void listCommentsReturnsOrderedList() {
116116
assertThat(list.get(2).message()).isEqualTo("second");
117117
}
118118

119+
@Test
120+
void shouldIdentifyTheAuthorByIdWhenListingComments() {
121+
internalCommentRepository.save(InternalCommentTestData.newCommentAll(application, otherProfessor, "from the other professor"));
122+
123+
List<InternalCommentDTO> list = api
124+
.with(JwtPostProcessors.jwtUser(professor.getUserId(), "ROLE_PROFESSOR"))
125+
.getAndRead(applicationCommentsUrl(), Map.of(), new TypeReference<>() {}, 200);
126+
127+
assertThat(list)
128+
.filteredOn(c -> "from the other professor".equals(c.message()))
129+
.singleElement()
130+
.satisfies(c -> {
131+
assertThat(c.authorUserId()).isEqualTo(otherProfessor.getUserId());
132+
assertThat(c.canEdit()).isFalse();
133+
});
134+
}
135+
119136
@Test
120137
void nonExistentApplicationReturns404() {
121138
UUID fakeAppId = UUID.randomUUID();

src/test/java/de/tum/cit/aet/evaluation/web/rest/RatingResourceTest.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package de.tum.cit.aet.evaluation.web.rest;
22

33
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.tuple;
45

56
import de.tum.cit.aet.AbstractResourceTest;
67
import de.tum.cit.aet.application.domain.Application;
78
import de.tum.cit.aet.application.repository.ApplicationRepository;
9+
import de.tum.cit.aet.evaluation.dto.RatingDTO;
810
import de.tum.cit.aet.evaluation.dto.RatingOverviewDTO;
911
import de.tum.cit.aet.evaluation.repository.RatingRepository;
1012
import de.tum.cit.aet.job.constants.JobState;
@@ -97,6 +99,25 @@ private String ratingsUrl(UUID applicationId) {
9799
return "/api/applications/" + applicationId + "/ratings";
98100
}
99101

102+
private User savedProfessorNamed(String email, String firstName, String lastName) {
103+
return UserTestData.savedProfessorAll(
104+
userRepository,
105+
researchGroup,
106+
null,
107+
email,
108+
firstName,
109+
lastName,
110+
"en",
111+
null,
112+
null,
113+
null,
114+
"DE",
115+
null,
116+
null,
117+
UUID.randomUUID().toString().replace("-", "").substring(0, 7)
118+
);
119+
}
120+
100121
@Nested
101122
class GetRatings {
102123

@@ -124,6 +145,39 @@ void includesOtherProfessorRatings() {
124145
);
125146
}
126147

148+
@Test
149+
void shouldIdentifyTheRaterByIdWhenReturningOtherRatings() {
150+
RatingTestData.saved(ratingRepository, application, otherProfessor, -1);
151+
152+
RatingOverviewDTO overview = api
153+
.with(JwtPostProcessors.jwtUser(professor.getUserId(), "ROLE_PROFESSOR"))
154+
.getAndRead(ratingsUrl(), Map.of(), RatingOverviewDTO.class, 200);
155+
156+
assertThat(overview.otherRatings())
157+
.singleElement()
158+
.satisfies(r -> {
159+
assertThat(r.fromUserId()).isEqualTo(otherProfessor.getUserId());
160+
assertThat(r.rating()).isEqualTo(-1);
161+
});
162+
}
163+
164+
@Test
165+
void shouldKeepRatingsApartWhenTwoRatersShareADisplayName() {
166+
User firstNamesake = savedProfessorNamed("namesake.one@tum.de", "Max", "Mustermann");
167+
User secondNamesake = savedProfessorNamed("namesake.two@tum.de", "Max", "Mustermann");
168+
RatingTestData.saved(ratingRepository, application, firstNamesake, -2);
169+
RatingTestData.saved(ratingRepository, application, secondNamesake, 2);
170+
171+
RatingOverviewDTO overview = api
172+
.with(JwtPostProcessors.jwtUser(professor.getUserId(), "ROLE_PROFESSOR"))
173+
.getAndRead(ratingsUrl(), Map.of(), RatingOverviewDTO.class, 200);
174+
175+
assertThat(overview.otherRatings()).hasSize(2);
176+
assertThat(overview.otherRatings())
177+
.extracting(RatingDTO::fromUserId, RatingDTO::rating)
178+
.containsExactlyInAnyOrder(tuple(firstNamesake.getUserId(), -2), tuple(secondNamesake.getUserId(), 2));
179+
}
180+
127181
@Test
128182
void nonexistentApplicationReturns404() {
129183
UUID fakeAppId = UUID.randomUUID();

src/test/webapp/app/shared/components/molecules/comment-section/comment-section.spec.ts

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ describe('CommentSection', () => {
240240
});
241241

242242
// ---------------- RATINGS ----------------
243-
describe('ratingByAuthor', () => {
243+
describe('ratingByAuthorId', () => {
244244
const setRatings = (ratings: RatingOverviewDTO | undefined): void => {
245245
fixture.componentRef.setInput('ratings', ratings);
246246
fixture.detectChanges();
@@ -254,59 +254,72 @@ describe('CommentSection', () => {
254254
it('should be empty when no ratings are provided', () => {
255255
setRatings(undefined);
256256

257-
expect(component['ratingByAuthor']().size).toBe(0);
257+
expect(component['ratingByAuthorId']().size).toBe(0);
258258
});
259259

260260
it('should map the current user to their own rating', () => {
261261
setRatings({ currentUserRating: 2, otherRatings: [] });
262262

263-
expect(component['ratingByAuthor']().get('Alice Reviewer')).toBe(2);
263+
expect(component['ratingByAuthorId']().get('reviewer-1')).toBe(2);
264264
});
265265

266266
it('should map every other reviewer to their rating', () => {
267267
setRatings({
268268
currentUserRating: 2,
269269
otherRatings: [
270-
{ from: 'Bob Reviewer', rating: -1 },
271-
{ from: 'Carol Reviewer', rating: 1 },
270+
{ fromUserId: 'reviewer-2', from: 'Bob Reviewer', rating: -1 },
271+
{ fromUserId: 'reviewer-3', from: 'Carol Reviewer', rating: 1 },
272272
],
273273
});
274274

275-
const map = component['ratingByAuthor']();
276-
expect(map.get('Bob Reviewer')).toBe(-1);
277-
expect(map.get('Carol Reviewer')).toBe(1);
275+
const map = component['ratingByAuthorId']();
276+
expect(map.get('reviewer-2')).toBe(-1);
277+
expect(map.get('reviewer-3')).toBe(1);
278278
expect(map.size).toBe(3);
279279
});
280280

281+
it('should keep reviewers who share a display name apart', () => {
282+
setRatings({
283+
otherRatings: [
284+
{ fromUserId: 'reviewer-2', from: 'Max Mustermann', rating: -2 },
285+
{ fromUserId: 'reviewer-3', from: 'Max Mustermann', rating: 2 },
286+
],
287+
});
288+
289+
const map = component['ratingByAuthorId']();
290+
expect(map.get('reviewer-2')).toBe(-2);
291+
expect(map.get('reviewer-3')).toBe(2);
292+
});
293+
281294
it('should keep a rating of zero rather than dropping it', () => {
282-
setRatings({ currentUserRating: 0, otherRatings: [{ from: 'Bob Reviewer', rating: 0 }] });
295+
setRatings({ currentUserRating: 0, otherRatings: [{ fromUserId: 'reviewer-2', from: 'Bob Reviewer', rating: 0 }] });
283296

284-
const map = component['ratingByAuthor']();
285-
expect(map.get('Alice Reviewer')).toBe(0);
286-
expect(map.get('Bob Reviewer')).toBe(0);
297+
const map = component['ratingByAuthorId']();
298+
expect(map.get('reviewer-1')).toBe(0);
299+
expect(map.get('reviewer-2')).toBe(0);
287300
});
288301

289-
it('should skip entries without an author or a rating', () => {
302+
it('should skip entries without a reviewer id or a rating', () => {
290303
setRatings({
291-
otherRatings: [{ from: 'Bob Reviewer' }, { rating: 1 }],
304+
otherRatings: [{ from: 'Bob Reviewer', rating: 1 }, { fromUserId: 'reviewer-2' }],
292305
});
293306

294-
expect(component['ratingByAuthor']().size).toBe(0);
307+
expect(component['ratingByAuthorId']().size).toBe(0);
295308
});
296309

297310
it('should not map the current user when they have not rated', () => {
298-
setRatings({ otherRatings: [{ from: 'Bob Reviewer', rating: 1 }] });
311+
setRatings({ otherRatings: [{ fromUserId: 'reviewer-2', from: 'Bob Reviewer', rating: 1 }] });
299312

300-
const map = component['ratingByAuthor']();
301-
expect(map.has('Alice Reviewer')).toBe(false);
313+
const map = component['ratingByAuthorId']();
314+
expect(map.has('reviewer-1')).toBe(false);
302315
expect(map.size).toBe(1);
303316
});
304317

305318
it('should update when the ratings input changes', () => {
306319
setRatings({ currentUserRating: 1, otherRatings: [] });
307320
setRatings({ currentUserRating: -2, otherRatings: [] });
308321

309-
expect(component['ratingByAuthor']().get('Alice Reviewer')).toBe(-2);
322+
expect(component['ratingByAuthorId']().get('reviewer-1')).toBe(-2);
310323
});
311324
});
312325
});

0 commit comments

Comments
 (0)