Files
novelly/src/Novelly.Api/Projects/ProjectService.cs
T
James Wampler 23348327a9 Remove Scenes, group beats by multiple characters; strip comments repo-wide
Drop the Scene entity/grouping in favor of chapters carrying prose directly
and beats belonging to many characters. Add markdown editor + character
multi-select components to the web client. Remove all XML doc and inline
comments across the touched C#/TS/CSS files in favor of self-documenting
names, and record that convention in CLAUDE.md. Add .mcp.json (local MCP
server config, no secrets) and ignore .idea/.
2026-08-11 21:05:13 -07:00

128 lines
4.0 KiB
C#

using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
namespace Novelly.Api.Projects;
public class ProjectService(
INovelDbContext db,
ILogger<ProjectService> logger,
IModelValidator<CreateProjectRequest> createValidator,
IModelValidator<UpdateProjectRequest> updateValidator)
{
public async Task<IReadOnlyList<ProjectSummaryResponse>> ListAsync(CancellationToken ct = default)
{
logger.LogInformation("Listing projects");
return await db.Projects
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new ProjectSummaryResponse(
p.Id,
p.Title,
p.Author,
p.Genre,
p.Logline,
p.TargetWordCount,
p.Phase,
p.Characters.Count,
p.Chapters.Count,
p.Chapters.Sum(c => (int?)c.WordCount) ?? 0,
p.UpdatedAt))
.ToListAsync(ct);
}
public async Task<Project?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting project {ProjectId}", id);
return await FindAsync(id, ct);
}
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Creating project {Title}", request.Title);
var project = new Project
{
Title = request.Title,
Author = request.Author,
Genre = request.Genre,
Logline = request.Logline,
Synopsis = request.Synopsis,
Notes = request.Notes,
TargetWordCount = request.TargetWordCount
};
db.Projects.Add(project);
await db.SaveChangesAsync(ct);
return project;
}
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Updating project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null)
{
return null;
}
project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
project.Author = Patch.Apply(project.Author, request.Author);
project.Genre = Patch.Apply(project.Genre, request.Genre);
project.Logline = Patch.Apply(project.Logline, request.Logline);
project.Synopsis = Patch.Apply(project.Synopsis, request.Synopsis);
project.Notes = Patch.Apply(project.Notes, request.Notes);
project.TargetWordCount = request.TargetWordCount ?? project.TargetWordCount;
project.Phase = request.Phase ?? project.Phase;
project.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return project;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null)
{
return false;
}
db.Projects.Remove(project);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<Project?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding project {ProjectId}", id);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
if (project is null)
{
logger.LogInformation("Project {ProjectId} not found", id);
}
else
{
logger.LogDebug("Found project {ProjectId}", id);
}
return project;
}
}