Phase 5B Project Subscription Infrastructure

This commit is contained in:
Nick Beckley 2026-06-07 10:13:16 +01:00
parent 90b2212890
commit 49f239b301
8 changed files with 415 additions and 8 deletions

View File

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

View File

@ -0,0 +1,42 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface ISubscriptionRepository
{
Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId);
Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId);
Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync();
}
public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFactory) : ISubscriptionRepository
{
public async Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<UserSubscriptionDetail>(
"dbo.UserSubscription_GetCurrent",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<SubscriptionLevel>(
"dbo.SubscriptionLevel_Get",
new { SubscriptionLevelID = subscriptionLevelId },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync()
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<SubscriptionLevel>(
"dbo.SubscriptionLevel_ListActive",
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
}

View File

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

View File

@ -103,6 +103,7 @@ public class Program
builder.Services.AddScoped<IAcceptanceRepository, AcceptanceRepository>();
builder.Services.AddScoped<IImportRepository, ImportRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
builder.Services.AddScoped<IProjectService, ProjectService>();
builder.Services.AddScoped<IBookService, BookService>();
builder.Services.AddScoped<IChapterService, ChapterService>();
@ -128,6 +129,7 @@ public class Program
builder.Services.AddScoped<IAcceptanceService, AcceptanceService>();
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddSingleton<IHelpService, HelpService>();
builder.Services.AddSingleton<IHelpMarkdownRenderer, HelpMarkdownRenderer>();
builder.Services.AddScoped<IAuthService, AuthService>();

View File

@ -0,0 +1,23 @@
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface ISubscriptionService
{
Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId);
Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId);
Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync();
}
public sealed class SubscriptionService(ISubscriptionRepository subscriptions) : ISubscriptionService
{
public Task<UserSubscriptionDetail?> GetCurrentSubscriptionAsync(int userId) =>
subscriptions.GetCurrentSubscriptionAsync(userId);
public Task<SubscriptionLevel?> GetSubscriptionLevelAsync(int subscriptionLevelId) =>
subscriptions.GetSubscriptionLevelAsync(subscriptionLevelId);
public Task<IReadOnlyList<SubscriptionLevel>> GetActiveSubscriptionLevelsAsync() =>
subscriptions.GetActiveSubscriptionLevelsAsync();
}

View File

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

View File

@ -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.")]

View File

@ -1,13 +1,14 @@
@model AppUser
@model ManageAccountViewModel
@{
ViewData["Title"] = "Manage Account";
var subscription = Model.Subscription;
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Manage Account</h1>
<p class="lead-text">@Model.Email</p>
<p class="lead-text">@Model.User.Email</p>
</div>
</div>
@ -21,15 +22,15 @@
<div class="row g-3 mb-3">
<div class="col-md-4">
<p class="eyebrow">Email address</p>
<p>@Model.Email</p>
<p>@Model.User.Email</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Email verified</p>
<p>@(Model.EmailConfirmed ? "Yes" : "No")</p>
<p>@(Model.User.EmailConfirmed ? "Yes" : "No")</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Two-factor authentication</p>
<p>@(Model.TwoFactorEnabled ? "Enabled" : "Not enabled")</p>
<p>@(Model.User.TwoFactorEnabled ? "Enabled" : "Not enabled")</p>
</div>
</div>
<div class="button-row mb-4">
@ -39,7 +40,7 @@
<h2>Two-Factor Authentication</h2>
<p class="muted">Use an authenticator app to add an extra sign-in step for your account.</p>
<div class="button-row">
@if (Model.TwoFactorEnabled)
@if (Model.User.TwoFactorEnabled)
{
<a class="btn btn-outline-primary" asp-action="RecoveryCodes">Recovery Codes</a>
<a class="btn btn-outline-danger" asp-action="DisableTwoFactor">Disable Two Factor</a>
@ -50,3 +51,50 @@
}
</div>
</section>
<section class="edit-panel">
<h2>Subscription</h2>
@if (subscription is null)
{
<p class="muted">No active subscription is assigned.</p>
}
else
{
<div class="row g-3">
<div class="col-md-4">
<p class="eyebrow">Current plan</p>
<p>@subscription.DisplayName</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Books</p>
<p>@FormatLimit(subscription.MaxBooks)</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Storage</p>
<p>@FormatStorage(subscription.MaxStorageMB)</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Chapters per book</p>
<p>@FormatLimit(subscription.MaxChaptersPerBook)</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Scenes per book</p>
<p>@FormatLimit(subscription.MaxScenesPerBook)</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Characters per project</p>
<p>@FormatLimit(subscription.MaxCharactersPerProject)</p>
</div>
<div class="col-md-4">
<p class="eyebrow">Collaborators</p>
<p>@subscription.MaxCollaborators</p>
</div>
</div>
}
</section>
@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";
}