diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 89c80d5..98dbed1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/app/p/[secret]/users/page.tsx b/src/app/p/[secret]/users/page.tsx index dd22692..29887ff 100644 --- a/src/app/p/[secret]/users/page.tsx +++ b/src/app/p/[secret]/users/page.tsx @@ -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(null); const [banReason, setBanReason] = useState(""); const [banExpiry, setBanExpiry] = useState(""); + const [shadowbanTarget, setShadowbanTarget] = useState(null); + const [unshadowbanTarget, setUnshadowbanTarget] = useState(null); + const [shadowbanReason, setShadowbanReason] = useState(""); const [search, setSearch] = useState(""); const [banStatus, setBanStatus] = useState("all"); const [role, setRole] = useState("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 )} + {user.shadowban && ( + + SHADOWBANNED + + )} {user.clerk && (

@@ -244,6 +275,9 @@ export default function AdminUsersPage() { {user.ban?.reason && (

Reason: {user.ban.reason}

)} + {user.shadowban?.reason && ( +

Shadowban: {user.shadowban.reason}

+ )}
{user.pets[0] && ( @@ -297,6 +331,29 @@ export default function AdminUsersPage() { )} + {user.shadowban ? ( + + ) : ( + + )} {user.ban ? ( + + + + + + {/* Un-shadowban confirm */} + { if (!o) setUnshadowbanTarget(null); }}> + + + Remove shadowban + +

+ Restore normal visibility for this user's posts? +

+ + + + +
+
+ {/* Warnings history + issue new */} { + 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) })) diff --git a/src/trpc/routers/explore.ts b/src/trpc/routers/explore.ts index 9c948b5..01d1407 100644 --- a/src/trpc/routers/explore.ts +++ b/src/trpc/routers/explore.ts @@ -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 } }, diff --git a/src/trpc/routers/feed.ts b/src/trpc/routers/feed.ts index 515e4d9..b7cb262 100644 --- a/src/trpc/routers/feed.ts +++ b/src/trpc/routers/feed.ts @@ -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, }); diff --git a/src/trpc/routers/milestones.ts b/src/trpc/routers/milestones.ts index 831e90d..e6f7ca3 100644 --- a/src/trpc/routers/milestones.ts +++ b/src/trpc/routers/milestones.ts @@ -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" } }, diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts index 1b0b9dd..d98732b 100644 --- a/src/trpc/routers/posts.ts +++ b/src/trpc/routers/posts.ts @@ -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) } } : {}), diff --git a/src/trpc/routers/search.ts b/src/trpc/routers/search.ts index e9ef95e..afbef79 100644 --- a/src/trpc/routers/search.ts +++ b/src/trpc/routers/search.ts @@ -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,