Forge
csharp4405de34
1using IntercomService.Contracts;
2using IntercomService.Data;
3using Microsoft.EntityFrameworkCore;
4
5namespace IntercomService.Services;
6
7public sealed class TeamMembershipService(
8 IntercomDbContext db,
9 TeamInviteService invites,
10 GitHubAuthService github,
11 IHostEnvironment environment)
12{
13 public async Task<TeamEntity> CreateTeamAsync(
14 string teamId,
15 string? displayName,
16 string creatorMemberId,
17 CancellationToken ct)
18 {
19 var team = new TeamEntity
20 {
21 TeamId = teamId.Trim(),
22 DisplayName = string.IsNullOrWhiteSpace(displayName) ? teamId.Trim() : displayName.Trim(),
23 JoinPolicy = JoinPolicies.FirstOwner,
24 DefaultTeamRole = TeamRoles.Member,
25 CreatedAtUtc = DateTimeOffset.UtcNow,
26 };
27
28 db.Teams.Add(team);
29 db.TeamMembers.Add(new TeamMemberEntity
30 {
31 TeamId = team.TeamId,
32 MemberId = creatorMemberId,
33 TeamRole = TeamRoles.Owner,
34 JoinedAtUtc = DateTimeOffset.UtcNow,
35 });
36 await db.SaveChangesAsync(ct).ConfigureAwait(false);
37 return team;
38 }
39
40 public async Task EnsureTeamAsync(string teamId, string? displayName, CancellationToken ct)
41 {
42 if (await db.Teams.AnyAsync(x => x.TeamId == teamId, ct).ConfigureAwait(false))
43 return;
44
45 db.Teams.Add(new TeamEntity
46 {
47 TeamId = teamId,
48 DisplayName = string.IsNullOrWhiteSpace(displayName) ? teamId : displayName.Trim(),
49 JoinPolicy = environment.IsDevelopment() ? JoinPolicies.Open : JoinPolicies.FirstOwner,
50 DefaultTeamRole = TeamRoles.Member,
51 CreatedAtUtc = DateTimeOffset.UtcNow,
52 });
53
54 try
55 {
56 await db.SaveChangesAsync(ct).ConfigureAwait(false);
57 }
58 catch (DbUpdateException)
59 {
60 db.ChangeTracker.Clear();
61 }
62 }
63
64 public async Task<MemberEntity> EnsureGitHubMemberAsync(long githubUserId, string login, CancellationToken ct)
65 {
66 var issuer = "https://github.com";
67 var subject = githubUserId.ToString();
68 var memberId = $"github:{githubUserId}";
69
70 var member = await db.Members.FirstOrDefaultAsync(x => x.Issuer == issuer && x.Subject == subject, ct)
71 .ConfigureAwait(false);
72
73 if (member is not null)
74 {
75 if (string.IsNullOrWhiteSpace(member.DisplayName))
76 {
77 member.DisplayName = login;
78 await db.SaveChangesAsync(ct).ConfigureAwait(false);
79 }
80
81 return member;
82 }
83
84 member = new MemberEntity
85 {
86 MemberId = memberId,
87 Issuer = issuer,
88 Subject = subject,
89 DisplayName = login,
90 MemberKind = MemberKinds.Human,
91 CreatedAtUtc = DateTimeOffset.UtcNow,
92 };
93 db.Members.Add(member);
94 await db.SaveChangesAsync(ct).ConfigureAwait(false);
95 return member;
96 }
97
98 public async Task<MemberEntity> EnsureOidcMemberAsync(
99 string issuer,
100 string subject,
101 string displayName,
102 CancellationToken ct)
103 {
104 var memberId = $"oidc:{Uri.EscapeDataString(issuer)}:{subject}";
105 var member = await db.Members.FirstOrDefaultAsync(x => x.Issuer == issuer && x.Subject == subject, ct)
106 .ConfigureAwait(false);
107
108 if (member is not null)
109 {
110 if (string.IsNullOrWhiteSpace(member.DisplayName) && !string.IsNullOrWhiteSpace(displayName))
111 {
112 member.DisplayName = displayName;
113 await db.SaveChangesAsync(ct).ConfigureAwait(false);
114 }
115
116 return member;
117 }
118
119 member = new MemberEntity
120 {
121 MemberId = memberId,
122 Issuer = issuer,
123 Subject = subject,
124 DisplayName = string.IsNullOrWhiteSpace(displayName) ? memberId : displayName,
125 MemberKind = MemberKinds.Human,
126 CreatedAtUtc = DateTimeOffset.UtcNow,
127 };
128 db.Members.Add(member);
129 await db.SaveChangesAsync(ct).ConfigureAwait(false);
130 return member;
131 }
132
133 public async Task JoinTeamAsync(string teamId, string memberId, string teamRole, CancellationToken ct)
134 {
135 if (await db.TeamMembers.AnyAsync(x => x.TeamId == teamId && x.MemberId == memberId, ct).ConfigureAwait(false))
136 return;
137
138 db.TeamMembers.Add(new TeamMemberEntity
139 {
140 TeamId = teamId,
141 MemberId = memberId,
142 TeamRole = teamRole,
143 JoinedAtUtc = DateTimeOffset.UtcNow,
144 });
145
146 try
147 {
148 await db.SaveChangesAsync(ct).ConfigureAwait(false);
149 }
150 catch (DbUpdateException)
151 {
152 db.ChangeTracker.Clear();
153 }
154 }
155
156 public async Task<(bool Joined, string? Error)> TryJoinTeamAsync(
157 string teamId,
158 MemberEntity member,
159 string? inviteToken,
160 string provider,
161 string? githubAccessToken,
162 CancellationToken ct)
163 {
164 if (await IsMemberOfTeamAsync(teamId, member.MemberId, ct).ConfigureAwait(false))
165 return (true, null);
166
167 var team = await db.Teams.FindAsync([teamId], ct).ConfigureAwait(false);
168 if (team is null)
169 return (false, "team_not_found");
170
171 var policy = team.JoinPolicy;
172 if (string.Equals(policy, JoinPolicies.Open, StringComparison.Ordinal))
173 {
174 if (!environment.IsDevelopment())
175 return (false, "open_join_forbidden");
176
177 await JoinTeamAsync(teamId, member.MemberId, team.DefaultTeamRole, ct).ConfigureAwait(false);
178 return (true, null);
179 }
180
181 if (string.Equals(policy, JoinPolicies.FirstOwner, StringComparison.Ordinal))
182 {
183 var hasHuman = await db.TeamMembers.AsNoTracking()
184 .Join(
185 db.Members.AsNoTracking(),
186 tm => tm.MemberId,
187 m => m.MemberId,
188 (tm, m) => new { tm.TeamId, m.MemberKind })
189 .AnyAsync(x => x.TeamId == teamId && x.MemberKind == MemberKinds.Human, ct)
190 .ConfigureAwait(false);
191
192 if (!hasHuman)
193 {
194 await JoinTeamAsync(teamId, member.MemberId, TeamRoles.Owner, ct).ConfigureAwait(false);
195 return (true, null);
196 }
197
198 policy = JoinPolicies.InviteRequired;
199 }
200
201 if (string.Equals(policy, JoinPolicies.InviteRequired, StringComparison.Ordinal))
202 {
203 if (string.IsNullOrWhiteSpace(inviteToken))
204 return (false, "invite_required");
205
206 var consumed = await invites.TryConsumeInviteAsync(teamId, inviteToken, ct).ConfigureAwait(false);
207 if (consumed is null)
208 return (false, "invalid_invite");
209
210 await JoinTeamAsync(teamId, member.MemberId, consumed.Value.TeamRole, ct).ConfigureAwait(false);
211 return (true, null);
212 }
213
214 if (string.Equals(policy, JoinPolicies.GitHubOrg, StringComparison.Ordinal))
215 {
216 if (!string.Equals(provider, "github", StringComparison.OrdinalIgnoreCase))
217 return (false, "github_org_requires_github");
218
219 if (string.IsNullOrWhiteSpace(githubAccessToken))
220 return (false, "github_token_required");
221
222 var config = JoinPolicyConfig.Parse(team.JoinPolicyJson);
223 if (config.GitHubOrgs.Count == 0)
224 return (false, "github_org_not_configured");
225
226 var ok = await github.IsMemberOfAnyOrgAsync(githubAccessToken, config.GitHubOrgs, ct)
227 .ConfigureAwait(false);
228 if (!ok)
229 return (false, "github_org_denied");
230
231 await JoinTeamAsync(teamId, member.MemberId, team.DefaultTeamRole, ct).ConfigureAwait(false);
232 return (true, null);
233 }
234
235 return (false, "unsupported_join_policy");
236 }
237
238 public async Task<IReadOnlyList<string>> ListTeamIdsForMemberAsync(string memberId, CancellationToken ct) =>
239 await db.TeamMembers.AsNoTracking()
240 .Where(x => x.MemberId == memberId)
241 .Select(x => x.TeamId)
242 .ToListAsync(ct)
243 .ConfigureAwait(false);
244
245 public async Task<IReadOnlyList<MeTeamDto>> ListMeTeamsAsync(string memberId, CancellationToken ct)
246 {
247 var rows = await db.TeamMembers.AsNoTracking()
248 .Where(x => x.MemberId == memberId)
249 .Join(
250 db.Teams.AsNoTracking(),
251 tm => tm.TeamId,
252 t => t.TeamId,
253 (tm, t) => new { tm, t })
254 .Join(
255 db.Members.AsNoTracking(),
256 x => x.tm.MemberId,
257 m => m.MemberId,
258 (x, m) => new { x.tm, x.t, m })
259 .ToListAsync(ct)
260 .ConfigureAwait(false);
261
262 return rows
263 .Select(x => new MeTeamDto(
264 x.tm.TeamId,
265 x.tm.TeamRole,
266 EffectiveDisplayName(x.m.DisplayName, x.tm.TeamDisplayName),
267 x.tm.TeamDisplayName))
268 .ToList();
269 }
270
271 public async Task<bool> IsMemberOfTeamAsync(string teamId, string memberId, CancellationToken ct) =>
272 await db.TeamMembers.AnyAsync(x => x.TeamId == teamId && x.MemberId == memberId, ct).ConfigureAwait(false);
273
274 public async Task<string?> GetTeamRoleAsync(string teamId, string memberId, CancellationToken ct) =>
275 await db.TeamMembers.AsNoTracking()
276 .Where(x => x.TeamId == teamId && x.MemberId == memberId)
277 .Select(x => x.TeamRole)
278 .FirstOrDefaultAsync(ct)
279 .ConfigureAwait(false);
280
281 public async Task<bool> EnsureAccessAsync(
282 string teamId,
283 string memberId,
284 bool allowDevAutoJoin,
285 string? displayName,
286 CancellationToken ct)
287 {
288 if (await IsMemberOfTeamAsync(teamId, memberId, ct).ConfigureAwait(false))
289 return true;
290
291 if (!allowDevAutoJoin)
292 return false;
293
294 await EnsureMemberAsync(memberId, "dev", memberId, displayName ?? memberId, ct).ConfigureAwait(false);
295 await EnsureTeamAsync(teamId, teamId, ct).ConfigureAwait(false);
296 await JoinTeamAsync(teamId, memberId, TeamRoles.Admin, ct).ConfigureAwait(false);
297 return true;
298 }
299
300 public async Task EnsureMemberAsync(
301 string memberId,
302 string issuer,
303 string subject,
304 string displayName,
305 CancellationToken ct)
306 {
307 if (await db.Members.FindAsync([memberId], ct).ConfigureAwait(false) is not null)
308 return;
309
310 db.Members.Add(new MemberEntity
311 {
312 MemberId = memberId,
313 Issuer = issuer,
314 Subject = subject,
315 DisplayName = displayName,
316 MemberKind = MemberKinds.Human,
317 CreatedAtUtc = DateTimeOffset.UtcNow,
318 });
319
320 try
321 {
322 await db.SaveChangesAsync(ct).ConfigureAwait(false);
323 }
324 catch (DbUpdateException)
325 {
326 db.ChangeTracker.Clear();
327 }
328 }
329
330 public async Task<MemberEntity?> GetMemberAsync(string memberId, CancellationToken ct) =>
331 await db.Members.FindAsync([memberId], ct).ConfigureAwait(false);
332
333 public async Task<bool> PatchMemberDefaultDisplayNameAsync(string memberId, string displayName, CancellationToken ct)
334 {
335 var member = await db.Members.FindAsync([memberId], ct).ConfigureAwait(false);
336 if (member is null || string.IsNullOrWhiteSpace(displayName))
337 return false;
338
339 member.DisplayName = displayName.Trim();
340 await db.SaveChangesAsync(ct).ConfigureAwait(false);
341 return true;
342 }
343
344 public async Task<bool> PatchSelfTeamDisplayNameAsync(
345 string teamId,
346 string memberId,
347 PatchSelfTeamMemberRequest body,
348 CancellationToken ct)
349 {
350 var row = await db.TeamMembers
351 .FirstOrDefaultAsync(x => x.TeamId == teamId && x.MemberId == memberId, ct)
352 .ConfigureAwait(false);
353 if (row is null)
354 return false;
355
356 if (body.ClearTeamDisplayName)
357 row.TeamDisplayName = null;
358 else if (body.TeamDisplayName is not null)
359 row.TeamDisplayName = string.IsNullOrWhiteSpace(body.TeamDisplayName) ? null : body.TeamDisplayName.Trim();
360
361 await db.SaveChangesAsync(ct).ConfigureAwait(false);
362 return true;
363 }
364
365 public async Task<IReadOnlyList<TeamMemberDto>> ListTeamMembersAsync(string teamId, CancellationToken ct)
366 {
367 var rows = await db.TeamMembers.AsNoTracking()
368 .Where(x => x.TeamId == teamId)
369 .Join(db.Members.AsNoTracking(), tm => tm.MemberId, m => m.MemberId, (tm, m) => new { tm, m })
370 .OrderBy(x => x.m.DisplayName)
371 .ToListAsync(ct)
372 .ConfigureAwait(false);
373
374 return rows.Select(x => new TeamMemberDto(
375 x.m.MemberId,
376 x.m.MemberKind,
377 x.tm.TeamRole,
378 EffectiveDisplayName(x.m.DisplayName, x.tm.TeamDisplayName),
379 x.tm.TeamDisplayName,
380 x.tm.JoinedAtUtc.ToString("O"))).ToList();
381 }
382
383 public async Task<(bool Ok, string? Error)> PatchTeamMemberRoleAsync(
384 string teamId,
385 string targetMemberId,
386 string callerMemberId,
387 PatchTeamMemberRequest body,
388 CancellationToken ct)
389 {
390 var callerRole = await GetTeamRoleAsync(teamId, callerMemberId, ct).ConfigureAwait(false);
391 if (callerRole is null || !TeamRoleAuthorization.CanManageMembers(callerRole))
392 return (false, "forbidden");
393
394 var target = await db.TeamMembers
395 .FirstOrDefaultAsync(x => x.TeamId == teamId && x.MemberId == targetMemberId, ct)
396 .ConfigureAwait(false);
397 if (target is null)
398 return (false, "member_not_found");
399
400 if (!string.IsNullOrWhiteSpace(body.TeamRole))
401 {
402 var newRole = body.TeamRole.Trim();
403 if (!TeamRoles.All.Contains(newRole))
404 return (false, "invalid_role");
405
406 if (string.Equals(newRole, TeamRoles.Owner, StringComparison.Ordinal)
407 && !TeamRoleAuthorization.CanPromoteToOwner(callerRole))
408 return (false, "owner_promotion_forbidden");
409
410 if (string.Equals(target.TeamRole, TeamRoles.Owner, StringComparison.Ordinal)
411 && !string.Equals(newRole, TeamRoles.Owner, StringComparison.Ordinal))
412 {
413 var ownerCount = await db.TeamMembers.CountAsync(
414 x => x.TeamId == teamId && x.TeamRole == TeamRoles.Owner,
415 ct).ConfigureAwait(false);
416 if (ownerCount <= 1)
417 return (false, "last_owner");
418 }
419
420 target.TeamRole = newRole;
421 }
422
423 if (body.ClearTeamDisplayName)
424 target.TeamDisplayName = null;
425 else if (body.TeamDisplayName is not null)
426 target.TeamDisplayName = string.IsNullOrWhiteSpace(body.TeamDisplayName)
427 ? null
428 : body.TeamDisplayName.Trim();
429
430 await db.SaveChangesAsync(ct).ConfigureAwait(false);
431 return (true, null);
432 }
433
434 public async Task<bool> PatchTeamAsync(string teamId, PatchTeamRequest body, CancellationToken ct)
435 {
436 var team = await db.Teams.FindAsync([teamId], ct).ConfigureAwait(false);
437 if (team is null)
438 return false;
439
440 if (!string.IsNullOrWhiteSpace(body.DisplayName))
441 team.DisplayName = body.DisplayName.Trim();
442
443 if (!string.IsNullOrWhiteSpace(body.JoinPolicy))
444 team.JoinPolicy = body.JoinPolicy.Trim();
445
446 if (!string.IsNullOrWhiteSpace(body.DefaultTeamRole))
447 team.DefaultTeamRole = body.DefaultTeamRole.Trim();
448
449 if (body.JoinPolicyJson is not null)
450 team.JoinPolicyJson = body.JoinPolicyJson;
451
452 await db.SaveChangesAsync(ct).ConfigureAwait(false);
453 return true;
454 }
455
456 public static string EffectiveDisplayName(string memberDisplayName, string? teamDisplayName) =>
457 string.IsNullOrWhiteSpace(teamDisplayName) ? memberDisplayName : teamDisplayName;
458}
459
View only · write via MCP/CIDE