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,
|
ICharacterService characters,
|
||||||
ILocationService locations,
|
ILocationService locations,
|
||||||
IWriterWorkspaceService writerWorkspace,
|
IWriterWorkspaceService writerWorkspace,
|
||||||
|
IStorageService storage,
|
||||||
IWebHostEnvironment environment) : Controller
|
IWebHostEnvironment environment) : Controller
|
||||||
{
|
{
|
||||||
private const long MaxAttachmentBytes = 25 * 1024 * 1024;
|
private const long MaxAttachmentBytes = 25 * 1024 * 1024;
|
||||||
@ -385,9 +386,30 @@ public sealed class ScenesController(
|
|||||||
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
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))
|
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);
|
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);
|
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)
|
public async Task<IActionResult> DeleteAttachment(int id, int sceneId, int? projectId, int? bookId, string? filePath)
|
||||||
{
|
{
|
||||||
await writerWorkspace.DeleteAttachmentAsync(id);
|
await writerWorkspace.DeleteAttachmentAsync(id);
|
||||||
|
await storage.MarkDeletedByStoragePathAsync(filePath);
|
||||||
DeleteUploadedAttachment(filePath);
|
DeleteUploadedAttachment(filePath);
|
||||||
return RedirectForScene(sceneId, projectId, bookId);
|
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";
|
: "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 sealed class GenreMetricPreset
|
||||||
{
|
{
|
||||||
public int GenreMetricPresetID { get; set; }
|
public int GenreMetricPresetID { get; set; }
|
||||||
|
|||||||
@ -76,6 +76,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||||
builder.Services.AddScoped<IAuthenticationRepository, AuthenticationRepository>();
|
builder.Services.AddScoped<IAuthenticationRepository, AuthenticationRepository>();
|
||||||
builder.Services.AddScoped<IEmailQueueRepository, EmailQueueRepository>();
|
builder.Services.AddScoped<IEmailQueueRepository, EmailQueueRepository>();
|
||||||
|
builder.Services.AddScoped<IUserFileRepository, UserFileRepository>();
|
||||||
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
|
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
|
||||||
builder.Services.AddScoped<IBookRepository, BookRepository>();
|
builder.Services.AddScoped<IBookRepository, BookRepository>();
|
||||||
builder.Services.AddScoped<IChapterRepository, ChapterRepository>();
|
builder.Services.AddScoped<IChapterRepository, ChapterRepository>();
|
||||||
@ -123,6 +124,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryBibleService, StoryBibleService>();
|
builder.Services.AddScoped<IStoryBibleService, StoryBibleService>();
|
||||||
builder.Services.AddScoped<IRelationshipMapService, RelationshipMapService>();
|
builder.Services.AddScoped<IRelationshipMapService, RelationshipMapService>();
|
||||||
builder.Services.AddScoped<IWriterWorkspaceService, WriterWorkspaceService>();
|
builder.Services.AddScoped<IWriterWorkspaceService, WriterWorkspaceService>();
|
||||||
|
builder.Services.AddScoped<IStorageService, StorageService>();
|
||||||
builder.Services.AddScoped<IExportService, ExportService>();
|
builder.Services.AddScoped<IExportService, ExportService>();
|
||||||
builder.Services.AddScoped<IProjectExportService, ProjectExportService>();
|
builder.Services.AddScoped<IProjectExportService, ProjectExportService>();
|
||||||
builder.Services.AddScoped<IProjectRestorePointService, ProjectRestorePointService>();
|
builder.Services.AddScoped<IProjectRestorePointService, ProjectRestorePointService>();
|
||||||
|
|||||||
@ -246,6 +246,14 @@ public interface IWriterWorkspaceService
|
|||||||
Task DeleteAttachmentAsync(int sceneAttachmentId);
|
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
|
public interface IExportService
|
||||||
{
|
{
|
||||||
Task<(string FileName, string ContentType, string Content)> ExportStoryBibleAsync(int projectId, string format);
|
Task<(string FileName, string ContentType, string Content)> ExportStoryBibleAsync(int projectId, string format);
|
||||||
@ -816,6 +824,7 @@ public sealed class SceneService(
|
|||||||
IWriterWorkspaceRepository writerWorkspace,
|
IWriterWorkspaceRepository writerWorkspace,
|
||||||
ISubscriptionService subscriptions,
|
ISubscriptionService subscriptions,
|
||||||
IProjectActivityService activity,
|
IProjectActivityService activity,
|
||||||
|
IStorageService storage,
|
||||||
ICurrentUserService currentUser) : ISceneService
|
ICurrentUserService currentUser) : ISceneService
|
||||||
{
|
{
|
||||||
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
|
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.Notes = model.SceneID == 0 ? [] : await writerWorkspace.ListNotesAsync(model.SceneID);
|
||||||
model.ChecklistItems = model.SceneID == 0 ? [] : await writerWorkspace.ListChecklistItemsAsync(model.SceneID);
|
model.ChecklistItems = model.SceneID == 0 ? [] : await writerWorkspace.ListChecklistItemsAsync(model.SceneID);
|
||||||
model.Attachments = model.SceneID == 0 ? [] : await writerWorkspace.ListAttachmentsAsync(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.SceneNoteTypeOptions = ChapterService.ToSelectList(noteTypes, x => x.SceneNoteTypeID, x => x.TypeName);
|
||||||
model.SceneAttachmentTypeOptions = ChapterService.ToSelectList(attachmentTypes, x => x.SceneAttachmentTypeID, x => x.TypeName);
|
model.SceneAttachmentTypeOptions = ChapterService.ToSelectList(attachmentTypes, x => x.SceneAttachmentTypeID, x => x.TypeName);
|
||||||
model.DraftStatusOptions = DraftStatuses()
|
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 sealed class AssetService(IProjectRepository projects, IAssetRepository assets, ILocationRepository locations, IProjectActivityService activity) : IAssetService
|
||||||
{
|
{
|
||||||
public async Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId)
|
public async Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId)
|
||||||
|
|||||||
@ -13,9 +13,11 @@ public interface ISubscriptionService
|
|||||||
Task<SubscriptionLimitResult> CanCreateSceneAsync(int bookId, int userId);
|
Task<SubscriptionLimitResult> CanCreateSceneAsync(int bookId, int userId);
|
||||||
Task<SubscriptionLimitResult> CanCreateCharacterAsync(int projectId, int userId);
|
Task<SubscriptionLimitResult> CanCreateCharacterAsync(int projectId, int userId);
|
||||||
Task<SubscriptionLimitResult> CanInviteCollaboratorAsync(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;
|
private const int PracticalUnlimited = 999999;
|
||||||
|
|
||||||
@ -63,6 +65,27 @@ public sealed class SubscriptionService(ISubscriptionRepository subscriptions) :
|
|||||||
return Evaluate(subscription, currentUsage, subscription.MaxCollaborators, "collaborators");
|
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)
|
private async Task<UserSubscriptionDetail> GetRequiredSubscriptionAsync(int userId)
|
||||||
{
|
{
|
||||||
var subscription = await subscriptions.GetCurrentSubscriptionAsync(userId);
|
var subscription = await subscriptions.GetCurrentSubscriptionAsync(userId);
|
||||||
@ -86,6 +109,8 @@ public sealed class SubscriptionService(ISubscriptionRepository subscriptions) :
|
|||||||
maximumAllowed,
|
maximumAllowed,
|
||||||
$"You have reached the maximum number of {limitName} allowed by your {subscription.DisplayName} subscription ({maximumAllowed:N0}). Upgrade your subscription to add more.");
|
$"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);
|
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> SceneNoteTypeOptions { get; set; } = [];
|
||||||
public IReadOnlyList<SelectListItem> SceneAttachmentTypeOptions { get; set; } = [];
|
public IReadOnlyList<SelectListItem> SceneAttachmentTypeOptions { get; set; } = [];
|
||||||
public IReadOnlyList<SelectListItem> DraftStatusOptions { get; set; } = [];
|
public IReadOnlyList<SelectListItem> DraftStatusOptions { get; set; } = [];
|
||||||
|
public StorageUsage? StorageUsage { get; set; }
|
||||||
|
|
||||||
public int? ReturnProjectID { get; set; }
|
public int? ReturnProjectID { get; set; }
|
||||||
public int? ReturnBookID { 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">
|
<section class="inspector-section writer-attachments-section" data-section-title="Attachments / Research" data-section-id="inspector-attachments">
|
||||||
<h3>Attachments / Research</h3>
|
<h3>Attachments / Research</h3>
|
||||||
|
@if (Model.StorageUsage is not null)
|
||||||
|
{
|
||||||
|
<p class="muted">Storage used: @Model.StorageUsage.DisplayLabel</p>
|
||||||
|
}
|
||||||
@if (!Model.Attachments.Any())
|
@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>
|
<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