diff --git a/PlotLine/Controllers/BooksController.cs b/PlotLine/Controllers/BooksController.cs index 1e94a71..897195d 100644 --- a/PlotLine/Controllers/BooksController.cs +++ b/PlotLine/Controllers/BooksController.cs @@ -35,7 +35,26 @@ public sealed class BooksController(IBookService books) : Controller return View("Edit", model); } - var bookId = await books.SaveAsync(model); + int bookId; + try + { + bookId = await books.SaveAsync(model); + } + catch (SubscriptionLimitException ex) when (model.BookID == 0) + { + ModelState.AddModelError(string.Empty, ex.Message); + var createModel = await books.GetCreateAsync(model.ProjectID); + if (createModel is null) + { + return NotFound(); + } + + createModel.BookTitle = model.BookTitle; + createModel.BookNumber = model.BookNumber; + createModel.Description = model.Description; + return View("Edit", createModel); + } + return RedirectToAction(nameof(Details), new { id = bookId }); } diff --git a/PlotLine/Controllers/ChaptersController.cs b/PlotLine/Controllers/ChaptersController.cs index 3dcd231..e19859c 100644 --- a/PlotLine/Controllers/ChaptersController.cs +++ b/PlotLine/Controllers/ChaptersController.cs @@ -35,7 +35,27 @@ public sealed class ChaptersController(IChapterService chapters) : Controller return View("Edit", model); } - var chapterId = await chapters.SaveAsync(model); + int chapterId; + try + { + chapterId = await chapters.SaveAsync(model); + } + catch (SubscriptionLimitException ex) when (model.ChapterID == 0) + { + ModelState.AddModelError(string.Empty, ex.Message); + var createModel = await chapters.GetCreateAsync(model.BookID); + if (createModel is null) + { + return NotFound(); + } + + createModel.ChapterNumber = model.ChapterNumber; + createModel.ChapterTitle = model.ChapterTitle; + createModel.Summary = model.Summary; + createModel.RevisionStatusID = model.RevisionStatusID; + return View("Edit", createModel); + } + return RedirectToAction(nameof(Details), new { id = chapterId }); } diff --git a/PlotLine/Controllers/CharactersController.cs b/PlotLine/Controllers/CharactersController.cs index a8b0ab1..3b29ffe 100644 --- a/PlotLine/Controllers/CharactersController.cs +++ b/PlotLine/Controllers/CharactersController.cs @@ -41,7 +41,33 @@ public sealed class CharactersController(ICharacterService characters) : Control return View("Edit", model); } - var characterId = await characters.SaveCharacterAsync(model); + int characterId; + try + { + characterId = await characters.SaveCharacterAsync(model); + } + catch (SubscriptionLimitException ex) when (model.CharacterID == 0) + { + ModelState.AddModelError(string.Empty, ex.Message); + var createModel = await characters.GetCreateCharacterAsync(model.ProjectID); + if (createModel is null) + { + return NotFound(); + } + + createModel.CharacterName = model.CharacterName; + createModel.ShortName = model.ShortName; + createModel.Sex = model.Sex; + createModel.BirthDate = model.BirthDate; + createModel.AgeAtSeriesStart = model.AgeAtSeriesStart; + createModel.Height = model.Height; + createModel.EyeColour = model.EyeColour; + createModel.CharacterImportance = model.CharacterImportance; + createModel.ShowInQuickAddBar = model.ShowInQuickAddBar; + createModel.DefaultDescription = model.DefaultDescription; + return View("Edit", createModel); + } + return RedirectToAction(nameof(Details), new { id = characterId }); } diff --git a/PlotLine/Controllers/ScenesController.cs b/PlotLine/Controllers/ScenesController.cs index b88aa33..c99d200 100644 --- a/PlotLine/Controllers/ScenesController.cs +++ b/PlotLine/Controllers/ScenesController.cs @@ -38,7 +38,24 @@ public sealed class ScenesController( return View("Edit", model); } - var sceneId = await scenes.SaveAsync(model); + int sceneId; + try + { + sceneId = await scenes.SaveAsync(model); + } + catch (SubscriptionLimitException ex) when (model.SceneID == 0) + { + ModelState.AddModelError(string.Empty, ex.Message); + var createModel = await scenes.GetCreateAsync(model.ChapterID); + if (createModel is null) + { + return NotFound(); + } + + CopyPostedSceneFields(model, createModel); + return View("Edit", createModel); + } + if (model.ReturnToTimeline && model.ReturnProjectID.HasValue) { return RedirectToAction("Index", "Timeline", new @@ -402,6 +419,39 @@ public sealed class ScenesController( return RedirectToAction(nameof(Edit), new { id = sceneId }); } + private static void CopyPostedSceneFields(SceneEditViewModel source, SceneEditViewModel target) + { + target.SceneTitle = source.SceneTitle; + target.SceneNumber = source.SceneNumber; + target.Summary = source.Summary; + target.TimeModeID = source.TimeModeID; + target.StartDateTime = source.StartDateTime; + target.EndDateTime = source.EndDateTime; + target.DurationAmount = source.DurationAmount; + target.DurationUnitID = source.DurationUnitID; + target.RelativeTimeText = source.RelativeTimeText; + target.TimeConfidenceID = source.TimeConfidenceID; + target.ScenePurposeNotes = source.ScenePurposeNotes; + target.SceneOutcomeNotes = source.SceneOutcomeNotes; + target.RevisionStatusID = source.RevisionStatusID; + target.PrimaryLocationID = source.PrimaryLocationID; + target.SelectedPurposeTypeIds = source.SelectedPurposeTypeIds; + target.ReturnToTimeline = source.ReturnToTimeline; + target.ReturnProjectID = source.ReturnProjectID; + target.ReturnBookID = source.ReturnBookID; + target.Metrics = target.Metrics.Select(metric => + { + var postedMetric = source.Metrics.FirstOrDefault(x => x.MetricTypeID == metric.MetricTypeID); + if (postedMetric is not null) + { + metric.Value = postedMetric.Value; + metric.Notes = postedMetric.Notes; + } + + return metric; + }).ToList(); + } + private async Task SaveUploadedAttachmentAsync(int sceneId, IFormFile uploadedFile) { var uploadRoot = Path.Combine(environment.WebRootPath, "uploads", "scenes", sceneId.ToString()); diff --git a/PlotLine/Data/SubscriptionRepository.cs b/PlotLine/Data/SubscriptionRepository.cs index 619d4ed..83f20ed 100644 --- a/PlotLine/Data/SubscriptionRepository.cs +++ b/PlotLine/Data/SubscriptionRepository.cs @@ -9,6 +9,11 @@ public interface ISubscriptionRepository Task GetCurrentSubscriptionAsync(int userId); Task GetSubscriptionLevelAsync(int subscriptionLevelId); Task> GetActiveSubscriptionLevelsAsync(); + Task GetOwnedBookCountAsync(int userId); + Task GetChapterCountForBookAsync(int bookId); + Task GetSceneCountForBookAsync(int bookId); + Task GetCharacterCountForProjectAsync(int projectId); + Task GetCollaboratorCountForOwnerAsync(int userId); } public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFactory) : ISubscriptionRepository @@ -39,4 +44,49 @@ public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFacto commandType: CommandType.StoredProcedure); return rows.ToList(); } + + public async Task GetOwnedBookCountAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.SubscriptionUsage_BookCountForOwner", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetChapterCountForBookAsync(int bookId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.SubscriptionUsage_ChapterCountForBook", + new { BookID = bookId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetSceneCountForBookAsync(int bookId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.SubscriptionUsage_SceneCountForBook", + new { BookID = bookId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetCharacterCountForProjectAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.SubscriptionUsage_CharacterCountForProject", + new { ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetCollaboratorCountForOwnerAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.SubscriptionUsage_CollaboratorCountForOwner", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } } diff --git a/PlotLine/Models/SubscriptionModels.cs b/PlotLine/Models/SubscriptionModels.cs index 95ed9ea..4998da4 100644 --- a/PlotLine/Models/SubscriptionModels.cs +++ b/PlotLine/Models/SubscriptionModels.cs @@ -46,3 +46,26 @@ public sealed class UserSubscriptionDetail : UserSubscription public bool SubscriptionLevelIsActive { get; set; } public int SortOrder { get; set; } } + +public sealed class SubscriptionLimitResult +{ + public bool Allowed { get; init; } + public int CurrentUsage { get; init; } + public int MaximumAllowed { get; init; } + public string Message { get; init; } = string.Empty; + + public static SubscriptionLimitResult AllowedResult(int currentUsage, int maximumAllowed) => new() + { + Allowed = true, + CurrentUsage = currentUsage, + MaximumAllowed = maximumAllowed + }; + + public static SubscriptionLimitResult Blocked(int currentUsage, int maximumAllowed, string message) => new() + { + Allowed = false, + CurrentUsage = currentUsage, + MaximumAllowed = maximumAllowed, + Message = message + }; +} diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index aa82b3e..e37a910 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -303,7 +303,29 @@ public sealed class ProjectService(IProjectRepository projects, IBookRepository => currentUser.UserId.HasValue ? projects.ArchiveForUserAsync(projectId, currentUser.UserId.Value) : Task.CompletedTask; } -public sealed class BookService(IProjectRepository projects, IBookRepository books, IChapterRepository chapters) : IBookService +internal static class SubscriptionLimitGuards +{ + public static async Task EnsureAllowedAsync(Func> validateAsync, int? userId) + { + if (!userId.HasValue) + { + throw new SubscriptionLimitException("Sign in before creating new content."); + } + + var result = await validateAsync(userId.Value); + if (!result.Allowed) + { + throw new SubscriptionLimitException(result.Message); + } + } +} + +public sealed class BookService( + IProjectRepository projects, + IBookRepository books, + IChapterRepository chapters, + ISubscriptionService subscriptions, + ICurrentUserService currentUser) : IBookService { public async Task GetDetailAsync(int bookId) { @@ -363,14 +385,22 @@ public sealed class BookService(IProjectRepository projects, IBookRepository boo }; } - public Task SaveAsync(BookEditViewModel model) => books.SaveAsync(new Book + public async Task SaveAsync(BookEditViewModel model) { - BookID = model.BookID, - ProjectID = model.ProjectID, - BookTitle = model.BookTitle, - BookNumber = model.BookNumber, - Description = model.Description - }); + if (model.BookID == 0) + { + await SubscriptionLimitGuards.EnsureAllowedAsync(subscriptions.CanCreateBookAsync, currentUser.UserId); + } + + return await books.SaveAsync(new Book + { + BookID = model.BookID, + ProjectID = model.ProjectID, + BookTitle = model.BookTitle, + BookNumber = model.BookNumber, + Description = model.Description + }); + } public Task ArchiveAsync(int bookId) => books.ArchiveAsync(bookId); } @@ -380,7 +410,9 @@ public sealed class ChapterService( IBookRepository books, IChapterRepository chapters, ISceneRepository scenes, - ILookupRepository lookups) : IChapterService + ILookupRepository lookups, + ISubscriptionService subscriptions, + ICurrentUserService currentUser) : IChapterService { public async Task GetDetailAsync(int chapterId) { @@ -458,15 +490,23 @@ public sealed class ChapterService( }; } - public Task SaveAsync(ChapterEditViewModel model) => chapters.SaveAsync(new Chapter + public async Task SaveAsync(ChapterEditViewModel model) { - ChapterID = model.ChapterID, - BookID = model.BookID, - ChapterNumber = model.ChapterNumber, - ChapterTitle = model.ChapterTitle, - Summary = model.Summary, - RevisionStatusID = model.RevisionStatusID - }); + if (model.ChapterID == 0) + { + await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateChapterAsync(model.BookID, userId), currentUser.UserId); + } + + return await chapters.SaveAsync(new Chapter + { + ChapterID = model.ChapterID, + BookID = model.BookID, + ChapterNumber = model.ChapterNumber, + ChapterTitle = model.ChapterTitle, + Summary = model.Summary, + RevisionStatusID = model.RevisionStatusID + }); + } public Task ArchiveAsync(int chapterId) => chapters.ArchiveAsync(chapterId); @@ -490,7 +530,9 @@ public sealed class SceneService( ILocationRepository locations, IWarningRepository warnings, ISceneDependencyRepository sceneDependencies, - IWriterWorkspaceRepository writerWorkspace) : ISceneService + IWriterWorkspaceRepository writerWorkspace, + ISubscriptionService subscriptions, + ICurrentUserService currentUser) : ISceneService { public async Task GetCreateAsync(int chapterId) { @@ -567,6 +609,17 @@ public sealed class SceneService( public async Task SaveAsync(SceneEditViewModel model) { + if (model.SceneID == 0) + { + var chapter = await chapters.GetAsync(model.ChapterID); + if (chapter is null) + { + throw new SubscriptionLimitException("The chapter could not be found."); + } + + await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateSceneAsync(chapter.BookID, userId), currentUser.UserId); + } + var sceneId = await scenes.SaveAsync(new Scene { SceneID = model.SceneID, @@ -3406,7 +3459,12 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi } } -public sealed class CharacterService(IProjectRepository projects, IBookRepository books, ICharacterRepository characters) : ICharacterService +public sealed class CharacterService( + IProjectRepository projects, + IBookRepository books, + ICharacterRepository characters, + ISubscriptionService subscriptions, + ICurrentUserService currentUser) : ICharacterService { public async Task GetCharactersAsync(int projectId) { @@ -3519,21 +3577,29 @@ public sealed class CharacterService(IProjectRepository projects, IBookRepositor }; } - public Task SaveCharacterAsync(CharacterEditViewModel model) => characters.SaveCharacterAsync(new Character + public async Task SaveCharacterAsync(CharacterEditViewModel model) { - CharacterID = model.CharacterID, - ProjectID = model.ProjectID, - CharacterName = model.CharacterName, - ShortName = model.ShortName, - Sex = model.Sex, - BirthDate = model.BirthDate, - AgeAtSeriesStart = model.AgeAtSeriesStart, - Height = model.Height, - EyeColour = model.EyeColour, - CharacterImportance = model.CharacterImportance, - ShowInQuickAddBar = model.ShowInQuickAddBar, - DefaultDescription = model.DefaultDescription - }); + if (model.CharacterID == 0) + { + await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateCharacterAsync(model.ProjectID, userId), currentUser.UserId); + } + + return await characters.SaveCharacterAsync(new Character + { + CharacterID = model.CharacterID, + ProjectID = model.ProjectID, + CharacterName = model.CharacterName, + ShortName = model.ShortName, + Sex = model.Sex, + BirthDate = model.BirthDate, + AgeAtSeriesStart = model.AgeAtSeriesStart, + Height = model.Height, + EyeColour = model.EyeColour, + CharacterImportance = model.CharacterImportance, + ShowInQuickAddBar = model.ShowInQuickAddBar, + DefaultDescription = model.DefaultDescription + }); + } public Task ArchiveCharacterAsync(int characterId) => characters.ArchiveCharacterAsync(characterId); diff --git a/PlotLine/Services/SubscriptionServices.cs b/PlotLine/Services/SubscriptionServices.cs index 56bb4e0..af64a99 100644 --- a/PlotLine/Services/SubscriptionServices.cs +++ b/PlotLine/Services/SubscriptionServices.cs @@ -8,10 +8,17 @@ public interface ISubscriptionService Task GetCurrentSubscriptionAsync(int userId); Task GetSubscriptionLevelAsync(int subscriptionLevelId); Task> GetActiveSubscriptionLevelsAsync(); + Task CanCreateBookAsync(int userId); + Task CanCreateChapterAsync(int bookId, int userId); + Task CanCreateSceneAsync(int bookId, int userId); + Task CanCreateCharacterAsync(int projectId, int userId); + Task CanInviteCollaboratorAsync(int userId); } public sealed class SubscriptionService(ISubscriptionRepository subscriptions) : ISubscriptionService { + private const int PracticalUnlimited = 999999; + public Task GetCurrentSubscriptionAsync(int userId) => subscriptions.GetCurrentSubscriptionAsync(userId); @@ -20,4 +27,65 @@ public sealed class SubscriptionService(ISubscriptionRepository subscriptions) : public Task> GetActiveSubscriptionLevelsAsync() => subscriptions.GetActiveSubscriptionLevelsAsync(); + + public async Task CanCreateBookAsync(int userId) + { + var subscription = await GetRequiredSubscriptionAsync(userId); + var currentUsage = await subscriptions.GetOwnedBookCountAsync(userId); + return Evaluate(subscription, currentUsage, subscription.MaxBooks, "books"); + } + + public async Task CanCreateChapterAsync(int bookId, int userId) + { + var subscription = await GetRequiredSubscriptionAsync(userId); + var currentUsage = await subscriptions.GetChapterCountForBookAsync(bookId); + return Evaluate(subscription, currentUsage, subscription.MaxChaptersPerBook, "chapters per book"); + } + + public async Task CanCreateSceneAsync(int bookId, int userId) + { + var subscription = await GetRequiredSubscriptionAsync(userId); + var currentUsage = await subscriptions.GetSceneCountForBookAsync(bookId); + return Evaluate(subscription, currentUsage, subscription.MaxScenesPerBook, "scenes per book"); + } + + public async Task CanCreateCharacterAsync(int projectId, int userId) + { + var subscription = await GetRequiredSubscriptionAsync(userId); + var currentUsage = await subscriptions.GetCharacterCountForProjectAsync(projectId); + return Evaluate(subscription, currentUsage, subscription.MaxCharactersPerProject, "characters per project"); + } + + public async Task CanInviteCollaboratorAsync(int userId) + { + var subscription = await GetRequiredSubscriptionAsync(userId); + var currentUsage = await subscriptions.GetCollaboratorCountForOwnerAsync(userId); + return Evaluate(subscription, currentUsage, subscription.MaxCollaborators, "collaborators"); + } + + private async Task GetRequiredSubscriptionAsync(int userId) + { + var subscription = await subscriptions.GetCurrentSubscriptionAsync(userId); + if (subscription is null) + { + throw new SubscriptionLimitException("No active subscription is assigned to your account."); + } + + return subscription; + } + + private static SubscriptionLimitResult Evaluate(UserSubscriptionDetail subscription, int currentUsage, int maximumAllowed, string limitName) + { + if (maximumAllowed >= PracticalUnlimited || currentUsage < maximumAllowed) + { + return SubscriptionLimitResult.AllowedResult(currentUsage, maximumAllowed); + } + + return SubscriptionLimitResult.Blocked( + currentUsage, + maximumAllowed, + $"You have reached the maximum number of {limitName} allowed by your {subscription.DisplayName} subscription ({maximumAllowed:N0}). Upgrade your subscription to add more."); + } } + +public sealed class SubscriptionLimitException(string message) : InvalidOperationException(message); diff --git a/PlotLine/Sql/044_Phase5C_SubscriptionLimitUsage.sql b/PlotLine/Sql/044_Phase5C_SubscriptionLimitUsage.sql new file mode 100644 index 0000000..138098a --- /dev/null +++ b/PlotLine/Sql/044_Phase5C_SubscriptionLimitUsage.sql @@ -0,0 +1,82 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.SubscriptionUsage_BookCountForOwner + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT COUNT(DISTINCT b.BookID) + FROM dbo.Books b + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE pua.UserID = @UserID + AND pua.AccessRole = N'Owner' + AND pua.IsActive = 1 + AND p.IsArchived = 0 + AND b.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SubscriptionUsage_ChapterCountForBook + @BookID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT COUNT(1) + FROM dbo.Chapters + WHERE BookID = @BookID + AND IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SubscriptionUsage_SceneCountForBook + @BookID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT COUNT(1) + FROM dbo.Scenes s + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + WHERE c.BookID = @BookID + AND c.IsArchived = 0 + AND s.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SubscriptionUsage_CharacterCountForProject + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT COUNT(1) + FROM dbo.Characters + WHERE ProjectID = @ProjectID + AND IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SubscriptionUsage_CollaboratorCountForOwner + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT COUNT(DISTINCT collaborator.UserID) + FROM dbo.ProjectUserAccess ownerAccess + INNER JOIN dbo.Projects p ON p.ProjectID = ownerAccess.ProjectID + INNER JOIN dbo.ProjectUserAccess collaborator ON collaborator.ProjectID = ownerAccess.ProjectID + WHERE ownerAccess.UserID = @UserID + AND ownerAccess.AccessRole = N'Owner' + AND ownerAccess.IsActive = 1 + AND collaborator.AccessRole = N'Collaborator' + AND collaborator.IsActive = 1 + AND p.IsArchived = 0; +END; +GO diff --git a/PlotLine/Views/Characters/Edit.cshtml b/PlotLine/Views/Characters/Edit.cshtml index 77078b4..e308315 100644 --- a/PlotLine/Views/Characters/Edit.cshtml +++ b/PlotLine/Views/Characters/Edit.cshtml @@ -13,6 +13,7 @@
+