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:
admin committed 2026-08-12 15:49:45 +02:00
1 parent a29c57e37a
commit eb26cb5fbe
8 files changed
+212 -5

No files matched your search

+17 -1
View File
@@ -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)
+123
View File
@@ -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&apos;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}
+49
View File
@@ -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) }))
+6 -1
View File
@@ -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 } },
+6 -1
View File
@@ -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,
});
+6 -1
View File
@@ -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" } },
+1
View File
@@ -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) } }
: {}),
+4 -1
View File
@@ -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,