Skip to content

Commit d13e2e3

Browse files
feat(workspace): add hide-member, fix avatars/profiles, fix team sync
Avatar consistency: - Add shared getInitials() utility for consistent initial generation across Header, ContributorCard, LeaderboardTable, ProfileHeader - Fix relative GitLab avatar URLs by prepending server URL in GitLabUserService.resolveAvatarUrl() and WorkspaceProvisioningService - Remove hardcoded github.com avatar fallback from Header Profile link fixes: - TeamsPage: use member.htmlUrl instead of hardcoded github.com URL - ProfileHeader: extract domain from htmlUrl for display text Hide member feature: - Add hidden boolean column to workspace_membership (Liquibase migration) - Add PATCH /{userId}/hidden endpoint for admin toggle - Filter hidden members from individual leaderboard - Filter hidden members from TeamInfoDTO (team views) - Add visibility toggle (eye icon) in admin members table - Enrich UserTeamsDTO with hidden flag for admin page Team sync Phase F fix: - Use TeamMembership constructor (sets composite ID + isNew flag) instead of no-arg constructor which caused merge failures - Students with WRITE/TRIAGE permissions now correctly added as MEMBER to their parent subgroup team
1 parent 41ab9ba commit d13e2e3

34 files changed

Lines changed: 506 additions & 46 deletions

server/application-server/openapi.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,43 @@ paths:
907907
summary: Get a specific member's details.
908908
tags:
909909
- workspace-membership
910+
/workspaces/{workspaceSlug}/members/{userId}/hidden:
911+
patch:
912+
description: |-
913+
Toggle the hidden flag for a workspace member.
914+
Hidden members are excluded from the leaderboard.
915+
operationId: updateMemberVisibility
916+
parameters:
917+
- description: Workspace slug
918+
in: path
919+
name: workspaceSlug
920+
required: true
921+
schema:
922+
type: string
923+
pattern: "^[a-z0-9][a-z0-9-]{2,50}$"
924+
- description: User ID
925+
in: path
926+
name: userId
927+
required: true
928+
schema:
929+
type: integer
930+
format: int64
931+
- description: whether the member should be hidden
932+
in: query
933+
name: hidden
934+
required: true
935+
schema:
936+
type: boolean
937+
responses:
938+
"200":
939+
content:
940+
application/json:
941+
schema:
942+
$ref: "#/components/schemas/WorkspaceMembership"
943+
description: Updated membership
944+
summary: Toggle the hidden flag for a workspace member.
945+
tags:
946+
- workspace-membership
910947
/workspaces/{workspaceSlug}/mentor/chat:
911948
post:
912949
operationId: mentorChat
@@ -4985,6 +5022,8 @@ components:
49855022
properties:
49865023
email:
49875024
type: string
5025+
hidden:
5026+
type: boolean
49885027
id:
49895028
type: integer
49905029
format: int64
@@ -5194,6 +5233,9 @@ components:
51945233
type: string
51955234
format: date-time
51965235
description: Timestamp when the membership was created
5236+
hidden:
5237+
type: boolean
5238+
description: Whether the member is hidden from the leaderboard
51975239
leaguePoints:
51985240
type: integer
51995241
format: int32

server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/repository/collaborator/RepositoryCollaboratorRepository.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,17 @@ Optional<RepositoryCollaborator> findByRepositoryIdAndUserId(
2222
);
2323

2424
List<RepositoryCollaborator> findByRepository_Id(Long repositoryId);
25+
26+
@Query(
27+
"""
28+
SELECT DISTINCT c FROM RepositoryCollaborator c
29+
JOIN FETCH c.user
30+
WHERE c.repository.organization.login = :orgLogin
31+
AND c.permission IN :permissions
32+
"""
33+
)
34+
List<RepositoryCollaborator> findByOrgLoginAndPermissions(
35+
@Param("orgLogin") String orgLogin,
36+
@Param("permissions") List<RepositoryCollaborator.Permission> permissions
37+
);
2538
}

server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/team/TeamInfoDTO.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ public record TeamInfoDTO(
3737
* @param isHidden whether the team is hidden in this scope
3838
* @param scopeLabels labels configured as filters for this team in this scope
3939
* @param hiddenRepoIds repository IDs hidden from contributions in this scope
40+
* @param hiddenMemberIds user IDs hidden from leaderboard/team views in this workspace
4041
* @return the DTO with scope-specific settings applied
4142
*/
4243
public static TeamInfoDTO fromTeamWithScopeSettings(
4344
Team team,
4445
boolean isHidden,
4546
Set<Label> scopeLabels,
46-
Set<Long> hiddenRepoIds
47+
Set<Long> hiddenRepoIds,
48+
Set<Long> hiddenMemberIds
4749
) {
4850
return new TeamInfoDTO(
4951
team.getId(),
@@ -69,7 +71,7 @@ public static TeamInfoDTO fromTeamWithScopeSettings(
6971
.getMemberships()
7072
.stream()
7173
.map(m -> m.getUser())
72-
.filter(u -> u != null && !User.Type.BOT.equals(u.getType()))
74+
.filter(u -> u != null && !User.Type.BOT.equals(u.getType()) && !hiddenMemberIds.contains(u.getId()))
7375
.map(UserInfoDTO::fromUser)
7476
.toList(),
7577
team.getMemberships().size(),

server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/team/gitlab/GitLabTeamSyncService.java

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import de.tum.in.www1.hephaestus.gitprovider.common.gitlab.graphql.GitLabPageInfo;
1717
import de.tum.in.www1.hephaestus.gitprovider.repository.Repository;
1818
import de.tum.in.www1.hephaestus.gitprovider.repository.RepositoryRepository;
19+
import de.tum.in.www1.hephaestus.gitprovider.repository.collaborator.RepositoryCollaborator;
20+
import de.tum.in.www1.hephaestus.gitprovider.repository.collaborator.RepositoryCollaboratorRepository;
1921
import de.tum.in.www1.hephaestus.gitprovider.team.Team;
2022
import de.tum.in.www1.hephaestus.gitprovider.team.TeamRepository;
2123
import de.tum.in.www1.hephaestus.gitprovider.team.membership.TeamMembership;
@@ -55,6 +57,7 @@
5557
* <li>C — Sync direct members per team</li>
5658
* <li>D — Sync team-repo permissions (repos whose org = subgroup fullPath)</li>
5759
* <li>E — Cleanup stale teams (only if sync completed fully)</li>
60+
* <li>F — Add project collaborators as team members (students in repos under subgroup)</li>
5861
* </ol>
5962
*/
6063
@Service
@@ -76,6 +79,7 @@ public class GitLabTeamSyncService {
7679
private final GitLabUserService gitLabUserService;
7780
private final GitProviderRepository gitProviderRepository;
7881
private final GitLabProperties gitLabProperties;
82+
private final RepositoryCollaboratorRepository collaboratorRepository;
7983
private final TransactionTemplate transactionTemplate;
8084

8185
public GitLabTeamSyncService(
@@ -88,6 +92,7 @@ public GitLabTeamSyncService(
8892
GitLabUserService gitLabUserService,
8993
GitProviderRepository gitProviderRepository,
9094
GitLabProperties gitLabProperties,
95+
RepositoryCollaboratorRepository collaboratorRepository,
9196
TransactionTemplate transactionTemplate
9297
) {
9398
this.teamRepository = teamRepository;
@@ -99,6 +104,7 @@ public GitLabTeamSyncService(
99104
this.gitLabUserService = gitLabUserService;
100105
this.gitProviderRepository = gitProviderRepository;
101106
this.gitLabProperties = gitLabProperties;
107+
this.collaboratorRepository = collaboratorRepository;
102108
this.transactionTemplate = transactionTemplate;
103109
}
104110

@@ -193,16 +199,38 @@ public int syncTeamsForGroup(Long scopeId, String groupFullPath) {
193199
}
194200
log.info("Phase D complete: groupPath={}, totalPermissions={}", groupFullPath, totalPermissions);
195201

202+
// Phase F: Add project collaborators as team members
203+
// Students who are direct project members (WRITE/TRIAGE) under a subgroup
204+
// should appear as team members of that subgroup.
205+
int totalCollaboratorMembers = 0;
206+
for (Map.Entry<Long, Team> entry : syncedTeamsByNativeId.entrySet()) {
207+
String fullPath = teamFullPathsByNativeId.get(entry.getKey());
208+
if (fullPath != null) {
209+
try {
210+
int added = addProjectCollaboratorsAsTeamMembers(entry.getValue().getId(), fullPath);
211+
totalCollaboratorMembers += added;
212+
} catch (Exception e) {
213+
log.warn(
214+
"Failed to sync collaborator-to-team for: teamSlug={}, error={}",
215+
entry.getValue().getSlug(),
216+
e.getMessage()
217+
);
218+
}
219+
}
220+
}
221+
log.info("Phase F complete: groupPath={}, collaboratorMembers={}", groupFullPath, totalCollaboratorMembers);
222+
196223
// Phase E: Cleanup stale teams (only if sync completed normally)
197224
if (syncCompletedNormally) {
198225
removeDeletedTeams(groupFullPath, syncedNativeIds, providerId);
199226
}
200227

201228
log.info(
202-
"GitLab team sync complete: groupPath={}, teams={}, members={}, permissions={}",
229+
"GitLab team sync complete: groupPath={}, teams={}, directMembers={}, collaboratorMembers={}, permissions={}",
203230
groupFullPath,
204231
totalSynced,
205232
totalMembers,
233+
totalCollaboratorMembers,
206234
totalPermissions
207235
);
208236

@@ -639,6 +667,68 @@ int syncTeamRepoPermissions(Long teamId, String groupFullPath, Long providerId)
639667
return result != null ? result : 0;
640668
}
641669

670+
// ========================================================================
671+
// Phase F: Add project collaborators as team members
672+
// ========================================================================
673+
674+
/**
675+
* Adds project-level collaborators (WRITE/TRIAGE) as members of their parent subgroup team.
676+
* <p>
677+
* In GitLab iPraktikum, each tutor subgroup contains student repos. Students are direct
678+
* project members but not group members. This phase bridges the gap so they appear in the
679+
* team on the leaderboard and profile views.
680+
*/
681+
int addProjectCollaboratorsAsTeamMembers(Long teamId, String subgroupFullPath) {
682+
Integer result = transactionTemplate.execute(status -> {
683+
Team team = teamRepository
684+
.findById(teamId)
685+
.orElseThrow(() -> new IllegalStateException("Team not found: teamId=" + teamId));
686+
687+
// Find collaborators with WRITE or TRIAGE permission on repos under this subgroup.
688+
// These are direct project members (students), not inherited ADMIN/MAINTAIN (professors/TAs).
689+
List<RepositoryCollaborator> collaborators = collaboratorRepository.findByOrgLoginAndPermissions(
690+
subgroupFullPath,
691+
List.of(RepositoryCollaborator.Permission.WRITE, RepositoryCollaborator.Permission.TRIAGE)
692+
);
693+
694+
log.info(
695+
"Phase F: querying collaborators for subgroup: orgLogin={}, found={}",
696+
subgroupFullPath,
697+
collaborators.size()
698+
);
699+
700+
if (collaborators.isEmpty()) {
701+
return 0;
702+
}
703+
704+
// Deduplicate by user (a student may be in multiple repos under the same subgroup)
705+
Set<Long> existingMemberIds = team
706+
.getMemberships()
707+
.stream()
708+
.map(m -> m.getUser().getId())
709+
.collect(Collectors.toSet());
710+
711+
int added = 0;
712+
Set<Long> seenUserIds = new HashSet<>();
713+
for (RepositoryCollaborator collab : collaborators) {
714+
Long userId = collab.getUser().getId();
715+
if (seenUserIds.add(userId) && !existingMemberIds.contains(userId)) {
716+
TeamMembership membership = new TeamMembership(team, collab.getUser(), TeamMembership.Role.MEMBER);
717+
teamMembershipRepository.save(membership);
718+
added++;
719+
}
720+
}
721+
722+
if (added > 0) {
723+
log.debug("Added project collaborators to team: teamSlug={}, added={}", team.getSlug(), added);
724+
}
725+
726+
return added;
727+
});
728+
729+
return result != null ? result : 0;
730+
}
731+
642732
// ========================================================================
643733
// Phase E: Cleanup
644734
// ========================================================================

server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/UserTeamsDTO.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ public record UserTeamsDTO(
1212
String email,
1313
@NonNull String name,
1414
@NonNull String url,
15-
@NonNull Set<TeamSummaryDTO> teams
15+
@NonNull Set<TeamSummaryDTO> teams,
16+
boolean hidden
1617
) {
1718
/**
1819
* Creates a UserTeamsDTO from a User entity using scope-specific settings.
@@ -25,6 +26,18 @@ public record UserTeamsDTO(
2526
* @return the DTO with scope-specific settings applied
2627
*/
2728
public static UserTeamsDTO fromUserWithScopeSettings(User user, Set<Long> hiddenTeamIds) {
29+
return fromUserWithScopeSettings(user, hiddenTeamIds, false);
30+
}
31+
32+
/**
33+
* Creates a UserTeamsDTO from a User entity using scope-specific settings.
34+
*
35+
* @param user the user entity
36+
* @param hiddenTeamIds set of team IDs that are hidden in this scope
37+
* @param hidden whether this member is hidden from the leaderboard
38+
* @return the DTO with scope-specific settings applied
39+
*/
40+
public static UserTeamsDTO fromUserWithScopeSettings(User user, Set<Long> hiddenTeamIds, boolean hidden) {
2841
return new UserTeamsDTO(
2942
user.getId(),
3043
user.getLogin(),
@@ -37,7 +50,8 @@ public static UserTeamsDTO fromUserWithScopeSettings(User user, Set<Long> hidden
3750
.map(m -> m.getTeam())
3851
.filter(t -> t != null)
3952
.map(team -> TeamSummaryDTO.fromTeamWithScopeSettings(team, hiddenTeamIds.contains(team.getId())))
40-
.collect(Collectors.toCollection(LinkedHashSet::new))
53+
.collect(Collectors.toCollection(LinkedHashSet::new)),
54+
hidden
4155
);
4256
}
4357
}

server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/gitlab/GitLabUserService.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ public GitLabUserService(UserRepository userRepository, GitLabProperties gitLabP
3434
this.gitLabProperties = gitLabProperties;
3535
}
3636

37+
/**
38+
* Resolves a GitLab avatar URL, prepending the server base URL for relative paths.
39+
* GitLab self-hosted instances return relative paths like {@code /uploads/-/system/user/avatar/123/avatar.png}.
40+
*/
41+
private String resolveAvatarUrl(@Nullable String avatarUrl) {
42+
if (avatarUrl == null || avatarUrl.isEmpty()) {
43+
return "";
44+
}
45+
if (avatarUrl.startsWith("/")) {
46+
return gitLabProperties.defaultServerUrl() + avatarUrl;
47+
}
48+
return avatarUrl;
49+
}
50+
3751
/**
3852
* Finds or creates a user from webhook data.
3953
* <p>
@@ -49,7 +63,7 @@ public User findOrCreateUser(@Nullable GitLabWebhookUser dto, Long providerId) {
4963
long nativeId = dto.id();
5064
String login = dto.username();
5165
String name = dto.name() != null ? dto.name() : login;
52-
String avatarUrl = dto.avatarUrl() != null ? dto.avatarUrl() : "";
66+
String avatarUrl = resolveAvatarUrl(dto.avatarUrl());
5367
String htmlUrl = gitLabProperties.defaultServerUrl() + "/" + login;
5468

5569
userRepository.upsertUser(
@@ -97,7 +111,7 @@ public User findOrCreateUser(
97111
}
98112

99113
String resolvedName = name != null ? name : username;
100-
String resolvedAvatarUrl = avatarUrl != null ? avatarUrl : "";
114+
String resolvedAvatarUrl = resolveAvatarUrl(avatarUrl);
101115
String resolvedHtmlUrl = webUrl != null ? webUrl : (gitLabProperties.defaultServerUrl() + "/" + username);
102116

103117
userRepository.upsertUser(

server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardService.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ private List<LeaderboardEntryDTO> createIndividualLeaderboard(
165165
}
166166
}
167167

168+
// Remove hidden members from the leaderboard
169+
Set<Long> hiddenMemberIds = workspaceMembershipService.getHiddenMemberIds(workspaceId);
170+
activityData.keySet().removeAll(hiddenMemberIds);
171+
168172
if (activityData.isEmpty()) {
169173
log.debug("Found no team members for leaderboard: workspaceId={}", workspaceId);
170174
return Collections.emptyList();
@@ -371,6 +375,7 @@ private List<LeaderboardEntryDTO> createTeamLeaderboard(
371375
workspaceId,
372376
allTeamIds
373377
);
378+
Set<Long> hiddenMemberIds = workspaceMembershipService.getHiddenMemberIds(workspaceId);
374379

375380
for (int i = 0; i < sorted.size(); i++) {
376381
Team teamEntity = sorted.get(i).getKey();
@@ -386,7 +391,13 @@ private List<LeaderboardEntryDTO> createTeamLeaderboard(
386391
i + 1,
387392
score,
388393
null,
389-
TeamInfoDTO.fromTeamWithScopeSettings(teamEntity, isHiddenInWorkspace, workspaceLabels, hiddenRepoIds),
394+
TeamInfoDTO.fromTeamWithScopeSettings(
395+
teamEntity,
396+
isHiddenInWorkspace,
397+
workspaceLabels,
398+
hiddenRepoIds,
399+
hiddenMemberIds
400+
),
390401
stats.reviewedPullRequests(),
391402
stats.numberOfReviewedPRs(),
392403
stats.numberOfApprovals(),

server/application-server/src/main/java/de/tum/in/www1/hephaestus/workspace/WorkspaceMembership.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ public class WorkspaceMembership {
8585
@Column(name = "league_points", nullable = false)
8686
private int leaguePoints = 0;
8787

88+
/** Whether this member is hidden from the leaderboard */
89+
@Column(name = "hidden", nullable = false)
90+
private boolean hidden = false;
91+
8892
/** Timestamp when this membership was created */
8993
@Column(name = "created_at", nullable = false, updatable = false)
9094
private Instant createdAt = Instant.now();

0 commit comments

Comments
 (0)