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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user