Adds delete and restore for a creator's page

This commit is contained in:
2025-04-15 15:57:40 -04:00
parent 43f37177af
commit adfaaf3595
9 changed files with 187 additions and 39 deletions

View File

@@ -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; namespace Hutopy.Web.Features.Contents.Handlers;
@@ -13,6 +14,9 @@ public class GetCreatorBySlugResponse(
Guid id, Guid id,
Guid createdBy, Guid createdBy,
DateTimeOffset createdAt, DateTimeOffset createdAt,
Guid? deletedBy,
DateTimeOffset? deletedAt,
bool isDeleted,
bool verified, bool verified,
bool acceptDonation, bool acceptDonation,
string name, string name,
@@ -25,6 +29,9 @@ public class GetCreatorBySlugResponse(
public Guid Id { get; } = id; public Guid Id { get; } = id;
public Guid CreatedBy { get; } = createdBy; public Guid CreatedBy { get; } = createdBy;
public DateTimeOffset CreatedAt { get; } = createdAt; 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 Verified { get; } = verified;
public bool AcceptDonation { get; } = acceptDonation; public bool AcceptDonation { get; } = acceptDonation;
public string Name { get; } = name; public string Name { get; } = name;
@@ -67,6 +74,7 @@ public class GetCreatorBySlugHandler(
var creator = await context var creator = await context
.Creators .Creators
.IgnoreQueryFilters()
.Where(c => EF.Functions.ILike(c.Slug, creatorName)) .Where(c => EF.Functions.ILike(c.Slug, creatorName))
.AsNoTracking() .AsNoTracking()
.Select(c => new GetCreatorBySlugResponse .Select(c => new GetCreatorBySlugResponse
@@ -74,6 +82,9 @@ public class GetCreatorBySlugHandler(
c.Id, c.Id,
c.CreatedBy, c.CreatedBy,
c.CreatedAt, c.CreatedAt,
c.DeletedBy,
c.DeletedAt,
c.IsDeleted,
c.Verified, c.Verified,
c.AcceptDonation, c.AcceptDonation,
c.Name, c.Name,
@@ -85,6 +96,15 @@ public class GetCreatorBySlugHandler(
.SingleOrDefaultAsync(ct); .SingleOrDefaultAsync(ct);
if (creator is null) 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); await SendNotFoundAsync(ct);
} }

View File

@@ -9,13 +9,17 @@ public sealed class GetCreatorProfileResponse
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid CreatedBy { get; set; } public Guid CreatedBy { get; set; }
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public string Title { get; set; } public Guid? DeletedBy { get; set; }
public string Name { 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 Verified { get; set; }
public bool AcceptDonation { get; set; } public bool AcceptDonation { get; set; }
public Images Images { get; set; } public required Images Images { get; set; }
public PresentationInfos PresentationInfos { get; set; } public required PresentationInfos PresentationInfos { get; set; }
public Socials Socials { get; set; } public required Socials Socials { get; set; }
} }
[PublicAPI] [PublicAPI]
@@ -27,7 +31,6 @@ public class GetCreatorProfileHandler(
{ {
Get("/api/creators/profile"); Get("/api/creators/profile");
Options((o => o.WithTags("Creators"))); Options((o => o.WithTags("Creators")));
AllowAnonymous();
} }
public override async Task HandleAsync( public override async Task HandleAsync(
@@ -35,6 +38,7 @@ public class GetCreatorProfileHandler(
{ {
var creator = await context var creator = await context
.Creators .Creators
.IgnoreQueryFilters()
.Where(c => c.Id == HttpContext.User.GetUserId()) .Where(c => c.Id == HttpContext.User.GetUserId())
.AsNoTracking() .AsNoTracking()
.Select(c => new GetCreatorProfileResponse .Select(c => new GetCreatorProfileResponse
@@ -42,7 +46,11 @@ public class GetCreatorProfileHandler(
Id = c.Id, Id = c.Id,
CreatedBy = c.CreatedBy, CreatedBy = c.CreatedBy,
CreatedAt = c.CreatedAt, CreatedAt = c.CreatedAt,
DeletedBy = c.DeletedBy,
DeletedAt = c.DeletedAt,
IsDeleted = c.IsDeleted,
Name = c.Name, Name = c.Name,
Slug = c.Slug,
Title = c.Title, Title = c.Title,
Verified = c.Verified, Verified = c.Verified,
AcceptDonation = c.AcceptDonation, AcceptDonation = c.AcceptDonation,

View File

@@ -5,17 +5,17 @@ namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI] [PublicAPI]
public record RemoveCreatorRequest( public record RemoveCreatorRequest(
Guid CreatorId); string CreatorSlug);
[UsedImplicitly] [UsedImplicitly]
public sealed class RemoveCreatorRequestValidator : Validator<RemoveCreatorRequest> public sealed class RemoveCreatorRequestValidator : Validator<RemoveCreatorRequest>
{ {
public RemoveCreatorRequestValidator() public RemoveCreatorRequestValidator()
{ {
RuleFor(r => r.CreatorId) RuleFor(r => r.CreatorSlug)
.NotNull() .NotNull()
.NotEmpty() .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() public override void Configure()
{ {
Delete("/api/creators"); Delete("/api/creators/@{CreatorSlug}");
Options(o => o.WithTags("Creators")); Options(o => o.WithTags("Creators"));
} }
@@ -34,33 +34,30 @@ public sealed class RemoveCreatorHandler(
RemoveCreatorRequest req, RemoveCreatorRequest req,
CancellationToken ct) CancellationToken ct)
{ {
await using var transaction = await context.Database.BeginTransactionAsync(ct); var creatorSlug = req.CreatorSlug.ToLower();
try var creator = await context
.Creators
.Where(c => EF.Functions.ILike(c.Slug, creatorSlug))
.SingleOrDefaultAsync(cancellationToken: ct);
if (creator is null)
{ {
var creator = await context await SendNotFoundAsync(ct);
.Creators return;
.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);
} }
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);
} }
} }

View File

@@ -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<RestoreCreatorRequest>
{
public RestoreCreatorRequestValidator()
{
RuleFor(r => r.CreatorSlug)
.NotNull()
.NotEmpty()
.WithMessage("You should specify a valid CreatorSlug");
}
}
[PublicAPI]
public sealed class RestoreCreatorHandler(
ContentDbContext context)
: Endpoint<RestoreCreatorRequest>
{
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);
}
}

View File

@@ -2,7 +2,7 @@
import {useClient} from "@/plugins/api.js"; import {useClient} from "@/plugins/api.js";
import {useSessionStorage} from "@vueuse/core"; import {useSessionStorage} from "@vueuse/core";
import {ref, watch} from "vue"; import {ref, watch} from "vue";
import {useRoute} from "vue-router"; import {useRoute, useRouter} from "vue-router";
export const useBrandingStore = defineStore( export const useBrandingStore = defineStore(
'branding', 'branding',
@@ -17,6 +17,7 @@ export const useBrandingStore = defineStore(
{writeDefaults: false}) {writeDefaults: false})
const presentationInfos = ref([]) const presentationInfos = ref([])
const router = useRouter()
const route = useRoute() const route = useRoute()
watch( watch(
() => route.params.creator, () => route.params.creator,
@@ -49,7 +50,7 @@ export const useBrandingStore = defineStore(
const response = await client.get(`/api/creators/@${creatorAlias}`) const response = await client.get(`/api/creators/@${creatorAlias}`)
return response.data return response.data
} catch (error) { } catch (error) {
console.error(`Error fetching content: ${error}`) await router.push('/');
} }
} }

View File

@@ -18,7 +18,7 @@ export const useCreatorProfileStore = defineStore(
if (newValue) { if (newValue) {
await fetchCreatorProfile(); await fetchCreatorProfile();
if (value.value && value.value.name !== undefined) { if (value.value && value.value.name !== undefined) {
await router.push(`/@${value.value.name}`); await router.push(`/@${value.value.slug}`);
} else { } else {
await router.push('/'); 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 { return {
creator: value, creator: value,
hasCreator, hasCreator,
removeCreatorPage,
restoreCreatorPage,
fetchCreatorProfile fetchCreatorProfile
}; };
}); });

View File

@@ -21,6 +21,10 @@ onMounted(async () => {
<v-progress-linear indeterminate></v-progress-linear> <v-progress-linear indeterminate></v-progress-linear>
</div> </div>
<div v-else> <div v-else>
<div v-if="brandingStore.value.isDeleted"
class="bg-red-500 p-2 m-4 text-center font-semibold">
This Creator page is pending deletion. You can revert the action in your profile.
</div>
<banner></banner> <banner></banner>
<router-view></router-view> <router-view></router-view>
<Footer></Footer> <Footer></Footer>

View File

@@ -70,7 +70,7 @@ function toggleLanguage() {
<div class="side-menu-items"> <div class="side-menu-items">
<template v-if="authStore.isAuthenticated"> <template v-if="authStore.isAuthenticated">
<router-link v-if="creatorProfileStore.hasCreator" :to="`/@${creatorProfileStore.creator.name}`"> <router-link v-if="creatorProfileStore.hasCreator" :to="`/@${creatorProfileStore.creator.slug}`">
<button class="menu-item-action"> <button class="menu-item-action">
<i class="mdi mdi-file-account-outline"></i> <i class="mdi mdi-file-account-outline"></i>
<span class="label">{{ t.myPage }}</span> <span class="label">{{ t.myPage }}</span>

View File

@@ -276,6 +276,25 @@ const closeDialog = () => {
</div> </div>
</div> </div>
<div class="card">
<div class="card-title">Danger Zone</div>
<div class="content">
<p>
CAUTION: This will delete your creator page and suspend all tips and donations.
</p>
<button v-if="!creatorProfileStore.creator.isDeleted"
class="danger-action"
@click="creatorProfileStore.removeCreatorPage()">
DELETE PAGE
</button>
<button v-else
class="safe-action"
@click="creatorProfileStore.restoreCreatorPage()">
RESTORE PAGE
</button>
</div>
</div>
</template> </template>
</div> </div>
@@ -286,11 +305,24 @@ const closeDialog = () => {
.action { .action {
@apply p-2 flex items-center w-full mb-2; @apply p-2 flex items-center w-full mb-2;
@apply font-sans text-base;
@apply rounded-md; @apply rounded-md;
@apply transition duration-200 ease-in-out; @apply transition duration-200 ease-in-out;
@apply hover:bg-hutopyPrimary active:bg-hutopySecondary; @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 { .label {
@apply flex-none min-w-40 text-left; @apply flex-none min-w-40 text-left;
} }