Surface project ownership/role on the wire and gate the web UI by it

ProjectResponse now carries OwnerId and a server-resolved MyRole so
Editors/Reviewers see read-only fields and no delete/grant-management
affordances instead of only finding out via a 403 after the fact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuBH9QSv66DPXSSBERmPs6
This commit is contained in:
Claude
2026-08-16 15:37:50 +00:00
parent e598c18d67
commit e2a2e69631
19 changed files with 447 additions and 145 deletions
+2 -2
View File
@@ -99,7 +99,7 @@ public class NovelAgentToolset(
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
+ "Call this first in a conversation to ground yourself in what the book is.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(), "Project", projectId));
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(null), "Project", projectId));
yield return new AgentTool(
"update_project_brief",
@@ -121,7 +121,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "logline"),
JsonInput.String(input, "synopsis"),
JsonInput.String(input, "notes"),
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(), "Project", projectId));
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Project", projectId));
yield return new AgentTool(
"list_characters",
@@ -202,7 +202,7 @@ public class ImportAgentToolset(
Notes: JsonInput.String(input, "notes")), ct);
ProjectId = created.Id;
return created.ToResponse();
return created.ToResponse(null);
});
yield return new ImportAgentTool(
@@ -225,7 +225,7 @@ public class ImportAgentToolset(
return updated is null
? new ImportToolNotFound("Project", projectId)
: updated.ToResponse();
: updated.ToResponse(null);
});
yield return new ImportAgentTool(
+4 -2
View File
@@ -25,6 +25,8 @@ public record ProjectResponse(
string? Notes,
int? TargetWordCount,
ProjectPhase Phase,
Guid? OwnerId,
string? MyRole,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
@@ -103,7 +105,7 @@ file static class ProjectValidation
public static class ProjectMapping
{
public static ProjectResponse ToResponse(this Project p) => new(
public static ProjectResponse ToResponse(this Project p, string? myRole) => new(
p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes,
p.TargetWordCount, p.Phase, p.CreatedAt, p.UpdatedAt);
p.TargetWordCount, p.Phase, p.OwnerId, myRole, p.CreatedAt, p.UpdatedAt);
}
+23 -6
View File
@@ -1,5 +1,6 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Users;
namespace Novelly.Api.Projects;
@@ -15,20 +16,36 @@ public static class ProjectEndpoints
Results.Ok(await service.ListAsync(ct)))
.WithSummary("List all novel projects.");
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
{
var project = await service.GetAsync(id, ct);
if (project is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(project, ct);
return Results.Ok(project.ToResponse(myRole));
})
.WithSummary("Read a project's brief.");
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) =>
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
{
var created = (await service.CreateAsync(request, ct)).ToResponse();
var project = await service.CreateAsync(request, ct);
var myRole = await access.GetMyRoleAsync(project, ct);
var created = project.ToResponse(myRole);
return Results.Created($"/api/projects/{created.Id}", created);
})
.WithSummary("Create a novel project.");
group.MapPatch("/{id:guid}", async (
Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
Guid id, UpdateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
{
var project = await service.UpdateAsync(id, request, ct);
if (project is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(project, ct);
return Results.Ok(project.ToResponse(myRole));
})
.WithSummary("Update a project's brief.");
group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
@@ -51,6 +51,23 @@ public class ProjectAccessService(INovelDbContext db, INovelUserContext userCont
}
}
public async Task<string?> GetMyRoleAsync(Project project, CancellationToken ct = default)
{
if (userContext.GlobalRole == GlobalRole.Admin)
return "Admin";
if (project.OwnerId is not null && project.OwnerId == userContext.UserId)
return "Owner";
if (userContext.UserId is null)
return null;
var member = await db.ProjectMembers.AsNoTracking()
.FirstOrDefaultAsync(m => m.ProjectId == project.Id && m.UserId == userContext.UserId, ct);
return member?.ProjectRole.ToString();
}
public IQueryable<Project> VisibleProjects()
{
if (userContext.GlobalRole == GlobalRole.Admin)