Phase 5C Subscription Limit Usage

This commit is contained in:
Nick Beckley 2026-06-07 10:25:03 +01:00
parent 49f239b301
commit b94ca66918
10 changed files with 442 additions and 37 deletions

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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<string> SaveUploadedAttachmentAsync(int sceneId, IFormFile uploadedFile)
{
var uploadRoot = Path.Combine(environment.WebRootPath, "uploads", "scenes", sceneId.ToString());

View File

@ -9,6 +9,11 @@ public interface ISubscriptionRepository
Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId);
Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId);
Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync();
Task<int> GetOwnedBookCountAsync(int userId);
Task<int> GetChapterCountForBookAsync(int bookId);
Task<int> GetSceneCountForBookAsync(int bookId);
Task<int> GetCharacterCountForProjectAsync(int projectId);
Task<int> 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<int> GetOwnedBookCountAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.SubscriptionUsage_BookCountForOwner",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<int> GetChapterCountForBookAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.SubscriptionUsage_ChapterCountForBook",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
}
public async Task<int> GetSceneCountForBookAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.SubscriptionUsage_SceneCountForBook",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
}
public async Task<int> GetCharacterCountForProjectAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.SubscriptionUsage_CharacterCountForProject",
new { ProjectID = projectId },
commandType: CommandType.StoredProcedure);
}
public async Task<int> GetCollaboratorCountForOwnerAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.SubscriptionUsage_CollaboratorCountForOwner",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
}

View File

@ -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
};
}

View File

@ -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<int, Task<SubscriptionLimitResult>> 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<BookDetailViewModel?> GetDetailAsync(int bookId)
{
@ -363,14 +385,22 @@ public sealed class BookService(IProjectRepository projects, IBookRepository boo
};
}
public Task<int> SaveAsync(BookEditViewModel model) => books.SaveAsync(new Book
public async Task<int> 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<ChapterDetailViewModel?> GetDetailAsync(int chapterId)
{
@ -458,15 +490,23 @@ public sealed class ChapterService(
};
}
public Task<int> SaveAsync(ChapterEditViewModel model) => chapters.SaveAsync(new Chapter
public async Task<int> 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<SceneEditViewModel?> GetCreateAsync(int chapterId)
{
@ -567,6 +609,17 @@ public sealed class SceneService(
public async Task<int> 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<CharacterListViewModel?> GetCharactersAsync(int projectId)
{
@ -3519,21 +3577,29 @@ public sealed class CharacterService(IProjectRepository projects, IBookRepositor
};
}
public Task<int> SaveCharacterAsync(CharacterEditViewModel model) => characters.SaveCharacterAsync(new Character
public async Task<int> 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);

View File

@ -8,10 +8,17 @@ public interface ISubscriptionService
Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId);
Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId);
Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync();
Task<SubscriptionLimitResult> CanCreateBookAsync(int userId);
Task<SubscriptionLimitResult> CanCreateChapterAsync(int bookId, int userId);
Task<SubscriptionLimitResult> CanCreateSceneAsync(int bookId, int userId);
Task<SubscriptionLimitResult> CanCreateCharacterAsync(int projectId, int userId);
Task<SubscriptionLimitResult> CanInviteCollaboratorAsync(int userId);
}
public sealed class SubscriptionService(ISubscriptionRepository subscriptions) : ISubscriptionService
{
private const int PracticalUnlimited = 999999;
public Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId) =>
subscriptions.GetCurrentSubscriptionAsync(userId);
@ -20,4 +27,65 @@ public sealed class SubscriptionService(ISubscriptionRepository subscriptions) :
public Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync() =>
subscriptions.GetActiveSubscriptionLevelsAsync();
public async Task<SubscriptionLimitResult> CanCreateBookAsync(int userId)
{
var subscription = await GetRequiredSubscriptionAsync(userId);
var currentUsage = await subscriptions.GetOwnedBookCountAsync(userId);
return Evaluate(subscription, currentUsage, subscription.MaxBooks, "books");
}
public async Task<SubscriptionLimitResult> 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<SubscriptionLimitResult> 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<SubscriptionLimitResult> 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<SubscriptionLimitResult> CanInviteCollaboratorAsync(int userId)
{
var subscription = await GetRequiredSubscriptionAsync(userId);
var currentUsage = await subscriptions.GetCollaboratorCountForOwnerAsync(userId);
return Evaluate(subscription, currentUsage, subscription.MaxCollaborators, "collaborators");
}
private async Task<UserSubscriptionDetail> 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);

View File

@ -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

View File

@ -13,6 +13,7 @@
<form asp-action="Save" method="post" class="edit-panel">
<input asp-for="CharacterID" type="hidden" />
<input asp-for="ProjectID" type="hidden" />
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="row g-3">
<div class="col-md-8">