Phase 5F – Storage Management and Quota Enforcement
This commit is contained in:
parent
1f3f68e6f3
commit
1c19edda5c
@ -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);
|
||||
}
|
||||
|
||||
53
PlotLine/Data/UserFileRepository.cs
Normal file
53
PlotLine/Data/UserFileRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
140
PlotLine/Sql/047_Phase5F_StorageManagement.sql
Normal file
140
PlotLine/Sql/047_Phase5F_StorageManagement.sql
Normal 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
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user