From adfaaf359535051bca40d6dd3ff71240fa162553 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Tue, 15 Apr 2025 15:57:40 -0400 Subject: [PATCH] Adds delete and restore for a creator's page --- .../Contents/Handlers/GetCreatorBySlug.cs | 22 ++++++- .../Contents/Handlers/GetCreatorProfile.cs | 20 ++++-- .../Contents/Handlers/RemoveCreator.cs | 53 ++++++++------- .../Contents/Handlers/RestoreCreator.cs | 64 +++++++++++++++++++ frontend/src/stores/brandingStore.js | 5 +- frontend/src/stores/creatorProfileStore.js | 24 ++++++- frontend/src/views/creators/CreatorLayout.vue | 4 ++ frontend/src/views/main/SideBar.vue | 2 +- frontend/src/views/profile/ProfilePage.vue | 32 ++++++++++ 9 files changed, 187 insertions(+), 39 deletions(-) create mode 100644 backend/src/Web/Features/Contents/Handlers/RestoreCreator.cs diff --git a/backend/src/Web/Features/Contents/Handlers/GetCreatorBySlug.cs b/backend/src/Web/Features/Contents/Handlers/GetCreatorBySlug.cs index 760cf82..2e7b990 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetCreatorBySlug.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetCreatorBySlug.cs @@ -1,4 +1,5 @@ -using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Contents.Data; namespace Hutopy.Web.Features.Contents.Handlers; @@ -13,6 +14,9 @@ public class GetCreatorBySlugResponse( Guid id, Guid createdBy, DateTimeOffset createdAt, + Guid? deletedBy, + DateTimeOffset? deletedAt, + bool isDeleted, bool verified, bool acceptDonation, string name, @@ -25,6 +29,9 @@ public class GetCreatorBySlugResponse( public Guid Id { get; } = id; public Guid CreatedBy { get; } = createdBy; public DateTimeOffset CreatedAt { get; } = createdAt; + public Guid? DeletedBy { get; } = deletedBy; + public DateTimeOffset? DeletedAt { get; } = deletedAt; + public bool IsDeleted { get; } = isDeleted; public bool Verified { get; } = verified; public bool AcceptDonation { get; } = acceptDonation; public string Name { get; } = name; @@ -67,6 +74,7 @@ public class GetCreatorBySlugHandler( var creator = await context .Creators + .IgnoreQueryFilters() .Where(c => EF.Functions.ILike(c.Slug, creatorName)) .AsNoTracking() .Select(c => new GetCreatorBySlugResponse @@ -74,6 +82,9 @@ public class GetCreatorBySlugHandler( c.Id, c.CreatedBy, c.CreatedAt, + c.DeletedBy, + c.DeletedAt, + c.IsDeleted, c.Verified, c.AcceptDonation, c.Name, @@ -85,6 +96,15 @@ public class GetCreatorBySlugHandler( .SingleOrDefaultAsync(ct); if (creator is null) + { + await SendNotFoundAsync(ct); + return; + } + + bool isOwner = User.Identity?.IsAuthenticated == true + && User.GetUserId() == creator.CreatedBy; + + if (creator.IsDeleted && !isOwner) { await SendNotFoundAsync(ct); } diff --git a/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs b/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs index 1add899..7b62264 100644 --- a/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs +++ b/backend/src/Web/Features/Contents/Handlers/GetCreatorProfile.cs @@ -9,13 +9,17 @@ public sealed class GetCreatorProfileResponse public Guid Id { get; set; } public Guid CreatedBy { get; set; } public DateTimeOffset CreatedAt { get; set; } - public string Title { get; set; } - public string Name { get; set; } + public Guid? DeletedBy { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public bool IsDeleted { get; set; } + public required string Name { get; set; } + public required string Slug { get; set; } + public string? Title { get; set; } public bool Verified { get; set; } public bool AcceptDonation { get; set; } - public Images Images { get; set; } - public PresentationInfos PresentationInfos { get; set; } - public Socials Socials { get; set; } + public required Images Images { get; set; } + public required PresentationInfos PresentationInfos { get; set; } + public required Socials Socials { get; set; } } [PublicAPI] @@ -27,7 +31,6 @@ public class GetCreatorProfileHandler( { Get("/api/creators/profile"); Options((o => o.WithTags("Creators"))); - AllowAnonymous(); } public override async Task HandleAsync( @@ -35,6 +38,7 @@ public class GetCreatorProfileHandler( { var creator = await context .Creators + .IgnoreQueryFilters() .Where(c => c.Id == HttpContext.User.GetUserId()) .AsNoTracking() .Select(c => new GetCreatorProfileResponse @@ -42,7 +46,11 @@ public class GetCreatorProfileHandler( Id = c.Id, CreatedBy = c.CreatedBy, CreatedAt = c.CreatedAt, + DeletedBy = c.DeletedBy, + DeletedAt = c.DeletedAt, + IsDeleted = c.IsDeleted, Name = c.Name, + Slug = c.Slug, Title = c.Title, Verified = c.Verified, AcceptDonation = c.AcceptDonation, diff --git a/backend/src/Web/Features/Contents/Handlers/RemoveCreator.cs b/backend/src/Web/Features/Contents/Handlers/RemoveCreator.cs index fc31ea2..8a4bd1c 100644 --- a/backend/src/Web/Features/Contents/Handlers/RemoveCreator.cs +++ b/backend/src/Web/Features/Contents/Handlers/RemoveCreator.cs @@ -5,17 +5,17 @@ namespace Hutopy.Web.Features.Contents.Handlers; [PublicAPI] public record RemoveCreatorRequest( - Guid CreatorId); + string CreatorSlug); [UsedImplicitly] public sealed class RemoveCreatorRequestValidator : Validator { public RemoveCreatorRequestValidator() { - RuleFor(r => r.CreatorId) + RuleFor(r => r.CreatorSlug) .NotNull() .NotEmpty() - .WithMessage("You should specify a valid CreatorId"); + .WithMessage("You should specify a valid CreatorSlug"); } } @@ -26,7 +26,7 @@ public sealed class RemoveCreatorHandler( { public override void Configure() { - Delete("/api/creators"); + Delete("/api/creators/@{CreatorSlug}"); Options(o => o.WithTags("Creators")); } @@ -34,33 +34,30 @@ public sealed class RemoveCreatorHandler( RemoveCreatorRequest req, CancellationToken ct) { - await using var transaction = await context.Database.BeginTransactionAsync(ct); - - try + var creatorSlug = req.CreatorSlug.ToLower(); + + var creator = await context + .Creators + .Where(c => EF.Functions.ILike(c.Slug, creatorSlug)) + .SingleOrDefaultAsync(cancellationToken: ct); + + if (creator is null) { - var creator = await context - .Creators - .Where(c => c.Id == req.CreatorId) - .SingleOrDefaultAsync(cancellationToken: ct); - - if (creator is null) - { - await SendNotFoundAsync(ct); - return; - } - - creator.DeletedAt = DateTimeOffset.UtcNow; - creator.DeletedBy = User.GetUserId(); - - await context.SaveChangesAsync(ct); - - await transaction.CommitAsync(ct); - - await SendOkAsync(ct); + await SendNotFoundAsync(ct); + return; } - catch (Exception e) + + if (creator.CreatedBy != User.GetUserId()) { - await transaction.RollbackAsync(ct); + await SendUnauthorizedAsync(ct); + return; } + + creator.DeletedAt = DateTimeOffset.UtcNow; + creator.DeletedBy = User.GetUserId(); + + await context.SaveChangesAsync(ct); + + await SendOkAsync(ct); } } diff --git a/backend/src/Web/Features/Contents/Handlers/RestoreCreator.cs b/backend/src/Web/Features/Contents/Handlers/RestoreCreator.cs new file mode 100644 index 0000000..f735afa --- /dev/null +++ b/backend/src/Web/Features/Contents/Handlers/RestoreCreator.cs @@ -0,0 +1,64 @@ +using Hutopy.Web.Common.Security; +using Hutopy.Web.Features.Contents.Data; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record RestoreCreatorRequest( + string CreatorSlug); + +[UsedImplicitly] +public sealed class RestoreCreatorRequestValidator : Validator +{ + public RestoreCreatorRequestValidator() + { + RuleFor(r => r.CreatorSlug) + .NotNull() + .NotEmpty() + .WithMessage("You should specify a valid CreatorSlug"); + } +} + +[PublicAPI] +public sealed class RestoreCreatorHandler( + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Put("/api/creators/@{CreatorSlug}/restore"); + Options(o => o.WithTags("Creators")); + } + + public override async Task HandleAsync( + RestoreCreatorRequest req, + CancellationToken ct) + { + var creatorSlug = req.CreatorSlug.ToLower(); + + var creator = await context + .Creators + .IgnoreQueryFilters() + .Where(c => EF.Functions.ILike(c.Slug, creatorSlug)) + .SingleOrDefaultAsync(cancellationToken: ct); + + if (creator is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (creator.CreatedBy != User.GetUserId()) + { + await SendUnauthorizedAsync(ct); + return; + } + + creator.DeletedAt = null; + creator.DeletedBy = null; + + await context.SaveChangesAsync(ct); + + await SendOkAsync(ct); + } +} diff --git a/frontend/src/stores/brandingStore.js b/frontend/src/stores/brandingStore.js index 15808bb..b240e3e 100644 --- a/frontend/src/stores/brandingStore.js +++ b/frontend/src/stores/brandingStore.js @@ -2,7 +2,7 @@ import {useClient} from "@/plugins/api.js"; import {useSessionStorage} from "@vueuse/core"; import {ref, watch} from "vue"; -import {useRoute} from "vue-router"; +import {useRoute, useRouter} from "vue-router"; export const useBrandingStore = defineStore( 'branding', @@ -17,6 +17,7 @@ export const useBrandingStore = defineStore( {writeDefaults: false}) const presentationInfos = ref([]) + const router = useRouter() const route = useRoute() watch( () => route.params.creator, @@ -49,7 +50,7 @@ export const useBrandingStore = defineStore( const response = await client.get(`/api/creators/@${creatorAlias}`) return response.data } catch (error) { - console.error(`Error fetching content: ${error}`) + await router.push('/'); } } diff --git a/frontend/src/stores/creatorProfileStore.js b/frontend/src/stores/creatorProfileStore.js index 64a1cb5..30e912a 100644 --- a/frontend/src/stores/creatorProfileStore.js +++ b/frontend/src/stores/creatorProfileStore.js @@ -18,7 +18,7 @@ export const useCreatorProfileStore = defineStore( if (newValue) { await fetchCreatorProfile(); if (value.value && value.value.name !== undefined) { - await router.push(`/@${value.value.name}`); + await router.push(`/@${value.value.slug}`); } else { await router.push('/'); } @@ -56,9 +56,31 @@ export const useCreatorProfileStore = defineStore( } } + async function removeCreatorPage() { + try { + await client.delete(`/api/creators/@${value.value.slug}`) + await fetchCreatorProfile(); + } + catch(error) { + console.error(error); + } + } + + async function restoreCreatorPage() { + try { + await client.put(`/api/creators/@${value.value.slug}/restore`, {}) + await fetchCreatorProfile(); + } + catch(error) { + console.error(error); + } + } + return { creator: value, hasCreator, + removeCreatorPage, + restoreCreatorPage, fetchCreatorProfile }; }); diff --git a/frontend/src/views/creators/CreatorLayout.vue b/frontend/src/views/creators/CreatorLayout.vue index 73a65b7..f9bdd78 100644 --- a/frontend/src/views/creators/CreatorLayout.vue +++ b/frontend/src/views/creators/CreatorLayout.vue @@ -21,6 +21,10 @@ onMounted(async () => {
+
+ This Creator page is pending deletion. You can revert the action in your profile. +
diff --git a/frontend/src/views/main/SideBar.vue b/frontend/src/views/main/SideBar.vue index 6fc71f7..cc0c842 100644 --- a/frontend/src/views/main/SideBar.vue +++ b/frontend/src/views/main/SideBar.vue @@ -70,7 +70,7 @@ function toggleLanguage() {
@@ -286,11 +305,24 @@ const closeDialog = () => { .action { @apply p-2 flex items-center w-full mb-2; + @apply font-sans text-base; @apply rounded-md; @apply transition duration-200 ease-in-out; @apply hover:bg-hutopyPrimary active:bg-hutopySecondary; } +.danger-action { + @apply action; + @apply mt-4; + @apply bg-red-800 hover:bg-red-700 active:bg-red-600; +} + +.safe-action { + @apply action; + @apply mt-4; + @apply bg-green-800 hover:bg-green-700 active:bg-green-600; +} + .label { @apply flex-none min-w-40 text-left; }