diff --git a/PlotLine/Controllers/ScenesController.cs b/PlotLine/Controllers/ScenesController.cs index c99d200..be4b79a 100644 --- a/PlotLine/Controllers/ScenesController.cs +++ b/PlotLine/Controllers/ScenesController.cs @@ -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 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); } diff --git a/PlotLine/Data/UserFileRepository.cs b/PlotLine/Data/UserFileRepository.cs new file mode 100644 index 0000000..233fcd4 --- /dev/null +++ b/PlotLine/Data/UserFileRepository.cs @@ -0,0 +1,53 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IUserFileRepository +{ + Task CreateAsync(UserFile file); + Task MarkDeletedByStoragePathAsync(string storagePath); + Task GetStorageUsageForOwnerAsync(int userId); +} + +public sealed class UserFileRepository(ISqlConnectionFactory connectionFactory) : IUserFileRepository +{ + public async Task CreateAsync(UserFile file) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "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 GetStorageUsageForOwnerAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.UserFile_GetStorageUsageForOwner", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 4aa97c4..4549edd 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -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; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index f1171e7..77eadf3 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -76,6 +76,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(); @@ -123,6 +124,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(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 6931ce1..6788bd6 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -246,6 +246,14 @@ public interface IWriterWorkspaceService Task DeleteAttachmentAsync(int sceneAttachmentId); } +public interface IStorageService +{ + Task GetSceneStorageUsageAsync(int sceneId); + Task 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 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 GetSceneStorageUsageAsync(int sceneId) + { + var context = await GetSceneOwnerContextAsync(sceneId); + return context is null ? null : await subscriptions.GetStorageUsageAsync(context.Value.OwnerUserID); + } + + public async Task 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 GetAssetsAsync(int projectId) diff --git a/PlotLine/Services/SubscriptionServices.cs b/PlotLine/Services/SubscriptionServices.cs index af64a99..4e191de 100644 --- a/PlotLine/Services/SubscriptionServices.cs +++ b/PlotLine/Services/SubscriptionServices.cs @@ -13,9 +13,11 @@ public interface ISubscriptionService Task CanCreateSceneAsync(int bookId, int userId); Task CanCreateCharacterAsync(int projectId, int userId); Task CanInviteCollaboratorAsync(int userId); + Task GetStorageUsageAsync(int userId); + Task 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 GetStorageUsageAsync(int userId) + { + var subscription = await GetRequiredSubscriptionAsync(userId); + return new StorageUsage + { + UsedBytes = await userFiles.GetStorageUsageForOwnerAsync(userId), + MaximumBytes = ToBytes(subscription.MaxStorageMB) + }; + } + + public async Task 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 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); diff --git a/PlotLine/Sql/047_Phase5F_StorageManagement.sql b/PlotLine/Sql/047_Phase5F_StorageManagement.sql new file mode 100644 index 0000000..a48ea5a --- /dev/null +++ b/PlotLine/Sql/047_Phase5F_StorageManagement.sql @@ -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 diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 828e693..f7ecc1e 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -209,6 +209,7 @@ public sealed class SceneEditViewModel public IReadOnlyList SceneNoteTypeOptions { get; set; } = []; public IReadOnlyList SceneAttachmentTypeOptions { get; set; } = []; public IReadOnlyList DraftStatusOptions { get; set; } = []; + public StorageUsage? StorageUsage { get; set; } public int? ReturnProjectID { get; set; } public int? ReturnBookID { get; set; } diff --git a/PlotLine/Views/Scenes/_SceneInspector.cshtml b/PlotLine/Views/Scenes/_SceneInspector.cshtml index 7f8808a..0058e31 100644 --- a/PlotLine/Views/Scenes/_SceneInspector.cshtml +++ b/PlotLine/Views/Scenes/_SceneInspector.cshtml @@ -400,6 +400,10 @@

Attachments / Research

+ @if (Model.StorageUsage is not null) + { +

Storage used: @Model.StorageUsage.DisplayLabel

+ } @if (!Model.Attachments.Any()) {

No scene references yet. Add images, links, floorplans, research notes, or files that help you write this scene.