Add GitHub-style activity contribution graph
Record create/update/delete events across novel content (chapters, beats, characters, arc stages, tags, locations, questions) into an append-only ActivityEvent log, aggregate by UTC day, and surface as a heatmap on the novels list and each novel's dashboard. Backfills history from existing CreatedAt timestamps on first boot after the migration.
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Data;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Activity;
|
||||||
|
|
||||||
|
public static class ActivityBackfill
|
||||||
|
{
|
||||||
|
public static async Task RunAsync(INovelDbContext db, ILogger logger, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (await db.ActivityEvents.AnyAsync(ct))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Backfilling activity events from existing rows");
|
||||||
|
|
||||||
|
var events = new List<ActivityEvent>();
|
||||||
|
|
||||||
|
var novels = await db.Novels.AsNoTracking().Select(n => new { n.Id, n.CreatedAt }).ToListAsync(ct);
|
||||||
|
events.AddRange(novels.Select(n => Backfilled(n.Id, ActivityEntityKind.Novel, n.Id, n.CreatedAt)));
|
||||||
|
|
||||||
|
var chapters = await db.Chapters.AsNoTracking().Select(c => new { c.Id, c.NovelId, c.CreatedAt, c.WordCount }).ToListAsync(ct);
|
||||||
|
events.AddRange(chapters.Select(c => Backfilled(c.NovelId, ActivityEntityKind.Chapter, c.Id, c.CreatedAt, c.WordCount)));
|
||||||
|
|
||||||
|
var characters = await db.Characters.AsNoTracking().Select(c => new { c.Id, c.NovelId, c.CreatedAt }).ToListAsync(ct);
|
||||||
|
events.AddRange(characters.Select(c => Backfilled(c.NovelId, ActivityEntityKind.Character, c.Id, c.CreatedAt)));
|
||||||
|
|
||||||
|
var arcStages = await db.CharacterArcStages.AsNoTracking().Select(s => new { s.Id, s.CreatedAt, NovelId = s.Character!.NovelId }).ToListAsync(ct);
|
||||||
|
events.AddRange(arcStages.Select(s => Backfilled(s.NovelId, ActivityEntityKind.ArcStage, s.Id, s.CreatedAt)));
|
||||||
|
|
||||||
|
var beats = await db.Beats.AsNoTracking().Select(b => new { b.Id, b.CreatedAt, NovelId = b.Chapter!.NovelId }).ToListAsync(ct);
|
||||||
|
events.AddRange(beats.Select(b => Backfilled(b.NovelId, ActivityEntityKind.Beat, b.Id, b.CreatedAt)));
|
||||||
|
|
||||||
|
var tags = await db.Tags.AsNoTracking().Select(t => new { t.Id, t.NovelId, t.CreatedAt }).ToListAsync(ct);
|
||||||
|
events.AddRange(tags.Select(t => Backfilled(t.NovelId, ActivityEntityKind.Tag, t.Id, t.CreatedAt)));
|
||||||
|
|
||||||
|
var locations = await db.Locations.AsNoTracking().Select(l => new { l.Id, l.NovelId, l.CreatedAt }).ToListAsync(ct);
|
||||||
|
events.AddRange(locations.Select(l => Backfilled(l.NovelId, ActivityEntityKind.Location, l.Id, l.CreatedAt)));
|
||||||
|
|
||||||
|
var questions = await db.OpenQuestions.AsNoTracking().Select(q => new { q.Id, q.NovelId, q.CreatedAt }).ToListAsync(ct);
|
||||||
|
events.AddRange(questions.Select(q => Backfilled(q.NovelId, ActivityEntityKind.Question, q.Id, q.CreatedAt)));
|
||||||
|
|
||||||
|
if (events.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ActivityEvents.AddRange(events);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
logger.LogInformation("Backfilled {Count} activity events", events.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ActivityEvent Backfilled(Guid novelId, ActivityEntityKind kind, Guid entityId, DateTimeOffset occurredAt, int wordDelta = 0) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
NovelId = novelId,
|
||||||
|
UserId = null,
|
||||||
|
OccurredAt = occurredAt,
|
||||||
|
DayKey = ActivityDayKey.For(occurredAt),
|
||||||
|
EntityKind = kind,
|
||||||
|
Action = ActivityAction.Created,
|
||||||
|
EntityId = entityId,
|
||||||
|
WordDelta = wordDelta
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
namespace Novelly.Api.Activity;
|
||||||
|
|
||||||
|
public record ActivityDayResponse(DateOnly Date, int Words, int Edits);
|
||||||
|
|
||||||
|
public record ActivityCalendarResponse(DateOnly From, DateOnly To, int TotalWords, int TotalEdits, IReadOnlyList<ActivityDayResponse> Days);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Novelly.Api.Common;
|
||||||
|
using Novelly.Api.Common.Validation;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Activity;
|
||||||
|
|
||||||
|
public static class ActivityEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapActivityEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/activity").WithTags("Activity")
|
||||||
|
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||||
|
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||||
|
|
||||||
|
novelScoped.MapGet("/", async (Guid novelId, int? days, ActivityService service, CancellationToken ct) =>
|
||||||
|
Results.Ok(await service.GetForNovelAsync(novelId, days, ct)))
|
||||||
|
.WithSummary("Get a novel's daily activity calendar.");
|
||||||
|
|
||||||
|
var mine = app.MapGroup("/api/activity").WithTags("Activity")
|
||||||
|
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||||
|
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||||
|
|
||||||
|
mine.MapGet("/", async (int? days, ActivityService service, CancellationToken ct) =>
|
||||||
|
Results.Ok(await service.GetForCurrentUserAsync(days, ct)))
|
||||||
|
.WithSummary("Get the current user's daily activity calendar across every visible novel.");
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Novelly.Api.Novels;
|
||||||
|
using Novelly.Api.Users;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Activity;
|
||||||
|
|
||||||
|
public enum ActivityEntityKind
|
||||||
|
{
|
||||||
|
Novel,
|
||||||
|
Chapter,
|
||||||
|
Beat,
|
||||||
|
Character,
|
||||||
|
ArcStage,
|
||||||
|
Tag,
|
||||||
|
Location,
|
||||||
|
Question
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ActivityAction
|
||||||
|
{
|
||||||
|
Created,
|
||||||
|
Updated,
|
||||||
|
Deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ActivityEvent
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
public Guid NovelId { get; set; }
|
||||||
|
public Novel? Novel { get; set; }
|
||||||
|
|
||||||
|
public Guid? UserId { get; set; }
|
||||||
|
public NovellyUser? User { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public int DayKey { get; set; } = ActivityDayKey.For(DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
public ActivityEntityKind EntityKind { get; set; }
|
||||||
|
public ActivityAction Action { get; set; }
|
||||||
|
public Guid EntityId { get; set; }
|
||||||
|
|
||||||
|
public int WordDelta { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ActivityDayKey
|
||||||
|
{
|
||||||
|
public static int For(DateTimeOffset occurredAt) =>
|
||||||
|
occurredAt.UtcDateTime.Year * 10000 + occurredAt.UtcDateTime.Month * 100 + occurredAt.UtcDateTime.Day;
|
||||||
|
|
||||||
|
public static int For(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
|
||||||
|
|
||||||
|
public static DateOnly ToDate(int dayKey) => new(dayKey / 10000, dayKey / 100 % 100, dayKey % 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ActivityEventEntityTypeConfiguration : IEntityTypeConfiguration<ActivityEvent>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ActivityEvent> entity)
|
||||||
|
{
|
||||||
|
entity.Property(e => e.EntityKind).HasConversion<string>().HasMaxLength(32);
|
||||||
|
entity.Property(e => e.Action).HasConversion<string>().HasMaxLength(32);
|
||||||
|
|
||||||
|
entity.HasIndex(e => new { e.NovelId, e.DayKey });
|
||||||
|
entity.HasIndex(e => new { e.UserId, e.DayKey });
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Novel).WithMany()
|
||||||
|
.HasForeignKey(e => e.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.User).WithMany()
|
||||||
|
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Novelly.Api.Data;
|
||||||
|
using Novelly.Api.Users;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Activity;
|
||||||
|
|
||||||
|
public class ActivityLog(INovelDbContext db, INovelUserContext userContext, ILogger<ActivityLog> logger)
|
||||||
|
{
|
||||||
|
public void Record(Guid novelId, ActivityEntityKind kind, ActivityAction action, Guid entityId, int wordDelta = 0)
|
||||||
|
{
|
||||||
|
var occurredAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
logger.LogDebug(
|
||||||
|
"Recording activity {Action} on {EntityKind} {EntityId} for novel {NovelId}, word delta {WordDelta}",
|
||||||
|
action, kind, entityId, novelId, wordDelta);
|
||||||
|
|
||||||
|
db.ActivityEvents.Add(new ActivityEvent
|
||||||
|
{
|
||||||
|
NovelId = novelId,
|
||||||
|
UserId = userContext.UserId,
|
||||||
|
OccurredAt = occurredAt,
|
||||||
|
DayKey = ActivityDayKey.For(occurredAt),
|
||||||
|
EntityKind = kind,
|
||||||
|
Action = action,
|
||||||
|
EntityId = entityId,
|
||||||
|
WordDelta = wordDelta
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Common;
|
||||||
|
using Novelly.Api.Data;
|
||||||
|
using Novelly.Api.Users;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Activity;
|
||||||
|
|
||||||
|
public class ActivityService(INovelDbContext db, NovelAccessService access, ILogger<ActivityService> logger)
|
||||||
|
{
|
||||||
|
private const int MinDays = 1;
|
||||||
|
private const int MaxDays = 400;
|
||||||
|
private const int DefaultDays = 365;
|
||||||
|
|
||||||
|
public async Task<ActivityCalendarResponse> GetForNovelAsync(Guid novelId, int? days, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Guard.Default(novelId, nameof(novelId));
|
||||||
|
|
||||||
|
logger.LogInformation("Getting activity calendar for novel {NovelId}", novelId);
|
||||||
|
|
||||||
|
await access.RequireAsync(novelId, NovelPermission.Read, ct);
|
||||||
|
|
||||||
|
return await BuildCalendarAsync(db.ActivityEvents.Where(e => e.NovelId == novelId), days, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ActivityCalendarResponse> GetForCurrentUserAsync(int? days, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Getting activity calendar for current user");
|
||||||
|
|
||||||
|
var visibleNovelIds = await access.VisibleNovels().Select(n => n.Id).ToListAsync(ct);
|
||||||
|
|
||||||
|
return await BuildCalendarAsync(db.ActivityEvents.Where(e => visibleNovelIds.Contains(e.NovelId)), days, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ActivityCalendarResponse> BuildCalendarAsync(
|
||||||
|
IQueryable<ActivityEvent> query, int? requestedDays, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var days = Math.Clamp(requestedDays ?? DefaultDays, MinDays, MaxDays);
|
||||||
|
var to = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var from = to.AddDays(-(days - 1));
|
||||||
|
var fromKey = ActivityDayKey.For(from);
|
||||||
|
|
||||||
|
var grouped = await query
|
||||||
|
.Where(e => e.DayKey >= fromKey)
|
||||||
|
.GroupBy(e => e.DayKey)
|
||||||
|
.Select(g => new { DayKey = g.Key, Words = g.Sum(e => e.WordDelta), Edits = g.Count() })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var responseDays = grouped
|
||||||
|
.OrderBy(g => g.DayKey)
|
||||||
|
.Select(g => new ActivityDayResponse(ActivityDayKey.ToDate(g.DayKey), g.Words, g.Edits))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new ActivityCalendarResponse(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
responseDays.Sum(d => d.Words),
|
||||||
|
responseDays.Sum(d => d.Edits),
|
||||||
|
responseDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
using Novelly.Api.Chapters;
|
using Novelly.Api.Chapters;
|
||||||
using Novelly.Api.Characters;
|
using Novelly.Api.Characters;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
@@ -13,6 +14,7 @@ public class BeatService(
|
|||||||
INovelDbContext db,
|
INovelDbContext db,
|
||||||
NovelAccessService access,
|
NovelAccessService access,
|
||||||
TagService tags,
|
TagService tags,
|
||||||
|
ActivityLog activity,
|
||||||
ILogger<BeatService> logger,
|
ILogger<BeatService> logger,
|
||||||
IModelValidator<CreateBeatRequest> createValidator,
|
IModelValidator<CreateBeatRequest> createValidator,
|
||||||
IModelValidator<UpdateBeatRequest> updateValidator,
|
IModelValidator<UpdateBeatRequest> updateValidator,
|
||||||
@@ -119,6 +121,7 @@ public class BeatService(
|
|||||||
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
db.Beats.Add(beat);
|
db.Beats.Add(beat);
|
||||||
|
activity.Record(chapter.NovelId, ActivityEntityKind.Beat, ActivityAction.Created, beat.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return (await FindAsync(beat.Id, ct))!;
|
return (await FindAsync(beat.Id, ct))!;
|
||||||
@@ -164,6 +167,7 @@ public class BeatService(
|
|||||||
beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
|
beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activity.Record(chapter.NovelId, ActivityEntityKind.Beat, ActivityAction.Updated, beat.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return (await FindAsync(id, ct))!;
|
return (await FindAsync(id, ct))!;
|
||||||
}
|
}
|
||||||
@@ -183,7 +187,11 @@ public class BeatService(
|
|||||||
await RequireBeatAccessAsync(beat, NovelPermission.DeleteContent, ct);
|
await RequireBeatAccessAsync(beat, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
|
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
|
||||||
if (chapter is not null) chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
if (chapter is not null)
|
||||||
|
{
|
||||||
|
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
activity.Record(chapter.NovelId, ActivityEntityKind.Beat, ActivityAction.Deleted, beat.Id);
|
||||||
|
}
|
||||||
|
|
||||||
db.Beats.Remove(beat);
|
db.Beats.Remove(beat);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Common.Validation;
|
using Novelly.Api.Common.Validation;
|
||||||
using Novelly.Api.Data;
|
using Novelly.Api.Data;
|
||||||
@@ -13,6 +14,7 @@ public class ChapterService(
|
|||||||
NovelAccessService access,
|
NovelAccessService access,
|
||||||
TagService tags,
|
TagService tags,
|
||||||
LocationService locations,
|
LocationService locations,
|
||||||
|
ActivityLog activity,
|
||||||
ILogger<ChapterService> logger,
|
ILogger<ChapterService> logger,
|
||||||
IModelValidator<CreateChapterRequest> createValidator,
|
IModelValidator<CreateChapterRequest> createValidator,
|
||||||
IModelValidator<UpdateChapterRequest> updateValidator)
|
IModelValidator<UpdateChapterRequest> updateValidator)
|
||||||
@@ -90,6 +92,7 @@ public class ChapterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.Chapters.Add(chapter);
|
db.Chapters.Add(chapter);
|
||||||
|
activity.Record(novelId, ActivityEntityKind.Chapter, ActivityAction.Created, chapter.Id, chapter.WordCount);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return (await FindAsync(chapter.Id, ct))!;
|
return (await FindAsync(chapter.Id, ct))!;
|
||||||
@@ -118,6 +121,8 @@ public class ChapterService(
|
|||||||
chapter.Status = request.Status ?? chapter.Status;
|
chapter.Status = request.Status ?? chapter.Status;
|
||||||
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
|
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
|
||||||
|
|
||||||
|
var wordCountBeforeEdit = chapter.WordCount;
|
||||||
|
|
||||||
if (request.Prose is not null)
|
if (request.Prose is not null)
|
||||||
{
|
{
|
||||||
chapter.Prose = Patch.Apply(chapter.Prose, request.Prose);
|
chapter.Prose = Patch.Apply(chapter.Prose, request.Prose);
|
||||||
@@ -136,6 +141,7 @@ public class ChapterService(
|
|||||||
chapter.Locations = await locations.ResolveAsync(chapter.NovelId, locationNames, ct);
|
chapter.Locations = await locations.ResolveAsync(chapter.NovelId, locationNames, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Updated, chapter.Id, chapter.WordCount - wordCountBeforeEdit);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return (await FindAsync(id, ct))!;
|
return (await FindAsync(id, ct))!;
|
||||||
}
|
}
|
||||||
@@ -155,6 +161,7 @@ public class ChapterService(
|
|||||||
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
|
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
db.Chapters.Remove(chapter);
|
db.Chapters.Remove(chapter);
|
||||||
|
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Deleted, chapter.Id, -chapter.WordCount);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Common.Validation;
|
using Novelly.Api.Common.Validation;
|
||||||
using Novelly.Api.Data;
|
using Novelly.Api.Data;
|
||||||
@@ -9,6 +10,7 @@ namespace Novelly.Api.Characters;
|
|||||||
public class CharacterArcService(
|
public class CharacterArcService(
|
||||||
INovelDbContext db,
|
INovelDbContext db,
|
||||||
NovelAccessService access,
|
NovelAccessService access,
|
||||||
|
ActivityLog activity,
|
||||||
ILogger<CharacterArcService> logger,
|
ILogger<CharacterArcService> logger,
|
||||||
IModelValidator<CreateArcStageRequest> createValidator,
|
IModelValidator<CreateArcStageRequest> createValidator,
|
||||||
IModelValidator<UpdateArcStageRequest> updateValidator,
|
IModelValidator<UpdateArcStageRequest> updateValidator,
|
||||||
@@ -76,6 +78,7 @@ public class CharacterArcService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
db.CharacterArcStages.Add(stage);
|
db.CharacterArcStages.Add(stage);
|
||||||
|
activity.Record(character.NovelId, ActivityEntityKind.ArcStage, ActivityAction.Created, stage.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return (await FindAsync(stage.Id, ct))!;
|
return (await FindAsync(stage.Id, ct))!;
|
||||||
@@ -112,6 +115,7 @@ public class CharacterArcService(
|
|||||||
stage.ChapterId = request.ChapterId ?? stage.ChapterId;
|
stage.ChapterId = request.ChapterId ?? stage.ChapterId;
|
||||||
stage.UpdatedAt = DateTimeOffset.UtcNow;
|
stage.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
activity.Record(character.NovelId, ActivityEntityKind.ArcStage, ActivityAction.Updated, stage.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return (await FindAsync(id, ct))!;
|
return (await FindAsync(id, ct))!;
|
||||||
}
|
}
|
||||||
@@ -128,9 +132,11 @@ public class CharacterArcService(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.DeleteContent, ct);
|
var novelId = await db.Characters.Where(c => c.Id == stage.CharacterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
|
||||||
|
await access.RequireAsync(novelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
db.CharacterArcStages.Remove(stage);
|
db.CharacterArcStages.Remove(stage);
|
||||||
|
activity.Record(novelId, ActivityEntityKind.ArcStage, ActivityAction.Deleted, stage.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Common.Validation;
|
using Novelly.Api.Common.Validation;
|
||||||
using Novelly.Api.Data;
|
using Novelly.Api.Data;
|
||||||
@@ -11,6 +12,7 @@ public class CharacterService(
|
|||||||
INovelDbContext db,
|
INovelDbContext db,
|
||||||
NovelAccessService access,
|
NovelAccessService access,
|
||||||
TagService tags,
|
TagService tags,
|
||||||
|
ActivityLog activity,
|
||||||
ILogger<CharacterService> logger,
|
ILogger<CharacterService> logger,
|
||||||
IModelValidator<CreateCharacterRequest> createValidator,
|
IModelValidator<CreateCharacterRequest> createValidator,
|
||||||
IModelValidator<UpdateCharacterRequest> updateValidator,
|
IModelValidator<UpdateCharacterRequest> updateValidator,
|
||||||
@@ -101,6 +103,7 @@ public class CharacterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.Characters.Add(character);
|
db.Characters.Add(character);
|
||||||
|
activity.Record(novelId, ActivityEntityKind.Character, ActivityAction.Created, character.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return (await FindAsync(character.Id, ct))!;
|
return (await FindAsync(character.Id, ct))!;
|
||||||
@@ -147,6 +150,7 @@ public class CharacterService(
|
|||||||
character.Aliases = [.. aliases];
|
character.Aliases = [.. aliases];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Updated, character.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return (await FindAsync(id, ct))!;
|
return (await FindAsync(id, ct))!;
|
||||||
}
|
}
|
||||||
@@ -166,6 +170,7 @@ public class CharacterService(
|
|||||||
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
|
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
db.Characters.Remove(character);
|
db.Characters.Remove(character);
|
||||||
|
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Deleted, character.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
using Novelly.Api.Agent;
|
using Novelly.Api.Agent;
|
||||||
using Novelly.Api.Beats;
|
using Novelly.Api.Beats;
|
||||||
using Novelly.Api.Chapters;
|
using Novelly.Api.Chapters;
|
||||||
@@ -52,6 +53,8 @@ public static class NovellyServiceRegistration
|
|||||||
services.AddHttpContextAccessor();
|
services.AddHttpContextAccessor();
|
||||||
services.AddScoped<INovelUserContext, NovelUserContext>();
|
services.AddScoped<INovelUserContext, NovelUserContext>();
|
||||||
services.AddScoped<NovelAccessService>();
|
services.AddScoped<NovelAccessService>();
|
||||||
|
services.AddScoped<ActivityLog>();
|
||||||
|
services.AddScoped<ActivityService>();
|
||||||
|
|
||||||
services.ConfigureApplicationCookie(options =>
|
services.ConfigureApplicationCookie(options =>
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Novelly.Api.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ActivityEvents : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ActivityEvents",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
NovelId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
OccurredAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
DayKey = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
EntityKind = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||||
|
Action = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||||
|
EntityId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
WordDelta = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ActivityEvents", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ActivityEvents_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ActivityEvents_Novels_NovelId",
|
||||||
|
column: x => x.NovelId,
|
||||||
|
principalTable: "Novels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ActivityEvents_NovelId_DayKey",
|
||||||
|
table: "ActivityEvents",
|
||||||
|
columns: new[] { "NovelId", "DayKey" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ActivityEvents_UserId_DayKey",
|
||||||
|
table: "ActivityEvents",
|
||||||
|
columns: new[] { "UserId", "DayKey" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ActivityEvents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,49 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.ToTable("AspNetUserTokens", (string)null);
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Novelly.Api.Activity.ActivityEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("DayKey")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EntityKind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("NovelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("OccurredAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WordDelta")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NovelId", "DayKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "DayKey");
|
||||||
|
|
||||||
|
b.ToTable("ActivityEvents");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1001,6 +1044,24 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Novelly.Api.Activity.ActivityEvent", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("NovelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Novelly.Api.Users.NovellyUser", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("Novel");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
using Novelly.Api.Agent;
|
using Novelly.Api.Agent;
|
||||||
using Novelly.Api.Beats;
|
using Novelly.Api.Beats;
|
||||||
using Novelly.Api.Chapters;
|
using Novelly.Api.Chapters;
|
||||||
@@ -33,6 +34,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) : Identity
|
|||||||
public DbSet<ImportJob> ImportJobs => Set<ImportJob>();
|
public DbSet<ImportJob> ImportJobs => Set<ImportJob>();
|
||||||
public DbSet<Genre> Genres => Set<Genre>();
|
public DbSet<Genre> Genres => Set<Genre>();
|
||||||
public DbSet<NovelMember> NovelMembers => Set<NovelMember>();
|
public DbSet<NovelMember> NovelMembers => Set<NovelMember>();
|
||||||
|
public DbSet<ActivityEvent> ActivityEvents => Set<ActivityEvent>();
|
||||||
|
|
||||||
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken);
|
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ public interface INovelDbContext
|
|||||||
DbSet<Genre> Genres { get; }
|
DbSet<Genre> Genres { get; }
|
||||||
DbSet<NovellyUser> Users { get; }
|
DbSet<NovellyUser> Users { get; }
|
||||||
DbSet<NovelMember> NovelMembers { get; }
|
DbSet<NovelMember> NovelMembers { get; }
|
||||||
|
DbSet<ActivityEvent> ActivityEvents { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Common.Validation;
|
using Novelly.Api.Common.Validation;
|
||||||
using Novelly.Api.Data;
|
using Novelly.Api.Data;
|
||||||
@@ -9,6 +10,7 @@ namespace Novelly.Api.Locations;
|
|||||||
public class LocationService(
|
public class LocationService(
|
||||||
INovelDbContext db,
|
INovelDbContext db,
|
||||||
NovelAccessService access,
|
NovelAccessService access,
|
||||||
|
ActivityLog activity,
|
||||||
ILogger<LocationService> logger,
|
ILogger<LocationService> logger,
|
||||||
IModelValidator<CreateLocationRequest> createValidator,
|
IModelValidator<CreateLocationRequest> createValidator,
|
||||||
IModelValidator<UpdateLocationRequest> updateValidator)
|
IModelValidator<UpdateLocationRequest> updateValidator)
|
||||||
@@ -75,6 +77,7 @@ public class LocationService(
|
|||||||
|
|
||||||
var location = new Location { NovelId = novelId, Name = name };
|
var location = new Location { NovelId = novelId, Name = name };
|
||||||
db.Locations.Add(location);
|
db.Locations.Add(location);
|
||||||
|
activity.Record(novelId, ActivityEntityKind.Location, ActivityAction.Created, location.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
@@ -110,6 +113,7 @@ public class LocationService(
|
|||||||
location.Name = name;
|
location.Name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Updated, location.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
@@ -130,6 +134,7 @@ public class LocationService(
|
|||||||
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
|
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
db.Locations.Remove(location);
|
db.Locations.Remove(location);
|
||||||
|
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Deleted, location.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Common.Validation;
|
using Novelly.Api.Common.Validation;
|
||||||
using Novelly.Api.Data;
|
using Novelly.Api.Data;
|
||||||
@@ -10,6 +11,7 @@ public class NovelService(
|
|||||||
INovelDbContext db,
|
INovelDbContext db,
|
||||||
NovelAccessService access,
|
NovelAccessService access,
|
||||||
INovelUserContext userContext,
|
INovelUserContext userContext,
|
||||||
|
ActivityLog activity,
|
||||||
ILogger<NovelService> logger,
|
ILogger<NovelService> logger,
|
||||||
IModelValidator<CreateNovelRequest> createValidator,
|
IModelValidator<CreateNovelRequest> createValidator,
|
||||||
IModelValidator<UpdateNovelRequest> updateValidator)
|
IModelValidator<UpdateNovelRequest> updateValidator)
|
||||||
@@ -69,6 +71,7 @@ public class NovelService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
db.Novels.Add(novel);
|
db.Novels.Add(novel);
|
||||||
|
activity.Record(novel.Id, ActivityEntityKind.Novel, ActivityAction.Created, novel.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return novel;
|
return novel;
|
||||||
}
|
}
|
||||||
@@ -96,6 +99,7 @@ public class NovelService(
|
|||||||
novel.Phase = request.Phase ?? novel.Phase;
|
novel.Phase = request.Phase ?? novel.Phase;
|
||||||
novel.UpdatedAt = DateTimeOffset.UtcNow;
|
novel.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
activity.Record(novel.Id, ActivityEntityKind.Novel, ActivityAction.Updated, novel.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return novel;
|
return novel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.AspNetCore.Diagnostics;
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
using Novelly.Api.Agent;
|
using Novelly.Api.Agent;
|
||||||
using Novelly.Api.Beats;
|
using Novelly.Api.Beats;
|
||||||
using Novelly.Api.Chapters;
|
using Novelly.Api.Chapters;
|
||||||
@@ -66,6 +67,7 @@ using (var scope = app.Services.CreateScope())
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ServiceUser.EnsureSeededAsync(db, builder.Configuration[ServiceApiKeyAuthenticationHandler.ConfigurationKey], app.Logger);
|
await ServiceUser.EnsureSeededAsync(db, builder.Configuration[ServiceApiKeyAuthenticationHandler.ConfigurationKey], app.Logger);
|
||||||
|
await ActivityBackfill.RunAsync(db, app.Logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
@@ -119,7 +121,8 @@ app.MapNovelEndpoints()
|
|||||||
.MapGenreEndpoints()
|
.MapGenreEndpoints()
|
||||||
.MapOpenQuestionEndpoints()
|
.MapOpenQuestionEndpoints()
|
||||||
.MapAgentEndpoints()
|
.MapAgentEndpoints()
|
||||||
.MapImportEndpoints();
|
.MapImportEndpoints()
|
||||||
|
.MapActivityEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
using Novelly.Api.Chapters;
|
using Novelly.Api.Chapters;
|
||||||
using Novelly.Api.Characters;
|
using Novelly.Api.Characters;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
@@ -11,6 +12,7 @@ namespace Novelly.Api.Questions;
|
|||||||
public class OpenQuestionService(
|
public class OpenQuestionService(
|
||||||
INovelDbContext db,
|
INovelDbContext db,
|
||||||
NovelAccessService access,
|
NovelAccessService access,
|
||||||
|
ActivityLog activity,
|
||||||
ILogger<OpenQuestionService> logger,
|
ILogger<OpenQuestionService> logger,
|
||||||
IModelValidator<CreateOpenQuestionRequest> createValidator,
|
IModelValidator<CreateOpenQuestionRequest> createValidator,
|
||||||
IModelValidator<UpdateOpenQuestionRequest> updateValidator,
|
IModelValidator<UpdateOpenQuestionRequest> updateValidator,
|
||||||
@@ -102,6 +104,7 @@ public class OpenQuestionService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
db.OpenQuestions.Add(question);
|
db.OpenQuestions.Add(question);
|
||||||
|
activity.Record(novelId, ActivityEntityKind.Question, ActivityAction.Created, question.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return (await FindAsync(question.Id, ct))!;
|
return (await FindAsync(question.Id, ct))!;
|
||||||
@@ -131,6 +134,7 @@ public class OpenQuestionService(
|
|||||||
question.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? question.CharacterId;
|
question.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? question.CharacterId;
|
||||||
question.UpdatedAt = DateTimeOffset.UtcNow;
|
question.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
activity.Record(question.NovelId, ActivityEntityKind.Question, ActivityAction.Updated, question.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return (await FindAsync(id, ct))!;
|
return (await FindAsync(id, ct))!;
|
||||||
}
|
}
|
||||||
@@ -185,6 +189,7 @@ public class OpenQuestionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activity.Record(question.NovelId, ActivityEntityKind.Question, ActivityAction.Updated, question.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return (await FindAsync(id, ct))!;
|
return (await FindAsync(id, ct))!;
|
||||||
}
|
}
|
||||||
@@ -207,6 +212,7 @@ public class OpenQuestionService(
|
|||||||
question.ResolvedAt = null;
|
question.ResolvedAt = null;
|
||||||
question.UpdatedAt = DateTimeOffset.UtcNow;
|
question.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
activity.Record(question.NovelId, ActivityEntityKind.Question, ActivityAction.Updated, question.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return (await FindAsync(id, ct))!;
|
return (await FindAsync(id, ct))!;
|
||||||
}
|
}
|
||||||
@@ -226,6 +232,7 @@ public class OpenQuestionService(
|
|||||||
await access.RequireAsync(question.NovelId, NovelPermission.DeleteContent, ct);
|
await access.RequireAsync(question.NovelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
db.OpenQuestions.Remove(question);
|
db.OpenQuestions.Remove(question);
|
||||||
|
activity.Record(question.NovelId, ActivityEntityKind.Question, ActivityAction.Deleted, question.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Common.Validation;
|
using Novelly.Api.Common.Validation;
|
||||||
using Novelly.Api.Data;
|
using Novelly.Api.Data;
|
||||||
@@ -9,6 +10,7 @@ namespace Novelly.Api.Tags;
|
|||||||
public class TagService(
|
public class TagService(
|
||||||
INovelDbContext db,
|
INovelDbContext db,
|
||||||
NovelAccessService access,
|
NovelAccessService access,
|
||||||
|
ActivityLog activity,
|
||||||
ILogger<TagService> logger,
|
ILogger<TagService> logger,
|
||||||
IModelValidator<CreateTagRequest> createValidator,
|
IModelValidator<CreateTagRequest> createValidator,
|
||||||
IModelValidator<UpdateTagRequest> updateValidator)
|
IModelValidator<UpdateTagRequest> updateValidator)
|
||||||
@@ -80,6 +82,7 @@ public class TagService(
|
|||||||
|
|
||||||
var tag = new Tag { NovelId = novelId, Name = name, Color = request.Color };
|
var tag = new Tag { NovelId = novelId, Name = name, Color = request.Color };
|
||||||
db.Tags.Add(tag);
|
db.Tags.Add(tag);
|
||||||
|
activity.Record(novelId, ActivityEntityKind.Tag, ActivityAction.Created, tag.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return tag;
|
return tag;
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,7 @@ public class TagService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tag.Color = Patch.Apply(tag.Color, request.Color);
|
tag.Color = Patch.Apply(tag.Color, request.Color);
|
||||||
|
activity.Record(tag.NovelId, ActivityEntityKind.Tag, ActivityAction.Updated, tag.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return tag;
|
return tag;
|
||||||
}
|
}
|
||||||
@@ -136,6 +140,7 @@ public class TagService(
|
|||||||
await access.RequireAsync(tag.NovelId, NovelPermission.DeleteContent, ct);
|
await access.RequireAsync(tag.NovelId, NovelPermission.DeleteContent, ct);
|
||||||
|
|
||||||
db.Tags.Remove(tag);
|
db.Tags.Remove(tag);
|
||||||
|
activity.Record(tag.NovelId, ActivityEntityKind.Tag, ActivityAction.Deleted, tag.Id);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api, ApiError } from './client'
|
import { api, ApiError } from './client'
|
||||||
import type {
|
import type {
|
||||||
|
ActivityCalendar,
|
||||||
AgentTurn,
|
AgentTurn,
|
||||||
ArcStage,
|
ArcStage,
|
||||||
Chapter,
|
Chapter,
|
||||||
@@ -46,6 +47,8 @@ export const keys = {
|
|||||||
conversations: (novelId: string) => ['novels', novelId, 'conversations'] as const,
|
conversations: (novelId: string) => ['novels', novelId, 'conversations'] as const,
|
||||||
conversation: (id: string) => ['conversations', id] as const,
|
conversation: (id: string) => ['conversations', id] as const,
|
||||||
importJob: (id: string) => ['imports', id] as const,
|
importJob: (id: string) => ['imports', id] as const,
|
||||||
|
novelActivity: (novelId: string) => ['novels', novelId, 'activity'] as const,
|
||||||
|
myActivity: ['activity'] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUiSettings = () =>
|
export const useUiSettings = () =>
|
||||||
@@ -126,7 +129,10 @@ export function useCreateNovel() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) =>
|
mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) =>
|
||||||
api.post<Novel>('/api/novels', body),
|
api.post<Novel>('/api/novels', body),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.novels }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: keys.novels })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +143,8 @@ export function useUpdateNovel(id: string) {
|
|||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
qc.setQueryData(keys.novel(id), updated)
|
qc.setQueryData(keys.novel(id), updated)
|
||||||
qc.invalidateQueries({ queryKey: keys.novels })
|
qc.invalidateQueries({ queryKey: keys.novels })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.novelActivity(id) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -163,6 +171,8 @@ export function useCreateCharacter(novelId: string) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -175,6 +185,8 @@ export function useUpdateCharacter(novelId: string) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -183,7 +195,11 @@ export function useDeleteCharacter(novelId: string) {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => api.delete(`/api/characters/${id}`),
|
mutationFn: (id: string) => api.delete(`/api/characters/${id}`),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +459,8 @@ export function useCreateBeat(chapterId: string, novelId: string) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
|
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -458,15 +476,21 @@ export function useUpdateBeat(chapterId: string, novelId: string) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
|
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteBeat(chapterId: string) {
|
export function useDeleteBeat(chapterId: string, novelId: string) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => api.delete(`/api/beats/${id}`),
|
mutationFn: (id: string) => api.delete(`/api/beats/${id}`),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +543,11 @@ export function useCreateChapter(novelId: string) {
|
|||||||
mutationFn: (
|
mutationFn: (
|
||||||
body: Partial<Omit<Chapter, 'tags' | 'locations'>> & { title: string; tags?: string[]; locations?: string[] },
|
body: Partial<Omit<Chapter, 'tags' | 'locations'>> & { title: string; tags?: string[]; locations?: string[] },
|
||||||
) => api.post<Chapter>(`/api/novels/${novelId}/chapters`, body),
|
) => api.post<Chapter>(`/api/novels/${novelId}/chapters`, body),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,6 +562,8 @@ export function useUpdateChapter(novelId: string) {
|
|||||||
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
|
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.locations(novelId) })
|
qc.invalidateQueries({ queryKey: keys.locations(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -542,7 +572,11 @@ export function useDeleteChapter(novelId: string) {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => api.delete(`/api/chapters/${id}`),
|
mutationFn: (id: string) => api.delete(`/api/chapters/${id}`),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.myActivity })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,6 +610,18 @@ export function useSendAgentMessage(novelId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useNovelActivity = (novelId: string) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: keys.novelActivity(novelId),
|
||||||
|
queryFn: () => api.get<ActivityCalendar>(`/api/novels/${novelId}/activity`),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useMyActivity = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: keys.myActivity,
|
||||||
|
queryFn: () => api.get<ActivityCalendar>('/api/activity'),
|
||||||
|
})
|
||||||
|
|
||||||
export function useInspectImport() {
|
export function useInspectImport() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
|
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
|
||||||
|
|||||||
@@ -317,3 +317,17 @@ export interface ImportInspection {
|
|||||||
chaptersTotal: number
|
chaptersTotal: number
|
||||||
completedPasses: string[]
|
completedPasses: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActivityDay {
|
||||||
|
date: string
|
||||||
|
words: number
|
||||||
|
edits: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityCalendar {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
totalWords: number
|
||||||
|
totalEdits: number
|
||||||
|
days: ActivityDay[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { ActivityDay } from '../api/types'
|
||||||
|
|
||||||
|
const WEEKS = 53
|
||||||
|
const DAYS_PER_WEEK = 7
|
||||||
|
const EDIT_WEIGHT = 25
|
||||||
|
const LEVEL_COUNT = 4
|
||||||
|
const CELL_SIZE = 11
|
||||||
|
const CELL_GAP = 3
|
||||||
|
const MONTH_LABEL_HEIGHT = 16
|
||||||
|
const WEEKDAY_LABEL_WIDTH = 20
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS: { row: number; label: string }[] = [
|
||||||
|
{ row: 1, label: 'Mon' },
|
||||||
|
{ row: 3, label: 'Wed' },
|
||||||
|
{ row: 5, label: 'Fri' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||||
|
]
|
||||||
|
|
||||||
|
function toIsoDate(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfGrid(today: Date): Date {
|
||||||
|
const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()))
|
||||||
|
const start = new Date(end)
|
||||||
|
start.setUTCDate(start.getUTCDate() - (WEEKS * DAYS_PER_WEEK - 1))
|
||||||
|
start.setUTCDate(start.getUTCDate() - start.getUTCDay())
|
||||||
|
return start
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelFor(score: number, sortedNonZero: number[]): number {
|
||||||
|
if (score <= 0) return 0
|
||||||
|
if (sortedNonZero.length === 0) return 0
|
||||||
|
|
||||||
|
const rank = sortedNonZero.filter((value) => value <= score).length
|
||||||
|
const quartile = Math.ceil((rank / sortedNonZero.length) * LEVEL_COUNT)
|
||||||
|
return Math.min(LEVEL_COUNT, Math.max(1, quartile))
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorFor(level: number): string {
|
||||||
|
if (level === 0) return 'var(--surface-sunken)'
|
||||||
|
const percent = (level / LEVEL_COUNT) * 100
|
||||||
|
return `color-mix(in srgb, var(--accent) ${percent}%, var(--surface-sunken))`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContributionGraph({ id, days, title }: { id: string; days: ActivityDay[]; title: string }) {
|
||||||
|
const today = new Date()
|
||||||
|
const gridStart = startOfGrid(today)
|
||||||
|
const byDate = new Map(days.map((d) => [d.date, d]))
|
||||||
|
|
||||||
|
const cells: { date: string; words: number; edits: number; score: number }[] = []
|
||||||
|
for (let i = 0; i < WEEKS * DAYS_PER_WEEK; i++) {
|
||||||
|
const date = new Date(gridStart)
|
||||||
|
date.setUTCDate(date.getUTCDate() + i)
|
||||||
|
const iso = toIsoDate(date)
|
||||||
|
const entry = byDate.get(iso)
|
||||||
|
const words = entry?.words ?? 0
|
||||||
|
const edits = entry?.edits ?? 0
|
||||||
|
cells.push({ date: iso, words, edits, score: words + edits * EDIT_WEIGHT })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedNonZero = cells.map((c) => c.score).filter((s) => s > 0).sort((a, b) => a - b)
|
||||||
|
|
||||||
|
const monthLabels: { week: number; label: string }[] = []
|
||||||
|
let lastMonth = -1
|
||||||
|
for (let week = 0; week < WEEKS; week++) {
|
||||||
|
const date = new Date(gridStart)
|
||||||
|
date.setUTCDate(date.getUTCDate() + week * DAYS_PER_WEEK)
|
||||||
|
const month = date.getUTCMonth()
|
||||||
|
if (month !== lastMonth) {
|
||||||
|
monthLabels.push({ week, label: MONTH_NAMES[month] })
|
||||||
|
lastMonth = month
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridWidth = WEEKS * (CELL_SIZE + CELL_GAP)
|
||||||
|
const gridHeight = DAYS_PER_WEEK * (CELL_SIZE + CELL_GAP)
|
||||||
|
const svgWidth = WEEKDAY_LABEL_WIDTH + gridWidth
|
||||||
|
const svgHeight = MONTH_LABEL_HEIGHT + gridHeight
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id={id} className="overflow-x-auto">
|
||||||
|
<svg width={svgWidth} height={svgHeight} role="img" aria-label={title}>
|
||||||
|
{monthLabels.map(({ week, label }) => (
|
||||||
|
<text
|
||||||
|
key={week}
|
||||||
|
x={WEEKDAY_LABEL_WIDTH + week * (CELL_SIZE + CELL_GAP)}
|
||||||
|
y={MONTH_LABEL_HEIGHT - 4}
|
||||||
|
fontSize={10}
|
||||||
|
fill="var(--ink-muted)"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{WEEKDAY_LABELS.map(({ row, label }) => (
|
||||||
|
<text
|
||||||
|
key={label}
|
||||||
|
x={0}
|
||||||
|
y={MONTH_LABEL_HEIGHT + row * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 2}
|
||||||
|
fontSize={9}
|
||||||
|
fill="var(--ink-muted)"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{cells.map((cell, i) => {
|
||||||
|
const week = Math.floor(i / DAYS_PER_WEEK)
|
||||||
|
const day = i % DAYS_PER_WEEK
|
||||||
|
const level = levelFor(cell.score, sortedNonZero)
|
||||||
|
const label = `${cell.date} · ${cell.words.toLocaleString()} words · ${cell.edits} edit${cell.edits === 1 ? '' : 's'}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={cell.date}
|
||||||
|
id={`${id}-day-${cell.date}`}
|
||||||
|
x={WEEKDAY_LABEL_WIDTH + week * (CELL_SIZE + CELL_GAP)}
|
||||||
|
y={MONTH_LABEL_HEIGHT + day * (CELL_SIZE + CELL_GAP)}
|
||||||
|
width={CELL_SIZE}
|
||||||
|
height={CELL_SIZE}
|
||||||
|
rx={2}
|
||||||
|
fill={colorFor(level)}
|
||||||
|
>
|
||||||
|
<title>{label}</title>
|
||||||
|
</rect>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center justify-end gap-1 text-xs muted">
|
||||||
|
<span>Less</span>
|
||||||
|
{[0, 1, 2, 3, 4].map((level) => (
|
||||||
|
<span
|
||||||
|
key={level}
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-sm"
|
||||||
|
style={{ background: colorFor(level) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span>More</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -351,7 +351,7 @@ function BeatTable({
|
|||||||
canDelete: boolean
|
canDelete: boolean
|
||||||
}) {
|
}) {
|
||||||
const update = useUpdateBeat(chapter.id, novelId)
|
const update = useUpdateBeat(chapter.id, novelId)
|
||||||
const remove = useDeleteBeat(chapter.id)
|
const remove = useDeleteBeat(chapter.id, novelId)
|
||||||
const reorder = useReorderBeats(chapter.id)
|
const reorder = useReorderBeats(chapter.id)
|
||||||
const assignCharacter = useAssignCharacterToBeats(chapter.id)
|
const assignCharacter = useAssignCharacterToBeats(chapter.id)
|
||||||
const moveBeats = useMoveBeats(chapter.id)
|
const moveBeats = useMoveBeats(chapter.id)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { useChapters, useCharacters, useNovel, useTags, useUpdateNovel } from '../api/hooks'
|
import { useChapters, useCharacters, useNovel, useNovelActivity, useTags, useUpdateNovel } from '../api/hooks'
|
||||||
import type { Novel, TagSummary } from '../api/types'
|
import type { Novel, TagSummary } from '../api/types'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
||||||
|
import { ContributionGraph } from '../components/ContributionGraph'
|
||||||
|
|
||||||
const RECENT_COUNT = 5
|
const RECENT_COUNT = 5
|
||||||
const RECENT_CHAPTERS_COUNT = 10
|
const RECENT_CHAPTERS_COUNT = 10
|
||||||
@@ -49,6 +50,7 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
|
|||||||
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(novelId)
|
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(novelId)
|
||||||
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(novelId)
|
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(novelId)
|
||||||
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(novelId)
|
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(novelId)
|
||||||
|
const { data: activity, isPending: activityPending, error: activityError } = useNovelActivity(novelId)
|
||||||
|
|
||||||
const recentCharacters = [...(characters ?? [])].sort(
|
const recentCharacters = [...(characters ?? [])].sort(
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
@@ -59,6 +61,19 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
|
<section id="dashboard-activity-graph" className="card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Activity</h2>
|
||||||
|
|
||||||
|
{activityError && <ErrorNote error={activityError} />}
|
||||||
|
{activityPending ? (
|
||||||
|
<Spinner label="Loading activity" />
|
||||||
|
) : !activity || activity.days.length === 0 ? (
|
||||||
|
<EmptyState title="No activity yet" hint="Write a chapter or add a beat to start the streak." />
|
||||||
|
) : (
|
||||||
|
<ContributionGraph id="novel-activity-graph" days={activity.days} title="Writing activity for this novel" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<section className="card p-5 lg:col-span-2">
|
<section className="card p-5 lg:col-span-2">
|
||||||
<div className="mb-4 flex items-center justify-between gap-4">
|
<div className="mb-4 flex items-center justify-between gap-4">
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useId, useState } from 'react'
|
import { useId, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useCreateNovel, useGenres, useLogout, useNovels } from '../api/hooks'
|
import { useCreateNovel, useGenres, useLogout, useMyActivity, useNovels } from '../api/hooks'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { ImportDialog } from '../components/ImportDialog'
|
import { ImportDialog } from '../components/ImportDialog'
|
||||||
|
import { ContributionGraph } from '../components/ContributionGraph'
|
||||||
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
|
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
|
||||||
import { HelpButton } from '../keyboard/HelpButton'
|
import { HelpButton } from '../keyboard/HelpButton'
|
||||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||||
|
|
||||||
export default function NovelsPage() {
|
export default function NovelsPage() {
|
||||||
const { data: novels, isPending, error } = useNovels()
|
const { data: novels, isPending, error } = useNovels()
|
||||||
|
const { data: activity, isPending: activityPending, error: activityError } = useMyActivity()
|
||||||
const { user, can } = useAuth()
|
const { user, can } = useAuth()
|
||||||
const logout = useLogout()
|
const logout = useLogout()
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
@@ -61,6 +63,19 @@ export default function NovelsPage() {
|
|||||||
Outlines, character dossiers, and a writing partner that knows the book.
|
Outlines, character dossiers, and a writing partner that knows the book.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<section id="novels-activity-graph" className="card mb-8 p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Activity</h2>
|
||||||
|
|
||||||
|
{activityError && <ErrorNote error={activityError} />}
|
||||||
|
{activityPending ? (
|
||||||
|
<Spinner label="Loading activity" />
|
||||||
|
) : !activity || activity.days.length === 0 ? (
|
||||||
|
<EmptyState title="No activity yet" hint="Write a chapter or add a beat to start the streak." />
|
||||||
|
) : (
|
||||||
|
<ContributionGraph id="all-novels-activity-graph" days={activity.days} title="Writing activity across every novel" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{error && <ErrorNote error={error} />}
|
{error && <ErrorNote error={error} />}
|
||||||
{isPending && <Spinner label="Loading novels" />}
|
{isPending && <Spinner label="Loading novels" />}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Activity;
|
||||||
|
using Novelly.Api.Beats;
|
||||||
|
using Novelly.Api.Chapters;
|
||||||
|
using Novelly.Api.Novels;
|
||||||
|
using Novelly.Api.Users;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class ActivityTests : ServiceTestFixture
|
||||||
|
{
|
||||||
|
private Guid AsNewUser(GlobalRole globalRole)
|
||||||
|
{
|
||||||
|
var user = new NovellyUser
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = $"{Guid.NewGuid()}@novelly.test",
|
||||||
|
Email = $"{Guid.NewGuid()}@novelly.test",
|
||||||
|
DisplayName = "Test User",
|
||||||
|
GlobalRole = globalRole
|
||||||
|
};
|
||||||
|
Db.Context.Users.Add(user);
|
||||||
|
Db.Context.SaveChanges();
|
||||||
|
|
||||||
|
UserContext.UserId = user.Id;
|
||||||
|
UserContext.GlobalRole = globalRole;
|
||||||
|
return user.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Writing_prose_records_the_word_delta_for_that_day()
|
||||||
|
{
|
||||||
|
var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
|
||||||
|
var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall"));
|
||||||
|
|
||||||
|
await Chapters.UpdateAsync(chapter!.Id, new UpdateChapterRequest(Prose: "one two three four five"));
|
||||||
|
|
||||||
|
var calendar = await Activity.GetForNovelAsync(novel.Id, days: 7);
|
||||||
|
|
||||||
|
Assert.That(calendar.Days.Sum(d => d.Words), Is.EqualTo(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Editing_a_beat_records_one_edit_not_two()
|
||||||
|
{
|
||||||
|
var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
|
||||||
|
var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall"));
|
||||||
|
var beat = await Beats.CreateAsync(chapter!.Id, new CreateBeatRequest("She finds the map"));
|
||||||
|
|
||||||
|
await Beats.UpdateAsync(beat!.Id, new UpdateBeatRequest(WhatHappened: "She finds the map under the floorboards."));
|
||||||
|
|
||||||
|
var beatEvents = await Db.Context.ActivityEvents.CountAsync(e => e.EntityKind == ActivityEntityKind.Beat);
|
||||||
|
var chapterEvents = await Db.Context.ActivityEvents.CountAsync(e => e.EntityKind == ActivityEntityKind.Chapter);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(beatEvents, Is.EqualTo(2));
|
||||||
|
Assert.That(chapterEvents, Is.EqualTo(1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Deleting_a_chapter_records_a_negative_word_delta()
|
||||||
|
{
|
||||||
|
var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
|
||||||
|
var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall", Prose: "one two three"));
|
||||||
|
|
||||||
|
await Chapters.DeleteAsync(chapter!.Id);
|
||||||
|
|
||||||
|
var calendar = await Activity.GetForNovelAsync(novel.Id, days: 7);
|
||||||
|
|
||||||
|
Assert.That(calendar.Days.Sum(d => d.Words), Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task The_calendar_groups_multiple_edits_into_one_day()
|
||||||
|
{
|
||||||
|
var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
|
||||||
|
var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall"));
|
||||||
|
|
||||||
|
await Chapters.UpdateAsync(chapter!.Id, new UpdateChapterRequest(Summary: "First pass"));
|
||||||
|
await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Summary: "Second pass"));
|
||||||
|
|
||||||
|
var calendar = await Activity.GetForNovelAsync(novel.Id, days: 7);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(calendar.Days, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(calendar.Days[0].Edits, Is.EqualTo(4));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task The_calendar_omits_days_with_no_activity()
|
||||||
|
{
|
||||||
|
var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
|
||||||
|
|
||||||
|
var calendar = await Activity.GetForNovelAsync(novel.Id, days: 7);
|
||||||
|
|
||||||
|
Assert.That(calendar.Days, Has.Count.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task The_calendar_excludes_novels_the_user_cannot_read()
|
||||||
|
{
|
||||||
|
var novel = await Novels.CreateAsync(new CreateNovelRequest("Someone Else's Novel"));
|
||||||
|
|
||||||
|
AsNewUser(GlobalRole.Writer);
|
||||||
|
await Novels.CreateAsync(new CreateNovelRequest("My Own Novel"));
|
||||||
|
|
||||||
|
var calendar = await Activity.GetForCurrentUserAsync(days: 7);
|
||||||
|
|
||||||
|
Assert.That(calendar.TotalEdits, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Deleting_a_novel_removes_its_activity_events()
|
||||||
|
{
|
||||||
|
var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
|
||||||
|
await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall"));
|
||||||
|
|
||||||
|
await Novels.DeleteAsync(novel.Id);
|
||||||
|
|
||||||
|
Assert.That(await Db.Context.ActivityEvents.CountAsync(e => e.NovelId == novel.Id), Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Novelly.Api.Activity;
|
||||||
using Novelly.Api.Beats;
|
using Novelly.Api.Beats;
|
||||||
using Novelly.Api.Chapters;
|
using Novelly.Api.Chapters;
|
||||||
using Novelly.Api.Characters;
|
using Novelly.Api.Characters;
|
||||||
@@ -15,6 +16,8 @@ public abstract class ServiceTestFixture
|
|||||||
protected TestDatabase Db { get; private set; } = null!;
|
protected TestDatabase Db { get; private set; } = null!;
|
||||||
protected TestUserContext UserContext { get; private set; } = null!;
|
protected TestUserContext UserContext { get; private set; } = null!;
|
||||||
protected NovelAccessService Access { get; private set; } = null!;
|
protected NovelAccessService Access { get; private set; } = null!;
|
||||||
|
protected ActivityLog ActivityLog { get; private set; } = null!;
|
||||||
|
protected ActivityService Activity { get; private set; } = null!;
|
||||||
protected TagService Tags { get; private set; } = null!;
|
protected TagService Tags { get; private set; } = null!;
|
||||||
protected LocationService Locations { get; private set; } = null!;
|
protected LocationService Locations { get; private set; } = null!;
|
||||||
protected NovelService Novels { get; private set; } = null!;
|
protected NovelService Novels { get; private set; } = null!;
|
||||||
@@ -41,6 +44,8 @@ public abstract class ServiceTestFixture
|
|||||||
Db = new TestDatabase();
|
Db = new TestDatabase();
|
||||||
UserContext = new TestUserContext();
|
UserContext = new TestUserContext();
|
||||||
Access = new NovelAccessService(Db.Context, UserContext, new CapturingLogger<NovelAccessService>());
|
Access = new NovelAccessService(Db.Context, UserContext, new CapturingLogger<NovelAccessService>());
|
||||||
|
ActivityLog = new ActivityLog(Db.Context, UserContext, new CapturingLogger<ActivityLog>());
|
||||||
|
Activity = new ActivityService(Db.Context, Access, new CapturingLogger<ActivityService>());
|
||||||
|
|
||||||
Db.Context.Users.Add(new NovellyUser
|
Db.Context.Users.Add(new NovellyUser
|
||||||
{
|
{
|
||||||
@@ -62,25 +67,25 @@ public abstract class ServiceTestFixture
|
|||||||
QuestionLogs = new CapturingLogger<OpenQuestionService>();
|
QuestionLogs = new CapturingLogger<OpenQuestionService>();
|
||||||
GenreLogs = new CapturingLogger<GenreService>();
|
GenreLogs = new CapturingLogger<GenreService>();
|
||||||
|
|
||||||
Tags = new TagService(Db.Context, Access, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
|
Tags = new TagService(Db.Context, Access, ActivityLog, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
|
||||||
Locations = new LocationService(Db.Context, Access, LocationLogs, new CreateLocationRequestValidator(), new UpdateLocationRequestValidator());
|
Locations = new LocationService(Db.Context, Access, ActivityLog, LocationLogs, new CreateLocationRequestValidator(), new UpdateLocationRequestValidator());
|
||||||
Novels = new NovelService(
|
Novels = new NovelService(
|
||||||
Db.Context, Access, UserContext, NovelLogs, new CreateNovelRequestValidator(), new UpdateNovelRequestValidator());
|
Db.Context, Access, UserContext, ActivityLog, NovelLogs, new CreateNovelRequestValidator(), new UpdateNovelRequestValidator());
|
||||||
Characters = new CharacterService(
|
Characters = new CharacterService(
|
||||||
Db.Context, Access, Tags, CharacterLogs,
|
Db.Context, Access, Tags, ActivityLog, CharacterLogs,
|
||||||
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(),
|
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(),
|
||||||
new LinkCharacterIdentityRequestValidator());
|
new LinkCharacterIdentityRequestValidator());
|
||||||
Chapters = new ChapterService(Db.Context, Access, Tags, Locations, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
|
Chapters = new ChapterService(Db.Context, Access, Tags, Locations, ActivityLog, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
|
||||||
Beats = new BeatService(
|
Beats = new BeatService(
|
||||||
Db.Context, Access, Tags, BeatLogs,
|
Db.Context, Access, Tags, ActivityLog, BeatLogs,
|
||||||
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),
|
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),
|
||||||
new AssignCharacterToBeatsRequestValidator(), new MoveBeatsRequestValidator());
|
new AssignCharacterToBeatsRequestValidator(), new MoveBeatsRequestValidator());
|
||||||
Arcs = new CharacterArcService(
|
Arcs = new CharacterArcService(
|
||||||
Db.Context, Access, ArcLogs,
|
Db.Context, Access, ActivityLog, ArcLogs,
|
||||||
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator(),
|
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator(),
|
||||||
new SetArcStageBeatsRequestValidator());
|
new SetArcStageBeatsRequestValidator());
|
||||||
Questions = new OpenQuestionService(
|
Questions = new OpenQuestionService(
|
||||||
Db.Context, Access, QuestionLogs,
|
Db.Context, Access, ActivityLog, QuestionLogs,
|
||||||
new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());
|
new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());
|
||||||
Genres = new GenreService(Db.Context, GenreLogs);
|
Genres = new GenreService(Db.Context, GenreLogs);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user