| 1 | using System.Security.Claims; |
| 2 | using IntercomService.Contracts; |
| 3 | using IntercomService.Data; |
| 4 | using IntercomService.Services; |
| 5 | using Microsoft.EntityFrameworkCore; |
| 6 | |
| 7 | namespace IntercomService.Endpoints; |
| 8 | |
| 9 | public static partial class ApiEndpoints |
| 10 | { |
| 11 | private static void MapIdentity(RouteGroupBuilder api) |
| 12 | { |
| 13 | api.MapGet("/auth/providers", (AuthProviderRegistry registry) => |
| 14 | Results.Json(registry.ListProviders())); |
| 15 | |
| 16 | api.MapPost("/auth/redeem-invite", async ( |
| 17 | RedeemInviteRequest body, |
| 18 | TeamInviteService invites, |
| 19 | CancellationToken ct) => |
| 20 | { |
| 21 | if (string.IsNullOrWhiteSpace(body.InviteToken) || string.IsNullOrWhiteSpace(body.TeamId)) |
| 22 | return Results.BadRequest(new { error = "invite_token and team_id required" }); |
| 23 | |
| 24 | var valid = await invites.IsValidInviteAsync(body.TeamId, body.InviteToken, ct).ConfigureAwait(false); |
| 25 | return valid ? Results.Ok(new { status = "valid" }) : Results.BadRequest(new { error = "invalid_invite" }); |
| 26 | }); |
| 27 | |
| 28 | api.MapPost("/teams", async ( |
| 29 | CreateTeamRequest body, |
| 30 | TeamMembershipService teams, |
| 31 | IntercomDbContext db, |
| 32 | ClaimsPrincipal user, |
| 33 | CancellationToken ct) => |
| 34 | { |
| 35 | var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier); |
| 36 | if (string.IsNullOrWhiteSpace(memberId)) |
| 37 | return Results.Unauthorized(); |
| 38 | |
| 39 | if (string.IsNullOrWhiteSpace(body.TeamId)) |
| 40 | return Results.BadRequest(new { error = "team_id required" }); |
| 41 | |
| 42 | var teamId = body.TeamId.Trim(); |
| 43 | if (await db.Teams.AnyAsync(x => x.TeamId == teamId, ct).ConfigureAwait(false)) |
| 44 | return Results.Conflict(new { error = "team_exists" }); |
| 45 | |
| 46 | var team = await teams.CreateTeamAsync(teamId, body.DisplayName, memberId, ct) |
| 47 | .ConfigureAwait(false); |
| 48 | return Results.Json(new TeamDto(team.TeamId, team.DisplayName, team.JoinPolicy, team.DefaultTeamRole)); |
| 49 | }).RequireAuthorization(); |
| 50 | |
| 51 | api.MapPatch("/teams/{teamId}", async ( |
| 52 | string teamId, |
| 53 | PatchTeamRequest body, |
| 54 | TeamMembershipService teams, |
| 55 | ClaimsPrincipal user, |
| 56 | CancellationToken ct) => |
| 57 | { |
| 58 | var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!; |
| 59 | var role = await teams.GetTeamRoleAsync(teamId, memberId, ct).ConfigureAwait(false); |
| 60 | if (role is null || !TeamRoleAuthorization.CanPatchTeam(role)) |
| 61 | return Results.Forbid(); |
| 62 | |
| 63 | if (!string.IsNullOrWhiteSpace(body.JoinPolicy) |
| 64 | && !TeamRoleAuthorization.CanChangeJoinPolicy(role)) |
| 65 | return Results.Forbid(); |
| 66 | |
| 67 | var ok = await teams.PatchTeamAsync(teamId, body, ct).ConfigureAwait(false); |
| 68 | return ok ? Results.Ok() : Results.NotFound(); |
| 69 | }).RequireAuthorization(); |
| 70 | |
| 71 | api.MapGet("/teams/{teamId}/members", async ( |
| 72 | string teamId, |
| 73 | TeamMembershipService teams, |
| 74 | ClaimsPrincipal user, |
| 75 | CancellationToken ct) => |
| 76 | { |
| 77 | var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!; |
| 78 | var role = await teams.GetTeamRoleAsync(teamId, memberId, ct).ConfigureAwait(false); |
| 79 | if (role is null || !TeamRoleAuthorization.MeetsMinimum(role, TeamRoles.Member)) |
| 80 | return Results.Forbid(); |
| 81 | |
| 82 | var list = await teams.ListTeamMembersAsync(teamId, ct).ConfigureAwait(false); |
| 83 | return Results.Json(list); |
| 84 | }).RequireAuthorization(); |
| 85 | |
| 86 | api.MapPatch("/teams/{teamId}/members/me", async ( |
| 87 | string teamId, |
| 88 | PatchSelfTeamMemberRequest body, |
| 89 | TeamMembershipService teams, |
| 90 | ClaimsPrincipal user, |
| 91 | CancellationToken ct) => |
| 92 | { |
| 93 | var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!; |
| 94 | if (!await teams.IsMemberOfTeamAsync(teamId, memberId, ct).ConfigureAwait(false)) |
| 95 | return Results.Forbid(); |
| 96 | |
| 97 | var ok = await teams.PatchSelfTeamDisplayNameAsync(teamId, memberId, body, ct).ConfigureAwait(false); |
| 98 | return ok ? Results.Ok() : Results.NotFound(); |
| 99 | }).RequireAuthorization(); |
| 100 | |
| 101 | api.MapPatch("/teams/{teamId}/members/{targetMemberId}", async ( |
| 102 | string teamId, |
| 103 | string targetMemberId, |
| 104 | PatchTeamMemberRequest body, |
| 105 | TeamMembershipService teams, |
| 106 | ClaimsPrincipal user, |
| 107 | CancellationToken ct) => |
| 108 | { |
| 109 | var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!; |
| 110 | var (ok, error) = await teams |
| 111 | .PatchTeamMemberRoleAsync(teamId, targetMemberId, memberId, body, ct) |
| 112 | .ConfigureAwait(false); |
| 113 | |
| 114 | return error switch |
| 115 | { |
| 116 | "forbidden" => Results.Forbid(), |
| 117 | "member_not_found" => Results.NotFound(), |
| 118 | "last_owner" => Results.Conflict(new { error }), |
| 119 | "owner_promotion_forbidden" => Results.Forbid(), |
| 120 | "invalid_role" => Results.BadRequest(new { error }), |
| 121 | _ when ok => Results.Ok(), |
| 122 | _ => Results.BadRequest(new { error = error ?? "failed" }), |
| 123 | }; |
| 124 | }).RequireAuthorization(); |
| 125 | |
| 126 | api.MapPost("/teams/{teamId}/invites", async ( |
| 127 | string teamId, |
| 128 | CreateInviteRequest body, |
| 129 | TeamInviteService invites, |
| 130 | TeamMembershipService teams, |
| 131 | ClaimsPrincipal user, |
| 132 | CancellationToken ct) => |
| 133 | { |
| 134 | var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!; |
| 135 | var role = await teams.GetTeamRoleAsync(teamId, memberId, ct).ConfigureAwait(false); |
| 136 | if (role is null || !TeamRoleAuthorization.CanManageInvites(role)) |
| 137 | return Results.Forbid(); |
| 138 | |
| 139 | var teamRole = string.IsNullOrWhiteSpace(body.TeamRole) ? TeamRoles.Member : body.TeamRole.Trim(); |
| 140 | var ttl = TimeSpan.FromHours(Math.Clamp(body.TtlHours ?? 168, 1, 8760)); |
| 141 | var maxUses = Math.Clamp(body.MaxUses ?? 1, 1, 1000); |
| 142 | |
| 143 | var created = await invites |
| 144 | .CreateInviteAsync(teamId, memberId, teamRole, ttl, maxUses, ct) |
| 145 | .ConfigureAwait(false); |
| 146 | if (created is null) |
| 147 | return Results.BadRequest(new { error = "invalid_role" }); |
| 148 | |
| 149 | var (invite, token) = created.Value; |
| 150 | return Results.Json(new InviteDto( |
| 151 | invite.InviteId, |
| 152 | token, |
| 153 | invite.TeamRole, |
| 154 | invite.ExpiresAtUtc.ToString("O"), |
| 155 | invite.MaxUses)); |
| 156 | }).RequireAuthorization(); |
| 157 | |
| 158 | api.MapPost("/teams/{teamId}/agents", async ( |
| 159 | string teamId, |
| 160 | CreateAgentRequest body, |
| 161 | AgentAccountService agents, |
| 162 | TeamMembershipService teams, |
| 163 | ClaimsPrincipal user, |
| 164 | CancellationToken ct) => |
| 165 | { |
| 166 | var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!; |
| 167 | if (!await teams.EnsureAccessAsync(teamId, memberId, IsDevAuth(user), user.FindFirstValue("display_name"), ct) |
| 168 | .ConfigureAwait(false)) |
| 169 | return Results.Forbid(); |
| 170 | |
| 171 | var role = await teams.GetTeamRoleAsync(teamId, memberId, ct).ConfigureAwait(false); |
| 172 | if (role is null || !TeamRoleAuthorization.CanManageAgents(role)) |
| 173 | return Results.Forbid(); |
| 174 | |
| 175 | if (string.IsNullOrWhiteSpace(body.DisplayName)) |
| 176 | return Results.BadRequest(new { error = "display_name required" }); |
| 177 | |
| 178 | var agent = await agents |
| 179 | .ProvisionAsync(teamId, body.DisplayName, body.AvatarGlyph, ct) |
| 180 | .ConfigureAwait(false); |
| 181 | return agent is null ? Results.BadRequest() : Results.Json(agent); |
| 182 | }).RequireAuthorization(); |
| 183 | |
| 184 | api.MapPatch("/teams/{teamId}/agents/{agentMemberId}", async ( |
| 185 | string teamId, |
| 186 | string agentMemberId, |
| 187 | PatchAgentRequest body, |
| 188 | AgentAccountService agents, |
| 189 | TeamMembershipService teams, |
| 190 | ClaimsPrincipal user, |
| 191 | CancellationToken ct) => |
| 192 | { |
| 193 | var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!; |
| 194 | var role = await teams.GetTeamRoleAsync(teamId, memberId, ct).ConfigureAwait(false); |
| 195 | var isAdmin = role is not null && TeamRoleAuthorization.CanManageAgents(role); |
| 196 | var isSelf = string.Equals(memberId, agentMemberId, StringComparison.Ordinal); |
| 197 | |
| 198 | if (!isAdmin && !isSelf) |
| 199 | return Results.Forbid(); |
| 200 | |
| 201 | var ok = await agents.PatchAsync(teamId, agentMemberId, body, isAdmin, ct).ConfigureAwait(false); |
| 202 | return ok ? Results.Ok() : Results.NotFound(); |
| 203 | }).RequireAuthorization(); |
| 204 | |
| 205 | api.MapGet("/resolve/workspace-context", async ( |
| 206 | HttpRequest request, |
| 207 | WorkspaceResolveService resolve, |
| 208 | ClaimsPrincipal user, |
| 209 | CancellationToken ct) => |
| 210 | { |
| 211 | var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier); |
| 212 | if (string.IsNullOrWhiteSpace(memberId)) |
| 213 | return Results.Unauthorized(); |
| 214 | |
| 215 | var repoUrls = request.Query["repo_url"].ToArray().Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!).ToArray(); |
| 216 | if (repoUrls.Length == 0) |
| 217 | return Results.BadRequest(new { error = "repo_url required" }); |
| 218 | |
| 219 | var response = await resolve.ResolveAsync(memberId, repoUrls, ct).ConfigureAwait(false); |
| 220 | return Results.Json(response); |
| 221 | }).RequireAuthorization(); |
| 222 | |
| 223 | api.MapPost("/projects", async ( |
| 224 | CreateProjectRequest body, |
| 225 | IntercomDbContext db, |
| 226 | CancellationToken ct) => |
| 227 | { |
| 228 | if (string.IsNullOrWhiteSpace(body.ProjectId)) |
| 229 | return Results.BadRequest(new { error = "project_id required" }); |
| 230 | |
| 231 | var exists = await db.Projects.AnyAsync(x => x.ProjectId == body.ProjectId, ct).ConfigureAwait(false); |
| 232 | if (exists) |
| 233 | return Results.Conflict(new { error = "exists" }); |
| 234 | |
| 235 | var project = new ProjectEntity |
| 236 | { |
| 237 | ProjectId = body.ProjectId.Trim(), |
| 238 | DisplayName = string.IsNullOrWhiteSpace(body.DisplayName) ? body.ProjectId.Trim() : body.DisplayName.Trim(), |
| 239 | CreatedAtUtc = DateTimeOffset.UtcNow, |
| 240 | }; |
| 241 | db.Projects.Add(project); |
| 242 | await db.SaveChangesAsync(ct).ConfigureAwait(false); |
| 243 | return Results.Json(new ProjectDto(project.ProjectId, project.DisplayName)); |
| 244 | }).RequireAuthorization(); |
| 245 | |
| 246 | api.MapPut("/projects/{projectId}/repos", async ( |
| 247 | string projectId, |
| 248 | PutProjectReposRequest body, |
| 249 | IntercomDbContext db, |
| 250 | CancellationToken ct) => |
| 251 | { |
| 252 | var project = await db.Projects.FindAsync([projectId], ct).ConfigureAwait(false); |
| 253 | if (project is null) |
| 254 | return Results.NotFound(); |
| 255 | |
| 256 | var normalized = GitRepoUrlNormalizer.NormalizeMany(body.RepoUrls ?? []); |
| 257 | var existing = await db.ProjectRepos.Where(x => x.ProjectId == projectId).ToListAsync(ct) |
| 258 | .ConfigureAwait(false); |
| 259 | db.ProjectRepos.RemoveRange(existing); |
| 260 | |
| 261 | foreach (var url in normalized) |
| 262 | { |
| 263 | db.ProjectRepos.Add(new ProjectRepoEntity |
| 264 | { |
| 265 | ProjectId = projectId, |
| 266 | NormalizedRepoUrl = url, |
| 267 | LinkedAtUtc = DateTimeOffset.UtcNow, |
| 268 | }); |
| 269 | } |
| 270 | |
| 271 | await db.SaveChangesAsync(ct).ConfigureAwait(false); |
| 272 | return Results.Ok(new { repo_urls = normalized }); |
| 273 | }).RequireAuthorization(); |
| 274 | |
| 275 | api.MapPut("/teams/{teamId}/projects", async ( |
| 276 | string teamId, |
| 277 | PutTeamProjectsRequest body, |
| 278 | IntercomDbContext db, |
| 279 | TeamMembershipService teams, |
| 280 | ClaimsPrincipal user, |
| 281 | CancellationToken ct) => |
| 282 | { |
| 283 | var memberId = user.FindFirstValue(ClaimTypes.NameIdentifier)!; |
| 284 | var role = await teams.GetTeamRoleAsync(teamId, memberId, ct).ConfigureAwait(false); |
| 285 | if (role is null || !TeamRoleAuthorization.CanPatchTeam(role)) |
| 286 | return Results.Forbid(); |
| 287 | |
| 288 | var team = await db.Teams.FindAsync([teamId], ct).ConfigureAwait(false); |
| 289 | if (team is null) |
| 290 | return Results.NotFound(); |
| 291 | |
| 292 | var projectIds = (body.ProjectIds ?? []) |
| 293 | .Where(x => !string.IsNullOrWhiteSpace(x)) |
| 294 | .Select(x => x.Trim()) |
| 295 | .Distinct(StringComparer.Ordinal) |
| 296 | .ToList(); |
| 297 | |
| 298 | var existing = await db.TeamProjects.Where(x => x.TeamId == teamId).ToListAsync(ct) |
| 299 | .ConfigureAwait(false); |
| 300 | db.TeamProjects.RemoveRange(existing); |
| 301 | |
| 302 | foreach (var pid in projectIds) |
| 303 | { |
| 304 | if (!await db.Projects.AnyAsync(x => x.ProjectId == pid, ct).ConfigureAwait(false)) |
| 305 | continue; |
| 306 | |
| 307 | db.TeamProjects.Add(new TeamProjectEntity |
| 308 | { |
| 309 | TeamId = teamId, |
| 310 | ProjectId = pid, |
| 311 | LinkedAtUtc = DateTimeOffset.UtcNow, |
| 312 | }); |
| 313 | } |
| 314 | |
| 315 | await db.SaveChangesAsync(ct).ConfigureAwait(false); |
| 316 | return Results.Ok(new { project_ids = projectIds }); |
| 317 | }).RequireAuthorization(); |
| 318 | } |
| 319 | } |
| 320 | |