From 49f239b301ea221818b276ba0d01c4598f00777c Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 7 Jun 2026 10:13:16 +0100 Subject: [PATCH] Phase 5B Project Subscription Infrastructure --- .../Controllers/ManageAccountController.cs | 9 +- PlotLine/Data/SubscriptionRepository.cs | 42 ++++ PlotLine/Models/SubscriptionModels.cs | 48 ++++ PlotLine/Program.cs | 2 + PlotLine/Services/SubscriptionServices.cs | 23 ++ ...043_Phase5B_SubscriptionInfrastructure.sql | 232 ++++++++++++++++++ PlotLine/ViewModels/AuthViewModels.cs | 7 + PlotLine/Views/ManageAccount/Index.cshtml | 60 ++++- 8 files changed, 415 insertions(+), 8 deletions(-) create mode 100644 PlotLine/Data/SubscriptionRepository.cs create mode 100644 PlotLine/Models/SubscriptionModels.cs create mode 100644 PlotLine/Services/SubscriptionServices.cs create mode 100644 PlotLine/Sql/043_Phase5B_SubscriptionInfrastructure.sql diff --git a/PlotLine/Controllers/ManageAccountController.cs b/PlotLine/Controllers/ManageAccountController.cs index c3b8aef..0d28b49 100644 --- a/PlotLine/Controllers/ManageAccountController.cs +++ b/PlotLine/Controllers/ManageAccountController.cs @@ -12,7 +12,8 @@ public sealed class ManageAccountController( ICurrentUserService currentUser, IUserRepository users, IAuthService authService, - ITwoFactorService twoFactorService) : Controller + ITwoFactorService twoFactorService, + ISubscriptionService subscriptions) : Controller { [HttpGet] public async Task Index() @@ -23,7 +24,11 @@ public sealed class ManageAccountController( return RedirectToAction("Login", "Account"); } - return View(user); + return View(new ManageAccountViewModel + { + User = user, + Subscription = await subscriptions.GetCurrentSubscriptionAsync(user.UserID) + }); } [HttpGet] diff --git a/PlotLine/Data/SubscriptionRepository.cs b/PlotLine/Data/SubscriptionRepository.cs new file mode 100644 index 0000000..619d4ed --- /dev/null +++ b/PlotLine/Data/SubscriptionRepository.cs @@ -0,0 +1,42 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface ISubscriptionRepository +{ + Task GetCurrentSubscriptionAsync(int userId); + Task GetSubscriptionLevelAsync(int subscriptionLevelId); + Task> GetActiveSubscriptionLevelsAsync(); +} + +public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFactory) : ISubscriptionRepository +{ + public async Task GetCurrentSubscriptionAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.UserSubscription_GetCurrent", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetSubscriptionLevelAsync(int subscriptionLevelId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.SubscriptionLevel_Get", + new { SubscriptionLevelID = subscriptionLevelId }, + commandType: CommandType.StoredProcedure); + } + + public async Task> GetActiveSubscriptionLevelsAsync() + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.SubscriptionLevel_ListActive", + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } +} diff --git a/PlotLine/Models/SubscriptionModels.cs b/PlotLine/Models/SubscriptionModels.cs new file mode 100644 index 0000000..95ed9ea --- /dev/null +++ b/PlotLine/Models/SubscriptionModels.cs @@ -0,0 +1,48 @@ +namespace PlotLine.Models; + +public sealed class SubscriptionLevel +{ + public int SubscriptionLevelID { get; set; } + public string Name { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string? Description { get; set; } + public int MaxBooks { get; set; } + public int MaxChaptersPerBook { get; set; } + public int MaxScenesPerBook { get; set; } + public int MaxCharactersPerProject { get; set; } + public int MaxStorageMB { get; set; } + public int MaxCollaborators { get; set; } + public bool IsActive { get; set; } + public int SortOrder { get; set; } + public DateTime CreatedDateUTC { get; set; } + public DateTime ModifiedDateUTC { get; set; } +} + +public class UserSubscription +{ + public int UserSubscriptionID { get; set; } + public int UserID { get; set; } + public int SubscriptionLevelID { get; set; } + public DateTime StartDateUTC { get; set; } + public DateTime? EndDateUTC { get; set; } + public bool IsActive { get; set; } + public bool IsTrial { get; set; } + public DateTime? TrialExpiryDateUTC { get; set; } + public DateTime CreatedDateUTC { get; set; } + public DateTime ModifiedDateUTC { get; set; } +} + +public sealed class UserSubscriptionDetail : UserSubscription +{ + public string Name { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string? Description { get; set; } + public int MaxBooks { get; set; } + public int MaxChaptersPerBook { get; set; } + public int MaxScenesPerBook { get; set; } + public int MaxCharactersPerProject { get; set; } + public int MaxStorageMB { get; set; } + public int MaxCollaborators { get; set; } + public bool SubscriptionLevelIsActive { get; set; } + public int SortOrder { get; set; } +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 59ea2d8..950c8b6 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -103,6 +103,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -128,6 +129,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/SubscriptionServices.cs b/PlotLine/Services/SubscriptionServices.cs new file mode 100644 index 0000000..56bb4e0 --- /dev/null +++ b/PlotLine/Services/SubscriptionServices.cs @@ -0,0 +1,23 @@ +using PlotLine.Data; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface ISubscriptionService +{ + Task GetCurrentSubscriptionAsync(int userId); + Task GetSubscriptionLevelAsync(int subscriptionLevelId); + Task> GetActiveSubscriptionLevelsAsync(); +} + +public sealed class SubscriptionService(ISubscriptionRepository subscriptions) : ISubscriptionService +{ + public Task GetCurrentSubscriptionAsync(int userId) => + subscriptions.GetCurrentSubscriptionAsync(userId); + + public Task GetSubscriptionLevelAsync(int subscriptionLevelId) => + subscriptions.GetSubscriptionLevelAsync(subscriptionLevelId); + + public Task> GetActiveSubscriptionLevelsAsync() => + subscriptions.GetActiveSubscriptionLevelsAsync(); +} diff --git a/PlotLine/Sql/043_Phase5B_SubscriptionInfrastructure.sql b/PlotLine/Sql/043_Phase5B_SubscriptionInfrastructure.sql new file mode 100644 index 0000000..b27770e --- /dev/null +++ b/PlotLine/Sql/043_Phase5B_SubscriptionInfrastructure.sql @@ -0,0 +1,232 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.SubscriptionLevel', N'U') IS NULL +BEGIN + CREATE TABLE dbo.SubscriptionLevel + ( + SubscriptionLevelID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_SubscriptionLevel PRIMARY KEY, + Name nvarchar(100) NOT NULL, + DisplayName nvarchar(100) NOT NULL, + Description nvarchar(500) NULL, + MaxBooks int NOT NULL, + MaxChaptersPerBook int NOT NULL, + MaxScenesPerBook int NOT NULL, + MaxCharactersPerProject int NOT NULL, + MaxStorageMB int NOT NULL, + MaxCollaborators int NOT NULL, + IsActive bit NOT NULL CONSTRAINT DF_SubscriptionLevel_IsActive DEFAULT 1, + SortOrder int NOT NULL, + CreatedDateUTC datetime2 NOT NULL CONSTRAINT DF_SubscriptionLevel_CreatedDateUTC DEFAULT SYSUTCDATETIME(), + ModifiedDateUTC datetime2 NOT NULL CONSTRAINT DF_SubscriptionLevel_ModifiedDateUTC DEFAULT SYSUTCDATETIME() + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_SubscriptionLevel_Name' AND object_id = OBJECT_ID(N'dbo.SubscriptionLevel')) + CREATE UNIQUE INDEX UX_SubscriptionLevel_Name ON dbo.SubscriptionLevel(Name); +GO + +IF OBJECT_ID(N'dbo.UserSubscription', N'U') IS NULL +BEGIN + CREATE TABLE dbo.UserSubscription + ( + UserSubscriptionID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_UserSubscription PRIMARY KEY, + UserID int NOT NULL, + SubscriptionLevelID int NOT NULL, + StartDateUTC datetime2 NOT NULL CONSTRAINT DF_UserSubscription_StartDateUTC DEFAULT SYSUTCDATETIME(), + EndDateUTC datetime2 NULL, + IsActive bit NOT NULL CONSTRAINT DF_UserSubscription_IsActive DEFAULT 1, + IsTrial bit NOT NULL CONSTRAINT DF_UserSubscription_IsTrial DEFAULT 0, + TrialExpiryDateUTC datetime2 NULL, + CreatedDateUTC datetime2 NOT NULL CONSTRAINT DF_UserSubscription_CreatedDateUTC DEFAULT SYSUTCDATETIME(), + ModifiedDateUTC datetime2 NOT NULL CONSTRAINT DF_UserSubscription_ModifiedDateUTC DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_UserSubscription_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID), + CONSTRAINT FK_UserSubscription_SubscriptionLevel FOREIGN KEY (SubscriptionLevelID) REFERENCES dbo.SubscriptionLevel(SubscriptionLevelID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_UserSubscription_User_Active' AND object_id = OBJECT_ID(N'dbo.UserSubscription')) + CREATE UNIQUE INDEX UX_UserSubscription_User_Active ON dbo.UserSubscription(UserID) WHERE IsActive = 1; +GO + +MERGE dbo.SubscriptionLevel AS target +USING (VALUES + (N'DraftDesk', N'Draft Desk', N'Trial and evaluation.', 2, 75, 300, 75, 100, 0, 1, 10), + (N'WritingRoom', N'Writing Room', N'Standard subscription for most authors.', 10, 150, 1000, 250, 1024, 1, 1, 20), + (N'StoryForge', N'Story Forge', N'Professional authors and co-authors.', 30, 200, 2000, 500, 5120, 3, 1, 30), + (N'AuthorStudio', N'Author Studio', N'Advanced users and writing teams.', 999999, 999999, 999999, 999999, 20480, 10, 1, 40) +) AS source +( + Name, + DisplayName, + Description, + MaxBooks, + MaxChaptersPerBook, + MaxScenesPerBook, + MaxCharactersPerProject, + MaxStorageMB, + MaxCollaborators, + IsActive, + SortOrder +) +ON target.Name = source.Name +WHEN MATCHED THEN UPDATE SET + DisplayName = source.DisplayName, + Description = source.Description, + MaxBooks = source.MaxBooks, + MaxChaptersPerBook = source.MaxChaptersPerBook, + MaxScenesPerBook = source.MaxScenesPerBook, + MaxCharactersPerProject = source.MaxCharactersPerProject, + MaxStorageMB = source.MaxStorageMB, + MaxCollaborators = source.MaxCollaborators, + IsActive = source.IsActive, + SortOrder = source.SortOrder, + ModifiedDateUTC = SYSUTCDATETIME() +WHEN NOT MATCHED THEN + INSERT + ( + Name, + DisplayName, + Description, + MaxBooks, + MaxChaptersPerBook, + MaxScenesPerBook, + MaxCharactersPerProject, + MaxStorageMB, + MaxCollaborators, + IsActive, + SortOrder + ) + VALUES + ( + source.Name, + source.DisplayName, + source.Description, + source.MaxBooks, + source.MaxChaptersPerBook, + source.MaxScenesPerBook, + source.MaxCharactersPerProject, + source.MaxStorageMB, + source.MaxCollaborators, + source.IsActive, + source.SortOrder + ); +GO + +DECLARE @AuthorStudioID int; +SELECT @AuthorStudioID = SubscriptionLevelID +FROM dbo.SubscriptionLevel +WHERE Name = N'AuthorStudio'; + +IF @AuthorStudioID IS NOT NULL +BEGIN + INSERT INTO dbo.UserSubscription (UserID, SubscriptionLevelID, StartDateUTC, IsActive, IsTrial) + SELECT u.UserID, @AuthorStudioID, SYSUTCDATETIME(), 1, 0 + FROM dbo.AppUser u + WHERE NOT EXISTS + ( + SELECT 1 + FROM dbo.UserSubscription us + WHERE us.UserID = u.UserID + AND us.IsActive = 1 + ); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SubscriptionLevel_ListActive +AS +BEGIN + SET NOCOUNT ON; + + SELECT SubscriptionLevelID, Name, DisplayName, Description, MaxBooks, MaxChaptersPerBook, + MaxScenesPerBook, MaxCharactersPerProject, MaxStorageMB, MaxCollaborators, IsActive, + SortOrder, CreatedDateUTC, ModifiedDateUTC + FROM dbo.SubscriptionLevel + WHERE IsActive = 1 + ORDER BY SortOrder, DisplayName; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SubscriptionLevel_Get + @SubscriptionLevelID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT SubscriptionLevelID, Name, DisplayName, Description, MaxBooks, MaxChaptersPerBook, + MaxScenesPerBook, MaxCharactersPerProject, MaxStorageMB, MaxCollaborators, IsActive, + SortOrder, CreatedDateUTC, ModifiedDateUTC + FROM dbo.SubscriptionLevel + WHERE SubscriptionLevelID = @SubscriptionLevelID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.UserSubscription_GetCurrent + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT us.UserSubscriptionID, us.UserID, us.SubscriptionLevelID, us.StartDateUTC, us.EndDateUTC, + us.IsActive, us.IsTrial, us.TrialExpiryDateUTC, us.CreatedDateUTC, us.ModifiedDateUTC, + sl.Name, sl.DisplayName, sl.Description, sl.MaxBooks, sl.MaxChaptersPerBook, + sl.MaxScenesPerBook, sl.MaxCharactersPerProject, sl.MaxStorageMB, sl.MaxCollaborators, + sl.IsActive AS SubscriptionLevelIsActive, sl.SortOrder + FROM dbo.UserSubscription us + INNER JOIN dbo.SubscriptionLevel sl ON sl.SubscriptionLevelID = us.SubscriptionLevelID + WHERE us.UserID = @UserID + AND us.IsActive = 1 + AND (us.EndDateUTC IS NULL OR us.EndDateUTC > SYSUTCDATETIME()); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.UserSubscription_AssignDefaultDraftDesk + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @DraftDeskID int; + SELECT @DraftDeskID = SubscriptionLevelID + FROM dbo.SubscriptionLevel + WHERE Name = N'DraftDesk'; + + IF @DraftDeskID IS NULL + RETURN; + + IF NOT EXISTS + ( + SELECT 1 + FROM dbo.UserSubscription + WHERE UserID = @UserID + AND IsActive = 1 + ) + BEGIN + INSERT INTO dbo.UserSubscription (UserID, SubscriptionLevelID, StartDateUTC, IsActive, IsTrial) + VALUES (@UserID, @DraftDeskID, SYSUTCDATETIME(), 1, 0); + END; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_Create + @Email nvarchar(256), + @DisplayName nvarchar(200), + @PasswordHash nvarchar(max), + @EmailConfirmed bit = 0 +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO dbo.AppUser (Email, DisplayName, PasswordHash, EmailConfirmed) + VALUES (@Email, @DisplayName, @PasswordHash, @EmailConfirmed); + + DECLARE @UserID int = SCOPE_IDENTITY(); + EXEC dbo.UserSubscription_AssignDefaultDraftDesk @UserID = @UserID; + + EXEC dbo.User_GetById @UserID = @UserID; +END; +GO diff --git a/PlotLine/ViewModels/AuthViewModels.cs b/PlotLine/ViewModels/AuthViewModels.cs index 563d0ac..7d07625 100644 --- a/PlotLine/ViewModels/AuthViewModels.cs +++ b/PlotLine/ViewModels/AuthViewModels.cs @@ -1,7 +1,14 @@ using System.ComponentModel.DataAnnotations; +using PlotLine.Models; namespace PlotLine.ViewModels; +public sealed class ManageAccountViewModel +{ + public AppUser User { get; set; } = new(); + public UserSubscriptionDetail? Subscription { get; set; } +} + public sealed class RegisterViewModel { [Required(ErrorMessage = "Enter your email address.")] diff --git a/PlotLine/Views/ManageAccount/Index.cshtml b/PlotLine/Views/ManageAccount/Index.cshtml index 90974ce..1c86e04 100644 --- a/PlotLine/Views/ManageAccount/Index.cshtml +++ b/PlotLine/Views/ManageAccount/Index.cshtml @@ -1,13 +1,14 @@ -@model AppUser +@model ManageAccountViewModel @{ ViewData["Title"] = "Manage Account"; + var subscription = Model.Subscription; }

Account

Manage Account

-

@Model.Email

+

@Model.User.Email

@@ -21,15 +22,15 @@

Email address

-

@Model.Email

+

@Model.User.Email

Email verified

-

@(Model.EmailConfirmed ? "Yes" : "No")

+

@(Model.User.EmailConfirmed ? "Yes" : "No")

Two-factor authentication

-

@(Model.TwoFactorEnabled ? "Enabled" : "Not enabled")

+

@(Model.User.TwoFactorEnabled ? "Enabled" : "Not enabled")

@@ -39,7 +40,7 @@

Two-Factor Authentication

Use an authenticator app to add an extra sign-in step for your account.

- @if (Model.TwoFactorEnabled) + @if (Model.User.TwoFactorEnabled) { Recovery Codes Disable Two Factor @@ -50,3 +51,50 @@ }
+ +
+

Subscription

+ @if (subscription is null) + { +

No active subscription is assigned.

+ } + else + { +
+
+

Current plan

+

@subscription.DisplayName

+
+
+

Books

+

@FormatLimit(subscription.MaxBooks)

+
+
+

Storage

+

@FormatStorage(subscription.MaxStorageMB)

+
+
+

Chapters per book

+

@FormatLimit(subscription.MaxChaptersPerBook)

+
+
+

Scenes per book

+

@FormatLimit(subscription.MaxScenesPerBook)

+
+
+

Characters per project

+

@FormatLimit(subscription.MaxCharactersPerProject)

+
+
+

Collaborators

+

@subscription.MaxCollaborators

+
+
+ } +
+ +@functions { + private static string FormatLimit(int value) => value >= 999999 ? "Practical unlimited" : value.ToString("N0"); + + private static string FormatStorage(int value) => value >= 1024 ? $"{value / 1024:N0} GB" : $"{value:N0} MB"; +}