Phase 5C Subscription Limit Usage
This commit is contained in:
parent
49f239b301
commit
b94ca66918
@ -35,7 +35,26 @@ public sealed class BooksController(IBookService books) : Controller
|
|||||||
return View("Edit", model);
|
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 });
|
return RedirectToAction(nameof(Details), new { id = bookId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,27 @@ public sealed class ChaptersController(IChapterService chapters) : Controller
|
|||||||
return View("Edit", model);
|
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 });
|
return RedirectToAction(nameof(Details), new { id = chapterId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,33 @@ public sealed class CharactersController(ICharacterService characters) : Control
|
|||||||
return View("Edit", model);
|
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 });
|
return RedirectToAction(nameof(Details), new { id = characterId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -38,7 +38,24 @@ public sealed class ScenesController(
|
|||||||
return View("Edit", model);
|
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)
|
if (model.ReturnToTimeline && model.ReturnProjectID.HasValue)
|
||||||
{
|
{
|
||||||
return RedirectToAction("Index", "Timeline", new
|
return RedirectToAction("Index", "Timeline", new
|
||||||
@ -402,6 +419,39 @@ public sealed class ScenesController(
|
|||||||
return RedirectToAction(nameof(Edit), new { id = sceneId });
|
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)
|
private async Task<string> SaveUploadedAttachmentAsync(int sceneId, IFormFile uploadedFile)
|
||||||
{
|
{
|
||||||
var uploadRoot = Path.Combine(environment.WebRootPath, "uploads", "scenes", sceneId.ToString());
|
var uploadRoot = Path.Combine(environment.WebRootPath, "uploads", "scenes", sceneId.ToString());
|
||||||
|
|||||||
@ -9,6 +9,11 @@ public interface ISubscriptionRepository
|
|||||||
Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId);
|
Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId);
|
||||||
Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId);
|
Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId);
|
||||||
Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync();
|
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
|
public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFactory) : ISubscriptionRepository
|
||||||
@ -39,4 +44,49 @@ public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFacto
|
|||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
return rows.ToList();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,3 +46,26 @@ public sealed class UserSubscriptionDetail : UserSubscription
|
|||||||
public bool SubscriptionLevelIsActive { get; set; }
|
public bool SubscriptionLevelIsActive { get; set; }
|
||||||
public int SortOrder { 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -303,7 +303,29 @@ public sealed class ProjectService(IProjectRepository projects, IBookRepository
|
|||||||
=> currentUser.UserId.HasValue ? projects.ArchiveForUserAsync(projectId, currentUser.UserId.Value) : Task.CompletedTask;
|
=> 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)
|
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,
|
if (model.BookID == 0)
|
||||||
ProjectID = model.ProjectID,
|
{
|
||||||
BookTitle = model.BookTitle,
|
await SubscriptionLimitGuards.EnsureAllowedAsync(subscriptions.CanCreateBookAsync, currentUser.UserId);
|
||||||
BookNumber = model.BookNumber,
|
}
|
||||||
Description = model.Description
|
|
||||||
});
|
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);
|
public Task ArchiveAsync(int bookId) => books.ArchiveAsync(bookId);
|
||||||
}
|
}
|
||||||
@ -380,7 +410,9 @@ public sealed class ChapterService(
|
|||||||
IBookRepository books,
|
IBookRepository books,
|
||||||
IChapterRepository chapters,
|
IChapterRepository chapters,
|
||||||
ISceneRepository scenes,
|
ISceneRepository scenes,
|
||||||
ILookupRepository lookups) : IChapterService
|
ILookupRepository lookups,
|
||||||
|
ISubscriptionService subscriptions,
|
||||||
|
ICurrentUserService currentUser) : IChapterService
|
||||||
{
|
{
|
||||||
public async Task<ChapterDetailViewModel?> GetDetailAsync(int chapterId)
|
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,
|
if (model.ChapterID == 0)
|
||||||
BookID = model.BookID,
|
{
|
||||||
ChapterNumber = model.ChapterNumber,
|
await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateChapterAsync(model.BookID, userId), currentUser.UserId);
|
||||||
ChapterTitle = model.ChapterTitle,
|
}
|
||||||
Summary = model.Summary,
|
|
||||||
RevisionStatusID = model.RevisionStatusID
|
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);
|
public Task ArchiveAsync(int chapterId) => chapters.ArchiveAsync(chapterId);
|
||||||
|
|
||||||
@ -490,7 +530,9 @@ public sealed class SceneService(
|
|||||||
ILocationRepository locations,
|
ILocationRepository locations,
|
||||||
IWarningRepository warnings,
|
IWarningRepository warnings,
|
||||||
ISceneDependencyRepository sceneDependencies,
|
ISceneDependencyRepository sceneDependencies,
|
||||||
IWriterWorkspaceRepository writerWorkspace) : ISceneService
|
IWriterWorkspaceRepository writerWorkspace,
|
||||||
|
ISubscriptionService subscriptions,
|
||||||
|
ICurrentUserService currentUser) : ISceneService
|
||||||
{
|
{
|
||||||
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
|
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
|
||||||
{
|
{
|
||||||
@ -567,6 +609,17 @@ public sealed class SceneService(
|
|||||||
|
|
||||||
public async Task<int> SaveAsync(SceneEditViewModel model)
|
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
|
var sceneId = await scenes.SaveAsync(new Scene
|
||||||
{
|
{
|
||||||
SceneID = model.SceneID,
|
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)
|
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,
|
if (model.CharacterID == 0)
|
||||||
ProjectID = model.ProjectID,
|
{
|
||||||
CharacterName = model.CharacterName,
|
await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateCharacterAsync(model.ProjectID, userId), currentUser.UserId);
|
||||||
ShortName = model.ShortName,
|
}
|
||||||
Sex = model.Sex,
|
|
||||||
BirthDate = model.BirthDate,
|
return await characters.SaveCharacterAsync(new Character
|
||||||
AgeAtSeriesStart = model.AgeAtSeriesStart,
|
{
|
||||||
Height = model.Height,
|
CharacterID = model.CharacterID,
|
||||||
EyeColour = model.EyeColour,
|
ProjectID = model.ProjectID,
|
||||||
CharacterImportance = model.CharacterImportance,
|
CharacterName = model.CharacterName,
|
||||||
ShowInQuickAddBar = model.ShowInQuickAddBar,
|
ShortName = model.ShortName,
|
||||||
DefaultDescription = model.DefaultDescription
|
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);
|
public Task ArchiveCharacterAsync(int characterId) => characters.ArchiveCharacterAsync(characterId);
|
||||||
|
|
||||||
|
|||||||
@ -8,10 +8,17 @@ public interface ISubscriptionService
|
|||||||
Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId);
|
Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId);
|
||||||
Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId);
|
Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId);
|
||||||
Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync();
|
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
|
public sealed class SubscriptionService(ISubscriptionRepository subscriptions) : ISubscriptionService
|
||||||
{
|
{
|
||||||
|
private const int PracticalUnlimited = 999999;
|
||||||
|
|
||||||
public Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId) =>
|
public Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId) =>
|
||||||
subscriptions.GetCurrentSubscriptionAsync(userId);
|
subscriptions.GetCurrentSubscriptionAsync(userId);
|
||||||
|
|
||||||
@ -20,4 +27,65 @@ public sealed class SubscriptionService(ISubscriptionRepository subscriptions) :
|
|||||||
|
|
||||||
public Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync() =>
|
public Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync() =>
|
||||||
subscriptions.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);
|
||||||
|
|||||||
82
PlotLine/Sql/044_Phase5C_SubscriptionLimitUsage.sql
Normal file
82
PlotLine/Sql/044_Phase5C_SubscriptionLimitUsage.sql
Normal 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
|
||||||
@ -13,6 +13,7 @@
|
|||||||
<form asp-action="Save" method="post" class="edit-panel">
|
<form asp-action="Save" method="post" class="edit-panel">
|
||||||
<input asp-for="CharacterID" type="hidden" />
|
<input asp-for="CharacterID" type="hidden" />
|
||||||
<input asp-for="ProjectID" type="hidden" />
|
<input asp-for="ProjectID" type="hidden" />
|
||||||
|
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user