feat(admin): shadowban owners (Gitea #10 Phase 3, part 2/4)
Adds a Shadowban model (mirrors UserBan: reason, issuedBy, expiresAt =
null for permanent) plus MODERATOR+ shadowbanOwner/removeShadowban
mutations logging SHADOWBAN_USER/UNSHADOWBAN_USER to ModerationLog.
Unlike a ban, a shadowbanned owner is never told and keeps using the
app normally — their posts just quietly stop reaching anyone else.
Every reader-facing post query now excludes a shadowbanned owner's
pets for other viewers while still showing that owner their own
content, via a `pet: { OR: [{ ownerId: ctx.userId }, { owner: {
shadowban: null } }] }` filter: feed (both Redis-postId lookup and the
Postgres fallback), profile grid, hashtag search, and the milestones
tab. Explore trending has no reliable per-viewer identity (public
procedure) so it excludes shadowbanned owners unconditionally — a
low-stakes gap since a single post rarely lands there anyway.
Admin dashboard (Gitea #10's "Konten sperren ... Shadowban" spec item):
Users page now shows a violet SHADOWBANNED badge + reason next to the
existing ban badge, and a Ghost-icon shadowban/un-shadowban button
mirroring the ban/unban flow (reason required, unlike ban's optional
one, since the deceptive nature of the action warrants a mandatory
audit trail).
Gitea #10
This commit is contained in:
1 parent
a29c57e37a
commit
eb26cb5fbe
8 files changed
+212
-5
No files matched your search
+17
-1
@@ -13,6 +13,7 @@ model Owner {
|
||||
pets Pet[]
|
||||
adminRole AdminRole?
|
||||
ban UserBan?
|
||||
shadowban Shadowban?
|
||||
issuedInvites InviteCode[] @relation("IssuedBy")
|
||||
legalAcknowledgedAt DateTime? // last time the owner dismissed the legal-update banner; null = never seen one
|
||||
contentRightsConsentedAt DateTime? // first-pet onboarding: confirms the owner holds all rights to uploaded photos/videos
|
||||
@@ -623,6 +624,21 @@ model UserBan {
|
||||
expiresAt DateTime? // null = permanent ban
|
||||
}
|
||||
|
||||
// Stealth moderation (Gitea #10 Phase 3) — unlike UserBan, the owner is never
|
||||
// told and can still use the app normally. Every reader-facing post query
|
||||
// excludes posts from a shadowbanned owner's pets for everyone except that
|
||||
// owner's own view (see feed/explore/posts/search/milestones routers), so
|
||||
// the content quietly stops reaching anyone else without a visible signal.
|
||||
model Shadowban {
|
||||
id String @id @default(cuid())
|
||||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
ownerId String @unique
|
||||
reason String @db.VarChar(500)
|
||||
shadowbannedAt DateTime @default(now())
|
||||
shadowbannedBy String // ownerId of moderator who issued it
|
||||
expiresAt DateTime? // null = permanent
|
||||
}
|
||||
|
||||
model Advertisement {
|
||||
id String @id @default(cuid())
|
||||
title String @db.VarChar(100)
|
||||
@@ -693,7 +709,7 @@ model AdRepost {
|
||||
|
||||
model ModerationLog {
|
||||
id String @id @default(cuid())
|
||||
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST
|
||||
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER
|
||||
targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET
|
||||
targetId String
|
||||
reason String? @db.VarChar(500)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
BadgeCheck,
|
||||
TriangleAlert,
|
||||
Trash2,
|
||||
Ghost,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -36,6 +37,9 @@ export default function AdminUsersPage() {
|
||||
const [unbanTarget, setUnbanTarget] = useState<string | null>(null);
|
||||
const [banReason, setBanReason] = useState("");
|
||||
const [banExpiry, setBanExpiry] = useState("");
|
||||
const [shadowbanTarget, setShadowbanTarget] = useState<string | null>(null);
|
||||
const [unshadowbanTarget, setUnshadowbanTarget] = useState<string | null>(null);
|
||||
const [shadowbanReason, setShadowbanReason] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [banStatus, setBanStatus] = useState<BanStatusFilter>("all");
|
||||
const [role, setRole] = useState<RoleFilter>("all");
|
||||
@@ -76,6 +80,25 @@ export default function AdminUsersPage() {
|
||||
})
|
||||
);
|
||||
|
||||
const shadowbanMutation = useMutation(
|
||||
trpc.admin.shadowbanOwner.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
|
||||
setShadowbanTarget(null);
|
||||
setShadowbanReason("");
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const removeShadowbanMutation = useMutation(
|
||||
trpc.admin.removeShadowban.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
|
||||
setUnshadowbanTarget(null);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const startConversationMutation = useMutation(
|
||||
trpc.admin.startTeamConversation.mutationOptions({
|
||||
onSuccess: ({ conversationId, teamPetId }) => {
|
||||
@@ -204,6 +227,14 @@ export default function AdminUsersPage() {
|
||||
BANNED
|
||||
</Badge>
|
||||
)}
|
||||
{user.shadowban && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] shrink-0 bg-violet-950/60 text-violet-300"
|
||||
>
|
||||
SHADOWBANNED
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{user.clerk && (
|
||||
<p className="text-xs text-zinc-400 mt-0.5 truncate">
|
||||
@@ -244,6 +275,9 @@ export default function AdminUsersPage() {
|
||||
{user.ban?.reason && (
|
||||
<p className="text-xs text-red-400 mt-0.5">Reason: {user.ban.reason}</p>
|
||||
)}
|
||||
{user.shadowban?.reason && (
|
||||
<p className="text-xs text-violet-400 mt-0.5">Shadowban: {user.shadowban.reason}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{user.pets[0] && (
|
||||
@@ -297,6 +331,29 @@ export default function AdminUsersPage() {
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{user.shadowban ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-violet-800 text-violet-300 hover:text-violet-100"
|
||||
onClick={() => setUnshadowbanTarget(user.id)}
|
||||
title="Remove shadowban"
|
||||
>
|
||||
<Ghost className="h-4 w-4 mr-1.5" />
|
||||
Un-shadowban
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-violet-400 hover:text-violet-300"
|
||||
onClick={() => setShadowbanTarget(user.id)}
|
||||
title="Shadowban — content quietly stops reaching others, user isn't told"
|
||||
>
|
||||
<Ghost className="h-4 w-4 mr-1.5" />
|
||||
Shadowban
|
||||
</Button>
|
||||
)}
|
||||
{user.ban ? (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -415,6 +472,72 @@ export default function AdminUsersPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Shadowban dialog */}
|
||||
<Dialog
|
||||
open={!!shadowbanTarget}
|
||||
onOpenChange={(o) => { if (!o) { setShadowbanTarget(null); setShadowbanReason(""); } }}
|
||||
>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Shadowban user</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-zinc-500 font-mono break-all">{shadowbanTarget}</p>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Their posts quietly stop reaching anyone else — feed, explore, search, hashtags,
|
||||
profile view. They keep posting normally and are never told.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Reason (required)"
|
||||
value={shadowbanReason}
|
||||
onChange={(e) => setShadowbanReason(e.target.value)}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" className="text-zinc-400" onClick={() => setShadowbanTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-violet-700 hover:bg-violet-600"
|
||||
onClick={() =>
|
||||
shadowbanMutation.mutate({
|
||||
ownerId: shadowbanTarget!,
|
||||
reason: shadowbanReason.trim(),
|
||||
})
|
||||
}
|
||||
disabled={shadowbanMutation.isPending || !shadowbanReason.trim()}
|
||||
>
|
||||
{shadowbanMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Confirm shadowban
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Un-shadowban confirm */}
|
||||
<Dialog open={!!unshadowbanTarget} onOpenChange={(o) => { if (!o) setUnshadowbanTarget(null); }}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove shadowban</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Restore normal visibility for this user's posts?
|
||||
</p>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" className="text-zinc-400" onClick={() => setUnshadowbanTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => removeShadowbanMutation.mutate({ ownerId: unshadowbanTarget! })}
|
||||
disabled={removeShadowbanMutation.isPending}
|
||||
className="bg-zinc-700 hover:bg-zinc-600"
|
||||
>
|
||||
{removeShadowbanMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Remove shadowban
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Warnings history + issue new */}
|
||||
<Dialog
|
||||
open={!!warnTarget}
|
||||
|
||||
@@ -425,6 +425,7 @@ export const adminRouter = router({
|
||||
include: {
|
||||
pets: { select: { id: true, name: true, avatarKey: true, isVerified: true }, take: 3 },
|
||||
ban: true,
|
||||
shadowban: true,
|
||||
adminRole: true,
|
||||
_count: { select: { pets: true, warnings: true } },
|
||||
},
|
||||
@@ -526,6 +527,54 @@ export const adminRouter = router({
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
shadowbanOwner: protectedProcedure
|
||||
.input(z.object({ ownerId: z.string().min(1), reason: z.string().min(1).max(500) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
|
||||
if (input.ownerId === ctx.userId)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot shadowban yourself." });
|
||||
await ctx.prisma.$transaction([
|
||||
ctx.prisma.shadowban.upsert({
|
||||
where: { ownerId: input.ownerId },
|
||||
create: { ownerId: input.ownerId, reason: input.reason, shadowbannedBy: ctx.userId },
|
||||
update: { reason: input.reason, shadowbannedBy: ctx.userId, shadowbannedAt: new Date() },
|
||||
}),
|
||||
ctx.prisma.moderationLog.create({
|
||||
data: {
|
||||
action: "SHADOWBAN_USER",
|
||||
targetType: "USER",
|
||||
targetId: input.ownerId,
|
||||
reason: input.reason,
|
||||
moderatorId: ctx.userId,
|
||||
moderatorRole: callerRole,
|
||||
ipAddress: ctx.ip,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
removeShadowban: protectedProcedure
|
||||
.input(z.object({ ownerId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
|
||||
await ctx.prisma.$transaction([
|
||||
ctx.prisma.shadowban.delete({ where: { ownerId: input.ownerId } }),
|
||||
ctx.prisma.moderationLog.create({
|
||||
data: {
|
||||
action: "UNSHADOWBAN_USER",
|
||||
targetType: "USER",
|
||||
targetId: input.ownerId,
|
||||
reason: null,
|
||||
moderatorId: ctx.userId,
|
||||
moderatorRole: callerRole,
|
||||
ipAddress: ctx.ip,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// ─── Warnings (append-only, no delete/edit — Gitea #10 permission matrix) ────
|
||||
listWarnings: protectedProcedure
|
||||
.input(z.object({ ownerId: z.string().min(1) }))
|
||||
|
||||
@@ -60,7 +60,12 @@ export const exploreRouter = router({
|
||||
|
||||
const [posts, hashtags] = await Promise.all([
|
||||
ctx.prisma.post.findMany({
|
||||
where: { createdAt: { gte: since48h }, type: "PHOTO", hiddenAt: null },
|
||||
where: {
|
||||
createdAt: { gte: since48h },
|
||||
type: "PHOTO",
|
||||
hiddenAt: null,
|
||||
pet: { owner: { shadowban: null } },
|
||||
},
|
||||
include: {
|
||||
images: { orderBy: { position: "asc" } },
|
||||
pet: { select: { id: true, name: true, avatarKey: true } },
|
||||
|
||||
@@ -67,6 +67,7 @@ async function getDiscoveryPosts(
|
||||
pet: {
|
||||
speciesId: viewerPet.speciesId,
|
||||
id: { notIn: [viewerPet.id, ...blockedPetIds] },
|
||||
owner: { shadowban: null },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
@@ -125,6 +126,7 @@ export const feedRouter = router({
|
||||
where: {
|
||||
hiddenAt: null,
|
||||
petId: { in: petIds, notIn: blockedPetIds },
|
||||
pet: { OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }] },
|
||||
...(cursorDate ? { createdAt: { lt: cursorDate } } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
@@ -168,7 +170,10 @@ export const feedRouter = router({
|
||||
where: {
|
||||
id: { in: postIds },
|
||||
hiddenAt: null,
|
||||
pet: { id: { notIn: blockedPetIds } },
|
||||
pet: {
|
||||
id: { notIn: blockedPetIds },
|
||||
OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }],
|
||||
},
|
||||
},
|
||||
include: postInclude,
|
||||
});
|
||||
|
||||
@@ -86,7 +86,12 @@ export const milestonesRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.post.findMany({
|
||||
where: { petId: input.petId, type: "MILESTONE", hiddenAt: null },
|
||||
where: {
|
||||
petId: input.petId,
|
||||
type: "MILESTONE",
|
||||
hiddenAt: null,
|
||||
pet: { OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }] },
|
||||
},
|
||||
include: {
|
||||
milestone: true,
|
||||
images: { orderBy: { position: "asc" } },
|
||||
|
||||
@@ -208,6 +208,7 @@ export const postsRouter = router({
|
||||
where: {
|
||||
petId: input.petId,
|
||||
hiddenAt: null,
|
||||
pet: { OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }] },
|
||||
...(input.cursor
|
||||
? { createdAt: { lt: new Date(input.cursor) } }
|
||||
: {}),
|
||||
|
||||
@@ -66,7 +66,10 @@ export const searchRouter = router({
|
||||
const rows = await ctx.prisma.postHashtag.findMany({
|
||||
where: {
|
||||
hashtagId: hashtag.id,
|
||||
post: { hiddenAt: null },
|
||||
post: {
|
||||
hiddenAt: null,
|
||||
pet: { OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }] },
|
||||
},
|
||||
...(input.cursor ? { postId: { lt: input.cursor } } : {}),
|
||||
},
|
||||
take: input.limit + 1,
|
||||
|
||||
Reference in new issue
Block a user