diff --git a/src/Novelly.Api/Activity/ActivityBackfill.cs b/src/Novelly.Api/Activity/ActivityBackfill.cs new file mode 100644 index 0000000..7163e4d --- /dev/null +++ b/src/Novelly.Api/Activity/ActivityBackfill.cs @@ -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(); + + 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 + }; +} diff --git a/src/Novelly.Api/Activity/ActivityContracts.cs b/src/Novelly.Api/Activity/ActivityContracts.cs new file mode 100644 index 0000000..5aebbb3 --- /dev/null +++ b/src/Novelly.Api/Activity/ActivityContracts.cs @@ -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 Days); diff --git a/src/Novelly.Api/Activity/ActivityEndpoints.cs b/src/Novelly.Api/Activity/ActivityEndpoints.cs new file mode 100644 index 0000000..9852af8 --- /dev/null +++ b/src/Novelly.Api/Activity/ActivityEndpoints.cs @@ -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() + .AddEndpointFilter(); + + 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() + .AddEndpointFilter(); + + 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; + } +} diff --git a/src/Novelly.Api/Activity/ActivityEvent.cs b/src/Novelly.Api/Activity/ActivityEvent.cs new file mode 100644 index 0000000..dfcc7e6 --- /dev/null +++ b/src/Novelly.Api/Activity/ActivityEvent.cs @@ -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 +{ + public void Configure(EntityTypeBuilder entity) + { + entity.Property(e => e.EntityKind).HasConversion().HasMaxLength(32); + entity.Property(e => e.Action).HasConversion().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); + } +} diff --git a/src/Novelly.Api/Activity/ActivityLog.cs b/src/Novelly.Api/Activity/ActivityLog.cs new file mode 100644 index 0000000..131a139 --- /dev/null +++ b/src/Novelly.Api/Activity/ActivityLog.cs @@ -0,0 +1,28 @@ +using Novelly.Api.Data; +using Novelly.Api.Users; + +namespace Novelly.Api.Activity; + +public class ActivityLog(INovelDbContext db, INovelUserContext userContext, ILogger 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 + }); + } +} diff --git a/src/Novelly.Api/Activity/ActivityService.cs b/src/Novelly.Api/Activity/ActivityService.cs new file mode 100644 index 0000000..7efdf76 --- /dev/null +++ b/src/Novelly.Api/Activity/ActivityService.cs @@ -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 logger) +{ + private const int MinDays = 1; + private const int MaxDays = 400; + private const int DefaultDays = 365; + + public async Task 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 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 BuildCalendarAsync( + IQueryable 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); + } +} diff --git a/src/Novelly.Api/Beats/BeatService.cs b/src/Novelly.Api/Beats/BeatService.cs index 0f62fa9..bf89b3e 100644 --- a/src/Novelly.Api/Beats/BeatService.cs +++ b/src/Novelly.Api/Beats/BeatService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Novelly.Api.Activity; using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Common; @@ -13,6 +14,7 @@ public class BeatService( INovelDbContext db, NovelAccessService access, TagService tags, + ActivityLog activity, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator, @@ -119,6 +121,7 @@ public class BeatService( chapter.UpdatedAt = DateTimeOffset.UtcNow; db.Beats.Add(beat); + activity.Record(chapter.NovelId, ActivityEntityKind.Beat, ActivityAction.Created, beat.Id); await db.SaveChangesAsync(ct); return (await FindAsync(beat.Id, ct))!; @@ -164,6 +167,7 @@ public class BeatService( beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct); } + activity.Record(chapter.NovelId, ActivityEntityKind.Beat, ActivityAction.Updated, beat.Id); await db.SaveChangesAsync(ct); return (await FindAsync(id, ct))!; } @@ -183,7 +187,11 @@ public class BeatService( await RequireBeatAccessAsync(beat, NovelPermission.DeleteContent, 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); await db.SaveChangesAsync(ct); diff --git a/src/Novelly.Api/Chapters/ChapterService.cs b/src/Novelly.Api/Chapters/ChapterService.cs index 9fb003b..99dc173 100644 --- a/src/Novelly.Api/Chapters/ChapterService.cs +++ b/src/Novelly.Api/Chapters/ChapterService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Novelly.Api.Activity; using Novelly.Api.Common; using Novelly.Api.Common.Validation; using Novelly.Api.Data; @@ -13,6 +14,7 @@ public class ChapterService( NovelAccessService access, TagService tags, LocationService locations, + ActivityLog activity, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator) @@ -90,6 +92,7 @@ public class ChapterService( } db.Chapters.Add(chapter); + activity.Record(novelId, ActivityEntityKind.Chapter, ActivityAction.Created, chapter.Id, chapter.WordCount); await db.SaveChangesAsync(ct); return (await FindAsync(chapter.Id, ct))!; @@ -118,6 +121,8 @@ public class ChapterService( chapter.Status = request.Status ?? chapter.Status; chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount; + var wordCountBeforeEdit = chapter.WordCount; + if (request.Prose is not null) { chapter.Prose = Patch.Apply(chapter.Prose, request.Prose); @@ -136,6 +141,7 @@ public class ChapterService( 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); return (await FindAsync(id, ct))!; } @@ -155,6 +161,7 @@ public class ChapterService( await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct); db.Chapters.Remove(chapter); + activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Deleted, chapter.Id, -chapter.WordCount); await db.SaveChangesAsync(ct); return true; } diff --git a/src/Novelly.Api/Characters/CharacterArcService.cs b/src/Novelly.Api/Characters/CharacterArcService.cs index 48e202d..7b5c3a2 100644 --- a/src/Novelly.Api/Characters/CharacterArcService.cs +++ b/src/Novelly.Api/Characters/CharacterArcService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Novelly.Api.Activity; using Novelly.Api.Common; using Novelly.Api.Common.Validation; using Novelly.Api.Data; @@ -9,6 +10,7 @@ namespace Novelly.Api.Characters; public class CharacterArcService( INovelDbContext db, NovelAccessService access, + ActivityLog activity, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator, @@ -76,6 +78,7 @@ public class CharacterArcService( }; db.CharacterArcStages.Add(stage); + activity.Record(character.NovelId, ActivityEntityKind.ArcStage, ActivityAction.Created, stage.Id); await db.SaveChangesAsync(ct); return (await FindAsync(stage.Id, ct))!; @@ -112,6 +115,7 @@ public class CharacterArcService( stage.ChapterId = request.ChapterId ?? stage.ChapterId; stage.UpdatedAt = DateTimeOffset.UtcNow; + activity.Record(character.NovelId, ActivityEntityKind.ArcStage, ActivityAction.Updated, stage.Id); await db.SaveChangesAsync(ct); return (await FindAsync(id, ct))!; } @@ -128,9 +132,11 @@ public class CharacterArcService( 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); + activity.Record(novelId, ActivityEntityKind.ArcStage, ActivityAction.Deleted, stage.Id); await db.SaveChangesAsync(ct); return true; } diff --git a/src/Novelly.Api/Characters/CharacterService.cs b/src/Novelly.Api/Characters/CharacterService.cs index 453c1da..741095f 100644 --- a/src/Novelly.Api/Characters/CharacterService.cs +++ b/src/Novelly.Api/Characters/CharacterService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Novelly.Api.Activity; using Novelly.Api.Common; using Novelly.Api.Common.Validation; using Novelly.Api.Data; @@ -11,6 +12,7 @@ public class CharacterService( INovelDbContext db, NovelAccessService access, TagService tags, + ActivityLog activity, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator, @@ -101,6 +103,7 @@ public class CharacterService( } db.Characters.Add(character); + activity.Record(novelId, ActivityEntityKind.Character, ActivityAction.Created, character.Id); await db.SaveChangesAsync(ct); return (await FindAsync(character.Id, ct))!; @@ -147,6 +150,7 @@ public class CharacterService( character.Aliases = [.. aliases]; } + activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Updated, character.Id); await db.SaveChangesAsync(ct); return (await FindAsync(id, ct))!; } @@ -166,6 +170,7 @@ public class CharacterService( await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct); db.Characters.Remove(character); + activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Deleted, character.Id); await db.SaveChangesAsync(ct); return true; } diff --git a/src/Novelly.Api/Common/NovellyServiceRegistration.cs b/src/Novelly.Api/Common/NovellyServiceRegistration.cs index c1ffdbf..325b6f6 100644 --- a/src/Novelly.Api/Common/NovellyServiceRegistration.cs +++ b/src/Novelly.Api/Common/NovellyServiceRegistration.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Novelly.Api.Activity; using Novelly.Api.Agent; using Novelly.Api.Beats; using Novelly.Api.Chapters; @@ -52,6 +53,8 @@ public static class NovellyServiceRegistration services.AddHttpContextAccessor(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.ConfigureApplicationCookie(options => { diff --git a/src/Novelly.Api/Data/Migrations/20260820002345_ActivityEvents.Designer.cs b/src/Novelly.Api/Data/Migrations/20260820002345_ActivityEvents.Designer.cs new file mode 100644 index 0000000..9ccb127 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260820002345_ActivityEvents.Designer.cs @@ -0,0 +1,1284 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Novelly.Api.Data; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + [DbContext(typeof(NovelDbContext))] + [Migration("20260820002345_ActivityEvents")] + partial class ActivityEvents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("BeatCharacter", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "CharactersId"); + + b.HasIndex("CharactersId"); + + b.ToTable("BeatCharacters", (string)null); + }); + + modelBuilder.Entity("BeatCharacterArcStage", b => + { + b.Property("ArcStagesId") + .HasColumnType("TEXT"); + + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.HasKey("ArcStagesId", "BeatsId"); + + b.HasIndex("BeatsId"); + + b.ToTable("ArcStageBeats", (string)null); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BeatTags", (string)null); + }); + + modelBuilder.Entity("ChapterLocation", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("LocationsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "LocationsId"); + + b.HasIndex("LocationsId"); + + b.ToTable("ChapterLocations", (string)null); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTags", (string)null); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("CharactersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("CharacterTags", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Novelly.Api.Activity.ActivityEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DayKey") + .HasColumnType("INTEGER"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityKind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("OccurredAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("WordDelta") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "DayKey"); + + b.HasIndex("UserId", "DayKey"); + + b.ToTable("ActivityEvents"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NovelId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WhatHappened") + .HasColumnType("TEXT"); + + b.Property("WhatsNext") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Prose") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Aliases") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Appearance") + .HasColumnType("TEXT"); + + b.Property("Backstory") + .HasColumnType("TEXT"); + + b.Property("Conflict") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IdentityNote") + .HasColumnType("TEXT"); + + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Motivation") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Occupation") + .HasColumnType("TEXT"); + + b.Property("Personality") + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .HasColumnType("TEXT"); + + b.Property("RevealedInChapterId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SameCharacterAsId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Voice") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NovelId"); + + b.HasIndex("RevealedInChapterId"); + + b.HasIndex("SameCharacterAsId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Result") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId", "SortOrder"); + + b.ToTable("CharacterArcStages"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property("RelationshipType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RelatedCharacterId"); + + b.ToTable("CharacterRelationships"); + }); + + modelBuilder.Entity("Novelly.Api.Genres.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + + b.HasData( + new + { + Id = new Guid("b89aadb3-ee96-5a33-897d-94946b037f96"), + Name = "Adventure" + }, + new + { + Id = new Guid("1295b746-5de1-5724-aab8-186d4220c84f"), + Name = "Contemporary Fiction" + }, + new + { + Id = new Guid("786d6d01-be6c-5dff-ab53-17081d2979ed"), + Name = "Crime" + }, + new + { + Id = new Guid("800eea0a-52cb-5e03-8b6f-5e1ceaec8554"), + Name = "Dystopian" + }, + new + { + Id = new Guid("8dbe0291-1ab6-5045-b327-00f2025a7b0a"), + Name = "Fantasy" + }, + new + { + Id = new Guid("93face5a-9a61-5d63-9a8d-7fd5d49eab7d"), + Name = "Historical Fiction" + }, + new + { + Id = new Guid("4eba456f-b706-5f1f-bfc9-5d32cab0da62"), + Name = "Horror" + }, + new + { + Id = new Guid("d49c5adf-3ed9-5bc9-8652-1f7a9a098ecb"), + Name = "Literary Fiction" + }, + new + { + Id = new Guid("f72c6437-c8e7-519f-8d35-5aefeebbff9e"), + Name = "Magical Realism" + }, + new + { + Id = new Guid("1b670010-b4cc-5b22-a879-d36eb1bf3429"), + Name = "Memoir" + }, + new + { + Id = new Guid("03063bbf-de5d-5dd0-af06-0ee939de58bc"), + Name = "Middle Grade" + }, + new + { + Id = new Guid("c22ed045-52e5-54b0-8cdd-cd1d6a699c19"), + Name = "Mystery" + }, + new + { + Id = new Guid("abe2e8bc-a35e-5a30-a07f-7ae30a00d838"), + Name = "Non-Fiction" + }, + new + { + Id = new Guid("f8543db0-c519-56a0-996a-c6028176e57e"), + Name = "Poetry" + }, + new + { + Id = new Guid("b6251b9e-63a1-563f-94c0-834162fb580b"), + Name = "Romance" + }, + new + { + Id = new Guid("4f188842-488e-567a-b31d-831e0c551fa5"), + Name = "Science Fiction" + }, + new + { + Id = new Guid("ae67fc84-1ed9-55ae-8c9f-8a37adb52b57"), + Name = "Thriller" + }, + new + { + Id = new Guid("37956a94-e9c4-5d29-abbc-f121d687f997"), + Name = "Young Adult" + }); + }); + + modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChaptersCompleted") + .HasColumnType("INTEGER"); + + b.Property("ChaptersTotal") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("SourceRoot") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SourceRoot"); + + b.ToTable("ImportJobs"); + }); + + modelBuilder.Entity("Novelly.Api.Locations.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "Name") + .IsUnique(); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Novelly.Api.Novels.Novel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasColumnType("TEXT"); + + b.Property("Logline") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Synopsis") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Novels"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Detail") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Resolution") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("NovelId"); + + b.ToTable("OpenQuestions"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Novelly.Api.Users.NovelMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("INTEGER"); + + b.Property("GrantedByUserId") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("NovelRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("NovelId", "UserId") + .IsUnique(); + + b.ToTable("NovelMembers"); + }); + + modelBuilder.Entity("Novelly.Api.Users.NovellyUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("GlobalRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("INTEGER"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("BeatCharacter", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BeatCharacterArcStage", b => + { + b.HasOne("Novelly.Api.Characters.CharacterArcStage", null) + .WithMany() + .HasForeignKey("ArcStagesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterLocation", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Locations.Location", null) + .WithMany() + .HasForeignKey("LocationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .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 => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Conversations") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Beats") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Chapters") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Characters") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Chapters.Chapter", "RevealedInChapter") + .WithMany() + .HasForeignKey("RevealedInChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "SameCharacterAs") + .WithMany("OtherIdentities") + .HasForeignKey("SameCharacterAsId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Novel"); + + b.Navigation("RevealedInChapter"); + + b.Navigation("SameCharacterAs"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("ArcStages") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("Relationships") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter") + .WithMany() + .HasForeignKey("RelatedCharacterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("RelatedCharacter"); + }); + + modelBuilder.Entity("Novelly.Api.Locations.Location", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany() + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Novels.Novel", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany() + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Tags") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Users.NovelMember", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Members") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Users.NovellyUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Navigation("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Navigation("ArcStages"); + + b.Navigation("OtherIdentities"); + + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("Novelly.Api.Novels.Novel", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("Members"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/20260820002345_ActivityEvents.cs b/src/Novelly.Api/Data/Migrations/20260820002345_ActivityEvents.cs new file mode 100644 index 0000000..f90d51a --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260820002345_ActivityEvents.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + /// + public partial class ActivityEvents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ActivityEvents", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + NovelId = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: true), + OccurredAt = table.Column(type: "INTEGER", nullable: false), + DayKey = table.Column(type: "INTEGER", nullable: false), + EntityKind = table.Column(type: "TEXT", maxLength: 32, nullable: false), + Action = table.Column(type: "TEXT", maxLength: 32, nullable: false), + EntityId = table.Column(type: "TEXT", nullable: false), + WordDelta = table.Column(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" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ActivityEvents"); + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs index cb1fbd3..5ac1fe1 100644 --- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs @@ -169,6 +169,49 @@ namespace Novelly.Api.Data.Migrations b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("Novelly.Api.Activity.ActivityEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DayKey") + .HasColumnType("INTEGER"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityKind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("OccurredAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("WordDelta") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "DayKey"); + + b.HasIndex("UserId", "DayKey"); + + b.ToTable("ActivityEvents"); + }); + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => { b.Property("Id") @@ -1001,6 +1044,24 @@ namespace Novelly.Api.Data.Migrations .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 => { b.HasOne("Novelly.Api.Novels.Novel", "Novel") diff --git a/src/Novelly.Api/Data/NovelDbContext.cs b/src/Novelly.Api/Data/NovelDbContext.cs index 93027a7..6e46583 100644 --- a/src/Novelly.Api/Data/NovelDbContext.cs +++ b/src/Novelly.Api/Data/NovelDbContext.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore; +using Novelly.Api.Activity; using Novelly.Api.Agent; using Novelly.Api.Beats; using Novelly.Api.Chapters; @@ -33,6 +34,7 @@ public class NovelDbContext(DbContextOptions options) : Identity public DbSet ImportJobs => Set(); public DbSet Genres => Set(); public DbSet NovelMembers => Set(); + public DbSet ActivityEvents => Set(); Task INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken); @@ -62,6 +64,7 @@ public interface INovelDbContext DbSet Genres { get; } DbSet Users { get; } DbSet NovelMembers { get; } + DbSet ActivityEvents { get; } Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Novelly.Api/Locations/LocationService.cs b/src/Novelly.Api/Locations/LocationService.cs index 2e15b68..7fb2aa3 100644 --- a/src/Novelly.Api/Locations/LocationService.cs +++ b/src/Novelly.Api/Locations/LocationService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Novelly.Api.Activity; using Novelly.Api.Common; using Novelly.Api.Common.Validation; using Novelly.Api.Data; @@ -9,6 +10,7 @@ namespace Novelly.Api.Locations; public class LocationService( INovelDbContext db, NovelAccessService access, + ActivityLog activity, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator) @@ -75,6 +77,7 @@ public class LocationService( var location = new Location { NovelId = novelId, Name = name }; db.Locations.Add(location); + activity.Record(novelId, ActivityEntityKind.Location, ActivityAction.Created, location.Id); await db.SaveChangesAsync(ct); return location; } @@ -110,6 +113,7 @@ public class LocationService( location.Name = name; } + activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Updated, location.Id); await db.SaveChangesAsync(ct); return location; } @@ -130,6 +134,7 @@ public class LocationService( await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct); db.Locations.Remove(location); + activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Deleted, location.Id); await db.SaveChangesAsync(ct); return true; } diff --git a/src/Novelly.Api/Novels/NovelService.cs b/src/Novelly.Api/Novels/NovelService.cs index 36539a6..3c9c0df 100644 --- a/src/Novelly.Api/Novels/NovelService.cs +++ b/src/Novelly.Api/Novels/NovelService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Novelly.Api.Activity; using Novelly.Api.Common; using Novelly.Api.Common.Validation; using Novelly.Api.Data; @@ -10,6 +11,7 @@ public class NovelService( INovelDbContext db, NovelAccessService access, INovelUserContext userContext, + ActivityLog activity, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator) @@ -69,6 +71,7 @@ public class NovelService( }; db.Novels.Add(novel); + activity.Record(novel.Id, ActivityEntityKind.Novel, ActivityAction.Created, novel.Id); await db.SaveChangesAsync(ct); return novel; } @@ -96,6 +99,7 @@ public class NovelService( novel.Phase = request.Phase ?? novel.Phase; novel.UpdatedAt = DateTimeOffset.UtcNow; + activity.Record(novel.Id, ActivityEntityKind.Novel, ActivityAction.Updated, novel.Id); await db.SaveChangesAsync(ct); return novel; } diff --git a/src/Novelly.Api/Program.cs b/src/Novelly.Api/Program.cs index 861f1c9..dfbdd82 100644 --- a/src/Novelly.Api/Program.cs +++ b/src/Novelly.Api/Program.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Diagnostics; using Microsoft.EntityFrameworkCore; +using Novelly.Api.Activity; using Novelly.Api.Agent; using Novelly.Api.Beats; 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 ActivityBackfill.RunAsync(db, app.Logger); } app.UseSerilogRequestLogging(); @@ -119,7 +121,8 @@ app.MapNovelEndpoints() .MapGenreEndpoints() .MapOpenQuestionEndpoints() .MapAgentEndpoints() - .MapImportEndpoints(); + .MapImportEndpoints() + .MapActivityEndpoints(); app.Run(); diff --git a/src/Novelly.Api/Questions/OpenQuestionService.cs b/src/Novelly.Api/Questions/OpenQuestionService.cs index 18c1cb6..6a75ad6 100644 --- a/src/Novelly.Api/Questions/OpenQuestionService.cs +++ b/src/Novelly.Api/Questions/OpenQuestionService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Novelly.Api.Activity; using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Common; @@ -11,6 +12,7 @@ namespace Novelly.Api.Questions; public class OpenQuestionService( INovelDbContext db, NovelAccessService access, + ActivityLog activity, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator, @@ -102,6 +104,7 @@ public class OpenQuestionService( }; db.OpenQuestions.Add(question); + activity.Record(novelId, ActivityEntityKind.Question, ActivityAction.Created, question.Id); await db.SaveChangesAsync(ct); return (await FindAsync(question.Id, ct))!; @@ -131,6 +134,7 @@ public class OpenQuestionService( question.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? question.CharacterId; question.UpdatedAt = DateTimeOffset.UtcNow; + activity.Record(question.NovelId, ActivityEntityKind.Question, ActivityAction.Updated, question.Id); await db.SaveChangesAsync(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); return (await FindAsync(id, ct))!; } @@ -207,6 +212,7 @@ public class OpenQuestionService( question.ResolvedAt = null; question.UpdatedAt = DateTimeOffset.UtcNow; + activity.Record(question.NovelId, ActivityEntityKind.Question, ActivityAction.Updated, question.Id); await db.SaveChangesAsync(ct); return (await FindAsync(id, ct))!; } @@ -226,6 +232,7 @@ public class OpenQuestionService( await access.RequireAsync(question.NovelId, NovelPermission.DeleteContent, ct); db.OpenQuestions.Remove(question); + activity.Record(question.NovelId, ActivityEntityKind.Question, ActivityAction.Deleted, question.Id); await db.SaveChangesAsync(ct); return true; } diff --git a/src/Novelly.Api/Tags/TagService.cs b/src/Novelly.Api/Tags/TagService.cs index 229cf29..f012a93 100644 --- a/src/Novelly.Api/Tags/TagService.cs +++ b/src/Novelly.Api/Tags/TagService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Novelly.Api.Activity; using Novelly.Api.Common; using Novelly.Api.Common.Validation; using Novelly.Api.Data; @@ -9,6 +10,7 @@ namespace Novelly.Api.Tags; public class TagService( INovelDbContext db, NovelAccessService access, + ActivityLog activity, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator) @@ -80,6 +82,7 @@ public class TagService( var tag = new Tag { NovelId = novelId, Name = name, Color = request.Color }; db.Tags.Add(tag); + activity.Record(novelId, ActivityEntityKind.Tag, ActivityAction.Created, tag.Id); await db.SaveChangesAsync(ct); return tag; } @@ -116,6 +119,7 @@ public class TagService( } tag.Color = Patch.Apply(tag.Color, request.Color); + activity.Record(tag.NovelId, ActivityEntityKind.Tag, ActivityAction.Updated, tag.Id); await db.SaveChangesAsync(ct); return tag; } @@ -136,6 +140,7 @@ public class TagService( await access.RequireAsync(tag.NovelId, NovelPermission.DeleteContent, ct); db.Tags.Remove(tag); + activity.Record(tag.NovelId, ActivityEntityKind.Tag, ActivityAction.Deleted, tag.Id); await db.SaveChangesAsync(ct); return true; } diff --git a/src/Novelly.Web/src/api/hooks.ts b/src/Novelly.Web/src/api/hooks.ts index ff20e9b..04cc71e 100644 --- a/src/Novelly.Web/src/api/hooks.ts +++ b/src/Novelly.Web/src/api/hooks.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api, ApiError } from './client' import type { + ActivityCalendar, AgentTurn, ArcStage, Chapter, @@ -46,6 +47,8 @@ export const keys = { conversations: (novelId: string) => ['novels', novelId, 'conversations'] as const, conversation: (id: string) => ['conversations', 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 = () => @@ -126,7 +129,10 @@ export function useCreateNovel() { return useMutation({ mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) => api.post('/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) => { qc.setQueryData(keys.novel(id), updated) 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: () => { qc.invalidateQueries({ queryKey: keys.characters(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: () => { qc.invalidateQueries({ queryKey: keys.characters(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() return useMutation({ 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: () => { qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }) 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: () => { qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }) 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() return useMutation({ 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: ( body: Partial> & { title: string; tags?: string[]; locations?: string[] }, ) => api.post(`/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.tags(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() return useMutation({ 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(`/api/novels/${novelId}/activity`), + }) + +export const useMyActivity = () => + useQuery({ + queryKey: keys.myActivity, + queryFn: () => api.get('/api/activity'), + }) + export function useInspectImport() { return useMutation({ mutationFn: (sourceRoot: string) => api.post('/api/imports/inspect', { sourceRoot }), diff --git a/src/Novelly.Web/src/api/types.ts b/src/Novelly.Web/src/api/types.ts index c4d1175..47615a3 100644 --- a/src/Novelly.Web/src/api/types.ts +++ b/src/Novelly.Web/src/api/types.ts @@ -317,3 +317,17 @@ export interface ImportInspection { chaptersTotal: number completedPasses: string[] } + +export interface ActivityDay { + date: string + words: number + edits: number +} + +export interface ActivityCalendar { + from: string + to: string + totalWords: number + totalEdits: number + days: ActivityDay[] +} diff --git a/src/Novelly.Web/src/components/ContributionGraph.tsx b/src/Novelly.Web/src/components/ContributionGraph.tsx new file mode 100644 index 0000000..b601178 --- /dev/null +++ b/src/Novelly.Web/src/components/ContributionGraph.tsx @@ -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 ( +
+ + {monthLabels.map(({ week, label }) => ( + + {label} + + ))} + + {WEEKDAY_LABELS.map(({ row, label }) => ( + + {label} + + ))} + + {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 ( + + {label} + + ) + })} + + +
+ Less + {[0, 1, 2, 3, 4].map((level) => ( + + ))} + More +
+
+ ) +} diff --git a/src/Novelly.Web/src/pages/ChapterPage.tsx b/src/Novelly.Web/src/pages/ChapterPage.tsx index 32616bc..82795e6 100644 --- a/src/Novelly.Web/src/pages/ChapterPage.tsx +++ b/src/Novelly.Web/src/pages/ChapterPage.tsx @@ -351,7 +351,7 @@ function BeatTable({ canDelete: boolean }) { const update = useUpdateBeat(chapter.id, novelId) - const remove = useDeleteBeat(chapter.id) + const remove = useDeleteBeat(chapter.id, novelId) const reorder = useReorderBeats(chapter.id) const assignCharacter = useAssignCharacterToBeats(chapter.id) const moveBeats = useMoveBeats(chapter.id) diff --git a/src/Novelly.Web/src/pages/DashboardPage.tsx b/src/Novelly.Web/src/pages/DashboardPage.tsx index 3ff2787..548dde7 100644 --- a/src/Novelly.Web/src/pages/DashboardPage.tsx +++ b/src/Novelly.Web/src/pages/DashboardPage.tsx @@ -1,8 +1,9 @@ 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 { useAuth } from '../auth/AuthContext' import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui' +import { ContributionGraph } from '../components/ContributionGraph' const RECENT_COUNT = 5 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: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(novelId) const { data: tags, isPending: tagsPending, error: tagsError } = useTags(novelId) + const { data: activity, isPending: activityPending, error: activityError } = useNovelActivity(novelId) const recentCharacters = [...(characters ?? [])].sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), @@ -59,6 +61,19 @@ function OutliningDashboard({ novelId }: { novelId: string }) { return (
+
+

Activity

+ + {activityError && } + {activityPending ? ( + + ) : !activity || activity.days.length === 0 ? ( + + ) : ( + + )} +
+
diff --git a/src/Novelly.Web/src/pages/NovelsPage.tsx b/src/Novelly.Web/src/pages/NovelsPage.tsx index ec4d3c7..3a8893b 100644 --- a/src/Novelly.Web/src/pages/NovelsPage.tsx +++ b/src/Novelly.Web/src/pages/NovelsPage.tsx @@ -1,14 +1,16 @@ import { useId, useState } from 'react' 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 { ImportDialog } from '../components/ImportDialog' +import { ContributionGraph } from '../components/ContributionGraph' import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui' import { HelpButton } from '../keyboard/HelpButton' import { useHotkey } from '../keyboard/HotkeysContext' export default function NovelsPage() { const { data: novels, isPending, error } = useNovels() + const { data: activity, isPending: activityPending, error: activityError } = useMyActivity() const { user, can } = useAuth() const logout = useLogout() const [creating, setCreating] = useState(false) @@ -61,6 +63,19 @@ export default function NovelsPage() { Outlines, character dossiers, and a writing partner that knows the book.

+
+

Activity

+ + {activityError && } + {activityPending ? ( + + ) : !activity || activity.days.length === 0 ? ( + + ) : ( + + )} +
+ {error && } {isPending && } diff --git a/tests/Novelly.Api.Tests/ActivityTests.cs b/tests/Novelly.Api.Tests/ActivityTests.cs new file mode 100644 index 0000000..552bd20 --- /dev/null +++ b/tests/Novelly.Api.Tests/ActivityTests.cs @@ -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)); + } +} diff --git a/tests/Novelly.Api.Tests/ServiceTestFixture.cs b/tests/Novelly.Api.Tests/ServiceTestFixture.cs index 80a40bb..8aeecb3 100644 --- a/tests/Novelly.Api.Tests/ServiceTestFixture.cs +++ b/tests/Novelly.Api.Tests/ServiceTestFixture.cs @@ -1,3 +1,4 @@ +using Novelly.Api.Activity; using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; @@ -15,6 +16,8 @@ public abstract class ServiceTestFixture protected TestDatabase Db { get; private set; } = null!; protected TestUserContext UserContext { 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 LocationService Locations { get; private set; } = null!; protected NovelService Novels { get; private set; } = null!; @@ -41,6 +44,8 @@ public abstract class ServiceTestFixture Db = new TestDatabase(); UserContext = new TestUserContext(); Access = new NovelAccessService(Db.Context, UserContext, new CapturingLogger()); + ActivityLog = new ActivityLog(Db.Context, UserContext, new CapturingLogger()); + Activity = new ActivityService(Db.Context, Access, new CapturingLogger()); Db.Context.Users.Add(new NovellyUser { @@ -62,25 +67,25 @@ public abstract class ServiceTestFixture QuestionLogs = new CapturingLogger(); GenreLogs = new CapturingLogger(); - Tags = new TagService(Db.Context, Access, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator()); - Locations = new LocationService(Db.Context, Access, LocationLogs, new CreateLocationRequestValidator(), new UpdateLocationRequestValidator()); + Tags = new TagService(Db.Context, Access, ActivityLog, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator()); + Locations = new LocationService(Db.Context, Access, ActivityLog, LocationLogs, new CreateLocationRequestValidator(), new UpdateLocationRequestValidator()); 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( - Db.Context, Access, Tags, CharacterLogs, + Db.Context, Access, Tags, ActivityLog, CharacterLogs, new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(), 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( - Db.Context, Access, Tags, BeatLogs, + Db.Context, Access, Tags, ActivityLog, BeatLogs, new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(), new AssignCharacterToBeatsRequestValidator(), new MoveBeatsRequestValidator()); Arcs = new CharacterArcService( - Db.Context, Access, ArcLogs, + Db.Context, Access, ActivityLog, ArcLogs, new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator(), new SetArcStageBeatsRequestValidator()); Questions = new OpenQuestionService( - Db.Context, Access, QuestionLogs, + Db.Context, Access, ActivityLog, QuestionLogs, new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator()); Genres = new GenreService(Db.Context, GenreLogs);