Phase 5F – Storage Management and Quota Enforcement

This commit is contained in:
Nick Beckley 2026-06-07 11:49:50 +01:00
parent 1f3f68e6f3
commit 1c19edda5c
9 changed files with 413 additions and 4 deletions

View File

@ -13,6 +13,7 @@ public sealed class ScenesController(
ICharacterService characters,
ILocationService locations,
IWriterWorkspaceService writerWorkspace,
IStorageService storage,
IWebHostEnvironment environment) : Controller
{
private const long MaxAttachmentBytes = 25 * 1024 * 1024;
@ -385,9 +386,30 @@ public sealed class ScenesController(
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
if (model.UploadedFile is not null && model.UploadedFile.Length > 0)
var uploadedFile = model.UploadedFile;
var originalFilePath = model.FilePath;
string? originalFileName = null;
string? contentType = null;
long fileSizeBytes = 0;
if (uploadedFile is not null && uploadedFile.Length > 0)
{
model.FilePath = await SaveUploadedAttachmentAsync(model.SceneID, model.UploadedFile);
var storageLimit = await storage.CanUploadSceneAttachmentAsync(model.SceneID, uploadedFile.Length);
if (storageLimit is null)
{
return NotFound();
}
if (!storageLimit.Allowed)
{
TempData["WriterWorkspaceError"] = storageLimit.Message;
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
originalFileName = Path.GetFileName(uploadedFile.FileName);
contentType = string.IsNullOrWhiteSpace(uploadedFile.ContentType) ? null : uploadedFile.ContentType;
fileSizeBytes = uploadedFile.Length;
model.FilePath = await SaveUploadedAttachmentAsync(model.SceneID, uploadedFile);
}
if (string.IsNullOrWhiteSpace(model.FilePath) && string.IsNullOrWhiteSpace(model.ExternalUrl))
@ -396,7 +418,25 @@ public sealed class ScenesController(
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
await writerWorkspace.SaveAttachmentAsync(model);
var attachmentId = await writerWorkspace.SaveAttachmentAsync(model);
if (fileSizeBytes > 0 && !string.IsNullOrWhiteSpace(model.FilePath))
{
await storage.TrackSceneAttachmentAsync(
model.SceneID,
attachmentId,
model.FilePath,
originalFileName ?? Path.GetFileName(model.FilePath),
contentType,
fileSizeBytes);
if (!string.IsNullOrWhiteSpace(originalFilePath)
&& !string.Equals(originalFilePath, model.FilePath, StringComparison.OrdinalIgnoreCase))
{
await storage.MarkDeletedByStoragePathAsync(originalFilePath);
DeleteUploadedAttachment(originalFilePath);
}
}
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
@ -405,6 +445,7 @@ public sealed class ScenesController(
public async Task<IActionResult> DeleteAttachment(int id, int sceneId, int? projectId, int? bookId, string? filePath)
{
await writerWorkspace.DeleteAttachmentAsync(id);
await storage.MarkDeletedByStoragePathAsync(filePath);
DeleteUploadedAttachment(filePath);
return RedirectForScene(sceneId, projectId, bookId);
}

View File

@ -0,0 +1,53 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IUserFileRepository
{
Task<long> CreateAsync(UserFile file);
Task MarkDeletedByStoragePathAsync(string storagePath);
Task<long> GetStorageUsageForOwnerAsync(int userId);
}
public sealed class UserFileRepository(ISqlConnectionFactory connectionFactory) : IUserFileRepository
{
public async Task<long> CreateAsync(UserFile file)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<long>(
"dbo.UserFile_Create",
new
{
file.UserID,
file.ProjectID,
file.EntityType,
file.EntityID,
file.FileName,
file.OriginalFileName,
file.ContentType,
file.FileSizeBytes,
file.StoragePath
},
commandType: CommandType.StoredProcedure);
}
public async Task MarkDeletedByStoragePathAsync(string storagePath)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.UserFile_MarkDeletedByStoragePath",
new { StoragePath = storagePath },
commandType: CommandType.StoredProcedure);
}
public async Task<long> GetStorageUsageForOwnerAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<long>(
"dbo.UserFile_GetStorageUsageForOwner",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
}

View File

@ -67,6 +67,75 @@ public sealed class ProjectActivity
: "System";
}
public sealed class UserFile
{
public long UserFileID { get; set; }
public int UserID { get; set; }
public int? ProjectID { get; set; }
public string EntityType { get; set; } = string.Empty;
public int EntityID { get; set; }
public string FileName { get; set; } = string.Empty;
public string? OriginalFileName { get; set; }
public string? ContentType { get; set; }
public long FileSizeBytes { get; set; }
public string StoragePath { get; set; } = string.Empty;
public DateTime UploadedDateUTC { get; set; }
public DateTime? DeletedDateUTC { get; set; }
public bool IsDeleted { get; set; }
}
public sealed class StorageUsage
{
public long UsedBytes { get; init; }
public long MaximumBytes { get; init; }
public string UsedLabel => FormatBytes(UsedBytes);
public string MaximumLabel => MaximumBytes >= 999999L * 1024L * 1024L ? "Unrestricted" : FormatBytes(MaximumBytes);
public string DisplayLabel => $"{UsedLabel} / {MaximumLabel}";
public static string FormatBytes(long bytes)
{
if (bytes >= 1024L * 1024L * 1024L)
{
return $"{bytes / (1024d * 1024d * 1024d):0.##} GB";
}
if (bytes >= 1024L * 1024L)
{
return $"{bytes / (1024d * 1024d):0.##} MB";
}
if (bytes >= 1024L)
{
return $"{bytes / 1024d:0.##} KB";
}
return $"{bytes:N0} bytes";
}
}
public sealed class StorageLimitResult
{
public bool Allowed { get; init; }
public long CurrentUsageBytes { get; init; }
public long MaximumAllowedBytes { get; init; }
public string Message { get; init; } = string.Empty;
public static StorageLimitResult AllowedResult(long currentUsageBytes, long maximumAllowedBytes) => new()
{
Allowed = true,
CurrentUsageBytes = currentUsageBytes,
MaximumAllowedBytes = maximumAllowedBytes
};
public static StorageLimitResult Blocked(long currentUsageBytes, long maximumAllowedBytes, long uploadBytes) => new()
{
Allowed = false,
CurrentUsageBytes = currentUsageBytes,
MaximumAllowedBytes = maximumAllowedBytes,
Message = $"You have used {StorageUsage.FormatBytes(currentUsageBytes)} of your {StorageUsage.FormatBytes(maximumAllowedBytes)} storage allowance. Uploading this {StorageUsage.FormatBytes(uploadBytes)} file would exceed your subscription limit."
};
}
public sealed class GenreMetricPreset
{
public int GenreMetricPresetID { get; set; }

View File

@ -76,6 +76,7 @@ public class Program
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IAuthenticationRepository, AuthenticationRepository>();
builder.Services.AddScoped<IEmailQueueRepository, EmailQueueRepository>();
builder.Services.AddScoped<IUserFileRepository, UserFileRepository>();
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
builder.Services.AddScoped<IBookRepository, BookRepository>();
builder.Services.AddScoped<IChapterRepository, ChapterRepository>();
@ -123,6 +124,7 @@ public class Program
builder.Services.AddScoped<IStoryBibleService, StoryBibleService>();
builder.Services.AddScoped<IRelationshipMapService, RelationshipMapService>();
builder.Services.AddScoped<IWriterWorkspaceService, WriterWorkspaceService>();
builder.Services.AddScoped<IStorageService, StorageService>();
builder.Services.AddScoped<IExportService, ExportService>();
builder.Services.AddScoped<IProjectExportService, ProjectExportService>();
builder.Services.AddScoped<IProjectRestorePointService, ProjectRestorePointService>();

View File

@ -246,6 +246,14 @@ public interface IWriterWorkspaceService
Task DeleteAttachmentAsync(int sceneAttachmentId);
}
public interface IStorageService
{
Task<StorageUsage?> GetSceneStorageUsageAsync(int sceneId);
Task<StorageLimitResult?> CanUploadSceneAttachmentAsync(int sceneId, long fileSizeBytes);
Task TrackSceneAttachmentAsync(int sceneId, int sceneAttachmentId, string storagePath, string originalFileName, string? contentType, long fileSizeBytes);
Task MarkDeletedByStoragePathAsync(string? storagePath);
}
public interface IExportService
{
Task<(string FileName, string ContentType, string Content)> ExportStoryBibleAsync(int projectId, string format);
@ -816,6 +824,7 @@ public sealed class SceneService(
IWriterWorkspaceRepository writerWorkspace,
ISubscriptionService subscriptions,
IProjectActivityService activity,
IStorageService storage,
ICurrentUserService currentUser) : ISceneService
{
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
@ -1024,6 +1033,7 @@ public sealed class SceneService(
model.Notes = model.SceneID == 0 ? [] : await writerWorkspace.ListNotesAsync(model.SceneID);
model.ChecklistItems = model.SceneID == 0 ? [] : await writerWorkspace.ListChecklistItemsAsync(model.SceneID);
model.Attachments = model.SceneID == 0 ? [] : await writerWorkspace.ListAttachmentsAsync(model.SceneID);
model.StorageUsage = model.SceneID == 0 ? null : await storage.GetSceneStorageUsageAsync(model.SceneID);
model.SceneNoteTypeOptions = ChapterService.ToSelectList(noteTypes, x => x.SceneNoteTypeID, x => x.TypeName);
model.SceneAttachmentTypeOptions = ChapterService.ToSelectList(attachmentTypes, x => x.SceneAttachmentTypeID, x => x.TypeName);
model.DraftStatusOptions = DraftStatuses()
@ -3485,6 +3495,70 @@ public sealed class AcceptanceService(IProjectRepository projects, IAcceptanceRe
}
}
public sealed class StorageService(
IBookRepository books,
IChapterRepository chapters,
ISceneRepository scenes,
IProjectCollaborationRepository collaboration,
ISubscriptionService subscriptions,
IUserFileRepository userFiles) : IStorageService
{
public async Task<StorageUsage?> GetSceneStorageUsageAsync(int sceneId)
{
var context = await GetSceneOwnerContextAsync(sceneId);
return context is null ? null : await subscriptions.GetStorageUsageAsync(context.Value.OwnerUserID);
}
public async Task<StorageLimitResult?> CanUploadSceneAttachmentAsync(int sceneId, long fileSizeBytes)
{
var context = await GetSceneOwnerContextAsync(sceneId);
return context is null ? null : await subscriptions.CanUploadFileAsync(context.Value.OwnerUserID, fileSizeBytes);
}
public async Task TrackSceneAttachmentAsync(int sceneId, int sceneAttachmentId, string storagePath, string originalFileName, string? contentType, long fileSizeBytes)
{
var context = await GetSceneOwnerContextAsync(sceneId);
if (context is null || string.IsNullOrWhiteSpace(storagePath))
{
return;
}
await userFiles.CreateAsync(new UserFile
{
UserID = context.Value.OwnerUserID,
ProjectID = context.Value.ProjectID,
EntityType = "SceneAttachment",
EntityID = sceneAttachmentId,
FileName = Path.GetFileName(storagePath),
OriginalFileName = originalFileName,
ContentType = contentType,
FileSizeBytes = fileSizeBytes,
StoragePath = storagePath
});
}
public Task MarkDeletedByStoragePathAsync(string? storagePath)
{
return string.IsNullOrWhiteSpace(storagePath)
? Task.CompletedTask
: userFiles.MarkDeletedByStoragePathAsync(storagePath);
}
private async Task<(int ProjectID, int OwnerUserID)?> GetSceneOwnerContextAsync(int sceneId)
{
var scene = await scenes.GetAsync(sceneId);
var chapter = scene is null ? null : await chapters.GetAsync(scene.ChapterID);
var book = chapter is null ? null : await books.GetAsync(chapter.BookID);
if (book is null)
{
return null;
}
var ownerUserId = await collaboration.GetOwnerUserIdAsync(book.ProjectID);
return ownerUserId.HasValue ? (book.ProjectID, ownerUserId.Value) : null;
}
}
public sealed class AssetService(IProjectRepository projects, IAssetRepository assets, ILocationRepository locations, IProjectActivityService activity) : IAssetService
{
public async Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId)

View File

@ -13,9 +13,11 @@ public interface ISubscriptionService
Task<SubscriptionLimitResult> CanCreateSceneAsync(int bookId, int userId);
Task<SubscriptionLimitResult> CanCreateCharacterAsync(int projectId, int userId);
Task<SubscriptionLimitResult> CanInviteCollaboratorAsync(int userId);
Task<StorageUsage> GetStorageUsageAsync(int userId);
Task<StorageLimitResult> CanUploadFileAsync(int userId, long fileSizeBytes);
}
public sealed class SubscriptionService(ISubscriptionRepository subscriptions) : ISubscriptionService
public sealed class SubscriptionService(ISubscriptionRepository subscriptions, IUserFileRepository userFiles) : ISubscriptionService
{
private const int PracticalUnlimited = 999999;
@ -63,6 +65,27 @@ public sealed class SubscriptionService(ISubscriptionRepository subscriptions) :
return Evaluate(subscription, currentUsage, subscription.MaxCollaborators, "collaborators");
}
public async Task<StorageUsage> GetStorageUsageAsync(int userId)
{
var subscription = await GetRequiredSubscriptionAsync(userId);
return new StorageUsage
{
UsedBytes = await userFiles.GetStorageUsageForOwnerAsync(userId),
MaximumBytes = ToBytes(subscription.MaxStorageMB)
};
}
public async Task<StorageLimitResult> CanUploadFileAsync(int userId, long fileSizeBytes)
{
var usage = await GetStorageUsageAsync(userId);
if (usage.MaximumBytes >= ToBytes(PracticalUnlimited) || usage.UsedBytes + fileSizeBytes <= usage.MaximumBytes)
{
return StorageLimitResult.AllowedResult(usage.UsedBytes, usage.MaximumBytes);
}
return StorageLimitResult.Blocked(usage.UsedBytes, usage.MaximumBytes, fileSizeBytes);
}
private async Task<UserSubscriptionDetail> GetRequiredSubscriptionAsync(int userId)
{
var subscription = await subscriptions.GetCurrentSubscriptionAsync(userId);
@ -86,6 +109,8 @@ public sealed class SubscriptionService(ISubscriptionRepository subscriptions) :
maximumAllowed,
$"You have reached the maximum number of {limitName} allowed by your {subscription.DisplayName} subscription ({maximumAllowed:N0}). Upgrade your subscription to add more.");
}
private static long ToBytes(int megabytes) => megabytes * 1024L * 1024L;
}
public sealed class SubscriptionLimitException(string message) : InvalidOperationException(message);

View File

@ -0,0 +1,140 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.UserFile', N'U') IS NULL
BEGIN
CREATE TABLE dbo.UserFile
(
UserFileID bigint IDENTITY(1,1) NOT NULL CONSTRAINT PK_UserFile PRIMARY KEY,
UserID int NOT NULL,
ProjectID int NULL,
EntityType nvarchar(100) NOT NULL,
EntityID int NOT NULL,
FileName nvarchar(300) NOT NULL,
OriginalFileName nvarchar(300) NULL,
ContentType nvarchar(200) NULL,
FileSizeBytes bigint NOT NULL,
StoragePath nvarchar(1000) NOT NULL,
UploadedDateUTC datetime2 NOT NULL CONSTRAINT DF_UserFile_UploadedDateUTC DEFAULT SYSUTCDATETIME(),
DeletedDateUTC datetime2 NULL,
IsDeleted bit NOT NULL CONSTRAINT DF_UserFile_IsDeleted DEFAULT 0,
CONSTRAINT FK_UserFile_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID),
CONSTRAINT FK_UserFile_Project FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
CONSTRAINT CK_UserFile_FileSizeBytes CHECK (FileSizeBytes >= 0)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_UserFile_User_Active' AND object_id = OBJECT_ID(N'dbo.UserFile'))
CREATE INDEX IX_UserFile_User_Active ON dbo.UserFile(UserID, IsDeleted, UploadedDateUTC DESC) INCLUDE (FileSizeBytes, ProjectID, EntityType, EntityID);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_UserFile_Project_Entity' AND object_id = OBJECT_ID(N'dbo.UserFile'))
CREATE INDEX IX_UserFile_Project_Entity ON dbo.UserFile(ProjectID, EntityType, EntityID, IsDeleted);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_UserFile_StoragePath' AND object_id = OBJECT_ID(N'dbo.UserFile'))
CREATE INDEX IX_UserFile_StoragePath ON dbo.UserFile(StoragePath, IsDeleted);
GO
CREATE OR ALTER PROCEDURE dbo.Project_GetOwnerUserID
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP (1) UserID
FROM dbo.ProjectUserAccess
WHERE ProjectID = @ProjectID
AND AccessRole = N'Owner'
AND IsActive = 1
ORDER BY ProjectUserAccessID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserFile_Create
@UserID int,
@ProjectID int = NULL,
@EntityType nvarchar(100),
@EntityID int,
@FileName nvarchar(300),
@OriginalFileName nvarchar(300) = NULL,
@ContentType nvarchar(200) = NULL,
@FileSizeBytes bigint,
@StoragePath nvarchar(1000)
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.UserFile (UserID, ProjectID, EntityType, EntityID, FileName, OriginalFileName, ContentType, FileSizeBytes, StoragePath)
VALUES (@UserID, @ProjectID, @EntityType, @EntityID, @FileName, @OriginalFileName, @ContentType, @FileSizeBytes, @StoragePath);
SELECT CAST(SCOPE_IDENTITY() AS bigint);
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserFile_MarkDeletedByStoragePath
@StoragePath nvarchar(1000)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.UserFile
SET IsDeleted = 1,
DeletedDateUTC = COALESCE(DeletedDateUTC, SYSUTCDATETIME())
WHERE StoragePath = @StoragePath
AND IsDeleted = 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserFile_GetStorageUsageForOwner
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT CAST(COALESCE(SUM(FileSizeBytes), 0) AS bigint)
FROM dbo.UserFile
WHERE UserID = @UserID
AND IsDeleted = 0;
END;
GO
INSERT INTO dbo.UserFile (UserID, ProjectID, EntityType, EntityID, FileName, OriginalFileName, ContentType, FileSizeBytes, StoragePath, UploadedDateUTC)
SELECT ownerAccess.UserID,
b.ProjectID,
N'SceneAttachment',
a.SceneAttachmentID,
RIGHT(a.FilePath, CHARINDEX(N'/', REVERSE(a.FilePath + N'/')) - 1),
RIGHT(a.FilePath, CHARINDEX(N'/', REVERSE(a.FilePath + N'/')) - 1),
NULL,
0,
a.FilePath,
COALESCE(a.CreatedDateUTC, a.CreatedDate, SYSUTCDATETIME())
FROM dbo.SceneAttachments a
INNER JOIN dbo.Scenes s ON s.SceneID = a.SceneID
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
INNER JOIN dbo.Books b ON b.BookID = c.BookID
OUTER APPLY
(
SELECT TOP (1) UserID
FROM dbo.ProjectUserAccess
WHERE ProjectID = b.ProjectID
AND AccessRole = N'Owner'
AND IsActive = 1
ORDER BY ProjectUserAccessID
) ownerAccess
WHERE a.FilePath IS NOT NULL
AND a.FilePath LIKE N'/uploads/scenes/%'
AND ownerAccess.UserID IS NOT NULL
AND NOT EXISTS
(
SELECT 1
FROM dbo.UserFile existing
WHERE existing.StoragePath = a.FilePath
AND existing.EntityType = N'SceneAttachment'
AND existing.EntityID = a.SceneAttachmentID
);
GO

View File

@ -209,6 +209,7 @@ public sealed class SceneEditViewModel
public IReadOnlyList<SelectListItem> SceneNoteTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SceneAttachmentTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> DraftStatusOptions { get; set; } = [];
public StorageUsage? StorageUsage { get; set; }
public int? ReturnProjectID { get; set; }
public int? ReturnBookID { get; set; }

View File

@ -400,6 +400,10 @@
<section class="inspector-section writer-attachments-section" data-section-title="Attachments / Research" data-section-id="inspector-attachments">
<h3>Attachments / Research</h3>
@if (Model.StorageUsage is not null)
{
<p class="muted">Storage used: @Model.StorageUsage.DisplayLabel</p>
}
@if (!Model.Attachments.Any())
{
<p class="muted">No scene references yet. Add images, links, floorplans, research notes, or files that help you write this scene.</p>