feat(admin): add hidden admin panel, ban system, ads, and legal pages
- Admin panel at /p/[ADMIN_SECRET]/* — middleware returns 404 for wrong/missing secret - tRPC adminRouter: getStats, listPosts, deletePost, listComments, deleteComment, listUsers, banUser/unbanUser, listAds, createAd/updateAd/deleteAd, getActiveAds, listModerators, grantRole/revokeRole, getModerationLog, getActiveBan - Prisma schema: AdminRole, UserBan, Advertisement, ModerationLog models added - Ban enforcement: (app)/layout.tsx checks active ban on every request → /banned - Feed ad injection: active ads shown every 7th post in FeedList (AdCard component) - Clerk middleware.ts: protects all routes; /p/[secret] validated before auth check - Impressum + Datenschutz pages: contact data base64-obfuscated, decoded client-side only - shadcn Switch component added - ADMIN_SECRET + ADMIN_OWNER_ID documented in .env.example
This commit is contained in:
1 parent
e407064866
commit
b5f02fe7a4
18 files changed
+1815
-6
No files matched your search
@@ -30,7 +30,15 @@ export default async function AppLayout({
|
||||
|
||||
// Fetch the owner's pets via tRPC server caller (scoped by userId)
|
||||
const caller = await createTRPCCaller();
|
||||
const pets = await caller.pets.list();
|
||||
const [pets, ban] = await Promise.all([
|
||||
caller.pets.list(),
|
||||
caller.admin.getActiveBan({ ownerId: userId }),
|
||||
]);
|
||||
|
||||
// Banned users see an error page, not the app
|
||||
if (ban) {
|
||||
redirect("/banned");
|
||||
}
|
||||
|
||||
// No pets → send to onboarding to create their first pet
|
||||
if (pets.length === 0) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export default function BannedPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<div className="max-w-md w-full text-center space-y-4">
|
||||
<div className="text-5xl">🚫</div>
|
||||
<h1 className="text-2xl font-bold">Account suspended</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Your account has been suspended due to a violation of our community
|
||||
guidelines. If you believe this is an error, please contact us at{" "}
|
||||
<a
|
||||
href="mailto:support@pawfeed.org"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
support@pawfeed.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function decode(b64: string): string {
|
||||
try {
|
||||
return atob(b64);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const IMP_NAME = "RGFuaWVsIEJhcmFjIE9ubGluZWRpZW5zdGU=";
|
||||
const IMP_STREET = "QWxwZW52ZWlsY2hlbndlZyA3";
|
||||
const IMP_CITY = "ODIxNDAgT2xjaGluZw==";
|
||||
const IMP_EMAIL = "d2VibWFzdGVyQHBhd2ZlZWQub3Jn";
|
||||
|
||||
export default function DatenschutzPage() {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRevealed(true);
|
||||
}, []);
|
||||
|
||||
const name = revealed ? decode(IMP_NAME) : "––";
|
||||
const street = revealed ? decode(IMP_STREET) : "––";
|
||||
const city = revealed ? decode(IMP_CITY) : "––";
|
||||
const email = revealed ? decode(IMP_EMAIL) : "––";
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-12 prose prose-sm dark:prose-invert">
|
||||
<h1>Datenschutzerklärung</h1>
|
||||
|
||||
<h2>1. Verantwortlicher</h2>
|
||||
<address className="not-italic">
|
||||
<p>{name}</p>
|
||||
<p>{street}</p>
|
||||
<p>{city}</p>
|
||||
{revealed ? (
|
||||
<p>E-Mail: <a href={`mailto:${email}`}>{email}</a></p>
|
||||
) : (
|
||||
<p>E-Mail: ––</p>
|
||||
)}
|
||||
</address>
|
||||
|
||||
<h2>2. Erhebung und Verarbeitung personenbezogener Daten</h2>
|
||||
<p>
|
||||
Wir erheben und verarbeiten personenbezogene Daten nur, soweit dies zur Bereitstellung
|
||||
unserer Plattform erforderlich ist oder Sie uns Daten freiwillig mitgeteilt haben.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Kontodaten (E-Mail-Adresse, Passwort-Hash) zur Authentifizierung</li>
|
||||
<li>Profilangaben (Name des Haustiers, Tierart, Fotos, Biografie)</li>
|
||||
<li>Von Ihnen erstellte Inhalte (Beiträge, Kommentare, Nachrichten)</li>
|
||||
<li>Nutzungsdaten (IP-Adresse, Zeitstempel, Seitenaufrufe)</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Rechtsgrundlagen der Verarbeitung</h2>
|
||||
<p>
|
||||
Die Verarbeitung personenbezogener Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b
|
||||
DSGVO (Vertragserfüllung) sowie Art. 6 Abs. 1 lit. f DSGVO (berechtigte Interessen,
|
||||
insbesondere Betrieb und Sicherheit der Plattform).
|
||||
</p>
|
||||
|
||||
<h2>4. Drittanbieter und Auftragsverarbeiter</h2>
|
||||
<ul>
|
||||
<li><strong>Clerk Inc.</strong> – Authentifizierungsdienst (USA). Datenübertragung auf Basis von EU-SCC.</li>
|
||||
<li><strong>Supabase Inc.</strong> – Datenbankhosting (AWS EU-West). AVV abgeschlossen.</li>
|
||||
<li><strong>Cloudflare Inc.</strong> – Medienspeicherung und CDN (USA/EU). AVV abgeschlossen.</li>
|
||||
<li><strong>Mux Inc.</strong> – Videoverarbeitung (USA). SCC-Basis.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Cookies und Tracking</h2>
|
||||
<p>
|
||||
Wir verwenden technisch notwendige Cookies zur Sitzungsverwaltung. Tracking-Cookies oder
|
||||
Cookies zu Werbezwecken werden ohne Ihre ausdrückliche Einwilligung nicht gesetzt.
|
||||
</p>
|
||||
|
||||
<h2>6. Speicherdauer</h2>
|
||||
<p>
|
||||
Personenbezogene Daten werden gelöscht, sobald der Zweck der Verarbeitung entfällt und
|
||||
keine gesetzlichen Aufbewahrungspflichten entgegenstehen. Kontodaten werden nach Löschung
|
||||
des Kontos innerhalb von 30 Tagen gelöscht.
|
||||
</p>
|
||||
|
||||
<h2>7. Ihre Rechte</h2>
|
||||
<ul>
|
||||
<li>Auskunft (Art. 15 DSGVO)</li>
|
||||
<li>Berichtigung (Art. 16 DSGVO)</li>
|
||||
<li>Löschung (Art. 17 DSGVO)</li>
|
||||
<li>Einschränkung der Verarbeitung (Art. 18 DSGVO)</li>
|
||||
<li>Datenübertragbarkeit (Art. 20 DSGVO)</li>
|
||||
<li>Widerspruchsrecht (Art. 21 DSGVO)</li>
|
||||
</ul>
|
||||
<p>
|
||||
Zur Ausübung Ihrer Rechte wenden Sie sich per E-Mail an{" "}
|
||||
{revealed ? <a href={`mailto:${email}`}>{email}</a> : "––"}.
|
||||
</p>
|
||||
|
||||
<h2>8. Beschwerderecht</h2>
|
||||
<p>
|
||||
Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren. Zuständig
|
||||
ist das Bayerische Landesamt für Datenschutzaufsicht (BayLDA),{" "}
|
||||
<a href="https://www.lda.bayern.de" target="_blank" rel="noopener noreferrer">
|
||||
www.lda.bayern.de
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<h2>9. Minderjährigenschutz</h2>
|
||||
<p>
|
||||
Unser Angebot richtet sich nicht an Personen unter 13 Jahren. Wir erheben wissentlich keine
|
||||
personenbezogenen Daten von Kindern unter 13 Jahren.
|
||||
</p>
|
||||
|
||||
<h2>10. Aktualität dieser Erklärung</h2>
|
||||
<p>Stand: Juni 2026. Durch die Weiterentwicklung unserer Plattform kann es notwendig werden,
|
||||
diese Erklärung zu ändern.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function decode(b64: string): string {
|
||||
try {
|
||||
return atob(b64);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Base64-encoded contact data — decoded client-side only; crawlers see placeholders.
|
||||
const IMP_NAME = "RGFuaWVsIEJhcmFjIE9ubGluZWRpZW5zdGU=";
|
||||
const IMP_STREET = "QWxwZW52ZWlsY2hlbndlZyA3";
|
||||
const IMP_CITY = "ODIxNDAgT2xjaGluZw==";
|
||||
const IMP_EMAIL = "d2VibWFzdGVyQHBhd2ZlZWQub3Jn";
|
||||
|
||||
export default function ImpressumPage() {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRevealed(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-12 prose prose-sm dark:prose-invert">
|
||||
<h1>Impressum</h1>
|
||||
|
||||
<h2>Angaben gemäß § 5 TMG</h2>
|
||||
{revealed ? (
|
||||
<address className="not-italic">
|
||||
<p>{decode(IMP_NAME)}</p>
|
||||
<p>{decode(IMP_STREET)}</p>
|
||||
<p>{decode(IMP_CITY)}</p>
|
||||
</address>
|
||||
) : (
|
||||
<address className="not-italic opacity-0 select-none" aria-hidden>
|
||||
<p>​</p>
|
||||
<p>​</p>
|
||||
<p>​</p>
|
||||
</address>
|
||||
)}
|
||||
|
||||
<h2>Kontakt</h2>
|
||||
{revealed ? (
|
||||
<p>
|
||||
E-Mail:{" "}
|
||||
<a href={`mailto:${decode(IMP_EMAIL)}`}>{decode(IMP_EMAIL)}</a>
|
||||
</p>
|
||||
) : (
|
||||
<p className="opacity-0 select-none" aria-hidden>
|
||||
​
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
|
||||
{revealed ? (
|
||||
<address className="not-italic">
|
||||
<p>{decode(IMP_NAME)}</p>
|
||||
<p>{decode(IMP_STREET)}</p>
|
||||
<p>{decode(IMP_CITY)}</p>
|
||||
</address>
|
||||
) : (
|
||||
<address className="not-italic opacity-0 select-none" aria-hidden>
|
||||
<p>​</p>
|
||||
</address>
|
||||
)}
|
||||
|
||||
<h2>Streitschlichtung</h2>
|
||||
<p>
|
||||
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:{" "}
|
||||
<a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener noreferrer">
|
||||
https://ec.europa.eu/consumers/odr/
|
||||
</a>
|
||||
. Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
|
||||
Verbraucherschlichtungsstelle teilzunehmen.
|
||||
</p>
|
||||
|
||||
<h2>Haftung für Inhalte</h2>
|
||||
<p>
|
||||
Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach
|
||||
den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter
|
||||
jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu
|
||||
überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
|
||||
</p>
|
||||
|
||||
<h2>Haftung für Links</h2>
|
||||
<p>
|
||||
Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen
|
||||
Einfluss haben. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder
|
||||
Betreiber der Seiten verantwortlich.
|
||||
</p>
|
||||
|
||||
<h2>Urheberrecht</h2>
|
||||
<p>
|
||||
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen
|
||||
dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der
|
||||
Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung
|
||||
des jeweiligen Autors bzw. Erstellers.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Plus, Pencil, Trash2, Loader2, Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type AdForm = {
|
||||
title: string;
|
||||
caption: string;
|
||||
targetUrl: string;
|
||||
isActive: boolean;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
const emptyForm: AdForm = {
|
||||
title: "",
|
||||
caption: "",
|
||||
targetUrl: "",
|
||||
isActive: true,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
};
|
||||
|
||||
export default function AdminAdsPage() {
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [form, setForm] = useState<AdForm>(emptyForm);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const { data: rawAds, isLoading } = useQuery(trpc.admin.listAds.queryOptions());
|
||||
const ads = rawAds ?? [];
|
||||
|
||||
const invalidate = () => void qc.invalidateQueries({ queryKey: trpc.admin.listAds.queryKey() });
|
||||
|
||||
const createAd = useMutation(
|
||||
trpc.admin.createAd.mutationOptions({ onSuccess: () => { invalidate(); closeDialog(); } })
|
||||
);
|
||||
|
||||
const updateAd = useMutation(
|
||||
trpc.admin.updateAd.mutationOptions({ onSuccess: () => { invalidate(); closeDialog(); } })
|
||||
);
|
||||
|
||||
const deleteAd = useMutation(
|
||||
trpc.admin.deleteAd.mutationOptions({ onSuccess: () => { invalidate(); setDeleteId(null); } })
|
||||
);
|
||||
|
||||
function openCreate() {
|
||||
setForm(emptyForm);
|
||||
setEditingId(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(ad: (typeof ads)[number]) {
|
||||
setForm({
|
||||
title: ad.title,
|
||||
caption: ad.caption ?? "",
|
||||
targetUrl: ad.targetUrl ?? "",
|
||||
isActive: ad.isActive,
|
||||
startDate: ad.startDate ? new Date(ad.startDate).toISOString().slice(0, 16) : "",
|
||||
endDate: ad.endDate ? new Date(ad.endDate).toISOString().slice(0, 16) : "",
|
||||
});
|
||||
setEditingId(ad.id);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialogOpen(false);
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
caption: form.caption || undefined,
|
||||
targetUrl: form.targetUrl || undefined,
|
||||
isActive: form.isActive,
|
||||
startDate: form.startDate ? new Date(form.startDate).toISOString() : undefined,
|
||||
endDate: form.endDate ? new Date(form.endDate).toISOString() : undefined,
|
||||
};
|
||||
if (editingId) {
|
||||
updateAd.mutate({ id: editingId, ...payload });
|
||||
} else {
|
||||
createAd.mutate({ ...payload, species: [] });
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createAd.isPending || updateAd.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Advertisements</h1>
|
||||
<Button size="sm" onClick={openCreate} className="bg-orange-500 hover:bg-orange-600 text-white">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
New ad
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
) : ads.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500 text-center py-12">No ads yet — create one above</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{ads.map((ad) => (
|
||||
<div
|
||||
key={ad.id}
|
||||
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-zinc-100 truncate">{ad.title}</p>
|
||||
{ad.isActive ? (
|
||||
<Badge className="bg-green-700 text-green-100 text-[10px]">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[10px]">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
{ad.caption && (
|
||||
<p className="text-xs text-zinc-500 truncate mt-0.5">{ad.caption}</p>
|
||||
)}
|
||||
{ad.targetUrl && (
|
||||
<p className="text-xs text-zinc-600 truncate">{ad.targetUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-zinc-400 hover:text-zinc-100"
|
||||
onClick={() => openEdit(ad)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:text-red-300"
|
||||
onClick={() => setDeleteId(ad.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create / Edit dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={(o) => { if (!o) closeDialog(); }}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? "Edit ad" : "Create ad"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Title *"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Caption"
|
||||
value={form.caption}
|
||||
onChange={(e) => setForm({ ...form, caption: e.target.value })}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Target URL (https://…)"
|
||||
value={form.targetUrl}
|
||||
onChange={(e) => setForm({ ...form, targetUrl: e.target.value })}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-zinc-500">Start date</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={form.startDate}
|
||||
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-zinc-500">End date</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={form.endDate}
|
||||
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={form.isActive}
|
||||
onCheckedChange={(v: boolean) => setForm({ ...form, isActive: v })}
|
||||
/>
|
||||
<span className="text-sm text-zinc-300">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 mt-2">
|
||||
<Button variant="ghost" className="text-zinc-400" onClick={closeDialog}>
|
||||
<X className="h-4 w-4 mr-1.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submitForm}
|
||||
disabled={isPending || !form.title.trim()}
|
||||
className="bg-orange-500 hover:bg-orange-600 text-white"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-1.5" />
|
||||
)}
|
||||
{editingId ? "Save" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirm */}
|
||||
<Dialog open={!!deleteId} onOpenChange={(o) => { if (!o) setDeleteId(null); }}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete ad</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-zinc-400">Delete this advertisement permanently?</p>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteAd.mutate({ id: deleteId! })}
|
||||
disabled={deleteAd.isPending}
|
||||
>
|
||||
{deleteAd.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import Link from "next/link";
|
||||
import { createTRPCCaller } from "@/trpc/server";
|
||||
import { LayoutDashboard, Users, Megaphone, ShieldCheck, FileText } from "lucide-react";
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ secret: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminLayout({ children, params }: AdminLayoutProps) {
|
||||
const { secret } = await params;
|
||||
|
||||
// Double-check secret (middleware already validated, this is defense-in-depth)
|
||||
const adminSecret = process.env.ADMIN_SECRET;
|
||||
if (!adminSecret || secret !== adminSecret) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
redirect("/log-in");
|
||||
}
|
||||
|
||||
// Allow ADMIN_OWNER_ID (bootstrapping) or any user with an AdminRole in the DB
|
||||
const isSuperOwner = process.env.ADMIN_OWNER_ID === userId;
|
||||
if (!isSuperOwner) {
|
||||
const caller = await createTRPCCaller();
|
||||
const roles = await caller.admin.listModerators();
|
||||
const hasRole = roles.some((r) => r.ownerId === userId);
|
||||
if (!hasRole) {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
const base = `/p/${secret}`;
|
||||
|
||||
const nav = [
|
||||
{ href: base, label: "Dashboard", Icon: LayoutDashboard },
|
||||
{ href: `${base}/posts`, label: "Posts", Icon: FileText },
|
||||
{ href: `${base}/users`, label: "Users", Icon: Users },
|
||||
{ href: `${base}/ads`, label: "Ads", Icon: Megaphone },
|
||||
{ href: `${base}/moderators`, label: "Moderators", Icon: ShieldCheck },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-zinc-950 text-zinc-100">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 shrink-0 border-r border-zinc-800 flex flex-col py-6 px-4 gap-1">
|
||||
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-widest px-2 mb-3">
|
||||
PawFeed Admin
|
||||
</p>
|
||||
{nav.map(({ href, label, Icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 transition-colors"
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-auto p-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { AdminRoleType } from "@prisma/client";
|
||||
|
||||
export default function AdminModeratorsPage() {
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [newOwnerId, setNewOwnerId] = useState("");
|
||||
const [newRole, setNewRole] = useState<AdminRoleType>(AdminRoleType.MODERATOR);
|
||||
const [revokeTarget, setRevokeTarget] = useState<string | null>(null);
|
||||
|
||||
const { data: mods = [], isLoading } = useQuery(trpc.admin.listModerators.queryOptions());
|
||||
|
||||
const invalidate = () =>
|
||||
void qc.invalidateQueries({ queryKey: trpc.admin.listModerators.queryKey() });
|
||||
|
||||
const grantRole = useMutation(
|
||||
trpc.admin.grantRole.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setDialogOpen(false);
|
||||
setNewOwnerId("");
|
||||
setNewRole(AdminRoleType.MODERATOR);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const revokeRole = useMutation(
|
||||
trpc.admin.revokeRole.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setRevokeTarget(null);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Moderators</h1>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Only super-admins can manage roles</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)} className="bg-zinc-700 hover:bg-zinc-600">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Grant role
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
) : mods.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500 text-center py-12">No moderators yet</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{mods.map((mod) => {
|
||||
const pet = mod.owner.pets[0];
|
||||
return (
|
||||
<div
|
||||
key={mod.id}
|
||||
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-zinc-100 font-mono truncate">{mod.ownerId}</p>
|
||||
<Badge
|
||||
className={
|
||||
mod.role === AdminRoleType.SUPER_ADMIN
|
||||
? "bg-orange-700 text-orange-100 text-[10px]"
|
||||
: "bg-zinc-700 text-zinc-200 text-[10px]"
|
||||
}
|
||||
>
|
||||
{mod.role}
|
||||
</Badge>
|
||||
</div>
|
||||
{pet && <p className="text-xs text-zinc-500 mt-0.5">Pet: {pet.name}</p>}
|
||||
<p className="text-xs text-zinc-600 mt-0.5">
|
||||
Granted {new Date(mod.grantedAt).toLocaleDateString()}
|
||||
{mod.grantedBy && ` by ${mod.grantedBy.slice(0, 12)}…`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:text-red-300 shrink-0"
|
||||
onClick={() => setRevokeTarget(mod.ownerId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grant role dialog */}
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(o) => { if (!o) { setDialogOpen(false); setNewOwnerId(""); } }}
|
||||
>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Grant role</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-zinc-500">
|
||||
User must already have an account. Paste their Clerk user ID (starts with{" "}
|
||||
<code className="text-orange-400">user_</code>).
|
||||
</p>
|
||||
<Input
|
||||
placeholder="user_xxxxxxxxxxxxxxxxxxxx"
|
||||
value={newOwnerId}
|
||||
onChange={(e) => setNewOwnerId(e.target.value)}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 font-mono text-sm"
|
||||
/>
|
||||
<Select value={newRole} onValueChange={(v) => setNewRole(v as AdminRoleType)}>
|
||||
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-zinc-100">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-800 border-zinc-700">
|
||||
<SelectItem value={AdminRoleType.MODERATOR}>Moderator</SelectItem>
|
||||
<SelectItem value={AdminRoleType.SUPER_ADMIN}>Super Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" className="text-zinc-400" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => grantRole.mutate({ ownerId: newOwnerId.trim(), role: newRole })}
|
||||
disabled={grantRole.isPending || !newOwnerId.trim()}
|
||||
className="bg-zinc-700 hover:bg-zinc-600"
|
||||
>
|
||||
{grantRole.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Grant
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Revoke confirm */}
|
||||
<Dialog open={!!revokeTarget} onOpenChange={(o) => { if (!o) setRevokeTarget(null); }}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revoke role</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-zinc-400">Remove admin/moderator access for this user?</p>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" className="text-zinc-400" onClick={() => setRevokeTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => revokeRole.mutate({ ownerId: revokeTarget! })}
|
||||
disabled={revokeRole.isPending}
|
||||
>
|
||||
{revokeRole.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Revoke
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Users, PawPrint, FileText, ShieldOff, Megaphone, Flag } from "lucide-react";
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const trpc = useTRPC();
|
||||
const { data: stats, isLoading } = useQuery(trpc.admin.getStats.queryOptions());
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Dashboard</h1>
|
||||
<p className="text-sm text-zinc-500 mt-1">Platform overview</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-zinc-500 text-sm">Loading…</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<StatCard icon={Users} label="Owners" value={stats?.owners ?? 0} />
|
||||
<StatCard icon={PawPrint} label="Pets" value={stats?.pets ?? 0} />
|
||||
<StatCard icon={FileText} label="Posts" value={stats?.posts ?? 0} />
|
||||
<StatCard icon={ShieldOff} label="Banned users" value={stats?.bans ?? 0} color="text-red-400" />
|
||||
<StatCard icon={Megaphone} label="Active ads" value={stats?.activeAds ?? 0} color="text-orange-400" />
|
||||
<StatCard icon={Flag} label="Open reports" value={stats?.reports ?? 0} color="text-yellow-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
color = "text-zinc-100",
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-5 py-4 flex items-center gap-4">
|
||||
<Icon className={`h-5 w-5 shrink-0 ${color}`} />
|
||||
<div>
|
||||
<p className={`text-2xl font-bold ${color}`}>{value.toLocaleString()}</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Trash2, MessageSquare, Loader2, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [commentsPostId, setCommentsPostId] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; type: "post" | "comment" } | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery(
|
||||
trpc.admin.listPosts.infiniteQueryOptions(
|
||||
{ limit: 20, search: search || undefined },
|
||||
{ getNextPageParam: (p) => p.nextCursor, initialCursor: undefined }
|
||||
)
|
||||
);
|
||||
|
||||
const posts = data?.pages.flatMap((p) => p.posts) ?? [];
|
||||
|
||||
const { data: comments } = useQuery(
|
||||
trpc.admin.listComments.queryOptions(
|
||||
{ postId: commentsPostId ?? "" },
|
||||
{ enabled: !!commentsPostId }
|
||||
)
|
||||
);
|
||||
|
||||
const deletePost = useMutation(
|
||||
trpc.admin.deletePost.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: trpc.admin.listPosts.queryKey() });
|
||||
setDeleteTarget(null);
|
||||
setReason("");
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const deleteComment = useMutation(
|
||||
trpc.admin.deleteComment.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: trpc.admin.listComments.queryKey({ postId: commentsPostId ?? "" }),
|
||||
});
|
||||
setDeleteTarget(null);
|
||||
setReason("");
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
if (deleteTarget.type === "post") {
|
||||
deletePost.mutate({ postId: deleteTarget.id, reason: reason || undefined });
|
||||
} else {
|
||||
deleteComment.mutate({ commentId: deleteTarget.id, reason: reason || undefined });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Posts</h1>
|
||||
<Input
|
||||
placeholder="Search caption…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-64 bg-zinc-900 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{posts.map((post) => {
|
||||
const thumb = post.images[0];
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
|
||||
>
|
||||
{thumb ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getMediaUrl(thumb.storageKey) ?? ""}
|
||||
alt=""
|
||||
className="h-12 w-12 rounded-lg object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded-lg bg-zinc-800 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-zinc-100 truncate">
|
||||
{post.caption ?? <em className="text-zinc-500">No caption</em>}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{post.pet.name} · {post._count.reactions} 🐾 · {post._count.comments} 💬
|
||||
{post._count.reports > 0 && (
|
||||
<span className="ml-2 text-yellow-400">
|
||||
⚑ {post._count.reports} report{post._count.reports !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-zinc-400 hover:text-zinc-100"
|
||||
onClick={() => setCommentsPostId(post.id)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:text-red-300"
|
||||
onClick={() => setDeleteTarget({ id: post.id, type: "post" })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
className="border-zinc-700 text-zinc-300"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments dialog */}
|
||||
<Dialog
|
||||
open={!!commentsPostId}
|
||||
onOpenChange={(o) => { if (!o) setCommentsPostId(null); }}
|
||||
>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Comments</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
|
||||
{(comments ?? []).length === 0 && (
|
||||
<p className="text-sm text-zinc-500 text-center py-4">No comments</p>
|
||||
)}
|
||||
{(comments ?? []).map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-start gap-3 rounded-lg border border-zinc-800 px-3 py-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-zinc-300">{c.pet.name}</p>
|
||||
<p className="text-sm text-zinc-100 mt-0.5">{c.body}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:text-red-300 shrink-0"
|
||||
onClick={() => setDeleteTarget({ id: c.id, type: "comment" })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirm delete dialog */}
|
||||
<Dialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => { if (!o) { setDeleteTarget(null); setReason(""); } }}
|
||||
>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm deletion</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Delete this {deleteTarget?.type}? This action cannot be undone.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Reason (optional)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(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={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
disabled={deletePost.isPending || deleteComment.isPending}
|
||||
>
|
||||
{(deletePost.isPending || deleteComment.isPending) && (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
)}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { ShieldOff, ShieldCheck, Loader2, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [banTarget, setBanTarget] = useState<string | null>(null);
|
||||
const [unbanTarget, setUnbanTarget] = useState<string | null>(null);
|
||||
const [banReason, setBanReason] = useState("");
|
||||
const [banExpiry, setBanExpiry] = useState("");
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery(
|
||||
trpc.admin.listUsers.infiniteQueryOptions(
|
||||
{ limit: 20 },
|
||||
{ getNextPageParam: (p) => p.nextCursor, initialCursor: undefined }
|
||||
)
|
||||
);
|
||||
|
||||
const users = data?.pages.flatMap((p) => p.users) ?? [];
|
||||
|
||||
const banMutation = useMutation(
|
||||
trpc.admin.banUser.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
|
||||
setBanTarget(null);
|
||||
setBanReason("");
|
||||
setBanExpiry("");
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const unbanMutation = useMutation(
|
||||
trpc.admin.unbanUser.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: trpc.admin.listUsers.queryKey() });
|
||||
setUnbanTarget(null);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Users</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-zinc-100 font-mono truncate">{user.id}</p>
|
||||
{user.adminRole && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
{user.adminRole.role}
|
||||
</Badge>
|
||||
)}
|
||||
{user.ban && (
|
||||
<Badge variant="destructive" className="text-[10px] shrink-0">
|
||||
BANNED
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">
|
||||
{user._count.pets} pet{user._count.pets !== 1 ? "s" : ""}
|
||||
{user.pets.length > 0 && " · " + user.pets.map((p) => p.name).join(", ")}
|
||||
</p>
|
||||
{user.ban?.reason && (
|
||||
<p className="text-xs text-red-400 mt-0.5">Reason: {user.ban.reason}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{user.ban ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
||||
onClick={() => setUnbanTarget(user.id)}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 mr-1.5" />
|
||||
Unban
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:text-red-300"
|
||||
onClick={() => setBanTarget(user.id)}
|
||||
>
|
||||
<ShieldOff className="h-4 w-4 mr-1.5" />
|
||||
Ban
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
className="border-zinc-700 text-zinc-300"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ban dialog */}
|
||||
<Dialog
|
||||
open={!!banTarget}
|
||||
onOpenChange={(o) => { if (!o) { setBanTarget(null); setBanReason(""); setBanExpiry(""); } }}
|
||||
>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ban user</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-zinc-500 font-mono break-all">{banTarget}</p>
|
||||
<Input
|
||||
placeholder="Reason (optional)"
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-zinc-500">
|
||||
Expires (optional — leave empty for permanent)
|
||||
</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={banExpiry}
|
||||
onChange={(e) => setBanExpiry(e.target.value)}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" className="text-zinc-400" onClick={() => setBanTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
banMutation.mutate({
|
||||
ownerId: banTarget!,
|
||||
reason: banReason || undefined,
|
||||
expiresAt: banExpiry ? new Date(banExpiry).toISOString() : undefined,
|
||||
})
|
||||
}
|
||||
disabled={banMutation.isPending}
|
||||
>
|
||||
{banMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Confirm ban
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Unban confirm */}
|
||||
<Dialog open={!!unbanTarget} onOpenChange={(o) => { if (!o) setUnbanTarget(null); }}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unban user</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-zinc-400">Remove the ban for this user?</p>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" className="text-zinc-400" onClick={() => setUnbanTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => unbanMutation.mutate({ ownerId: unbanTarget! })}
|
||||
disabled={unbanMutation.isPending}
|
||||
className="bg-zinc-700 hover:bg-zinc-600"
|
||||
>
|
||||
{unbanMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Unban
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
type AdCardAd = {
|
||||
id: string;
|
||||
title: string;
|
||||
caption: string | null;
|
||||
targetUrl: string | null;
|
||||
imageKey: string | null;
|
||||
};
|
||||
|
||||
export function AdCard({ ad }: { ad: AdCardAd }) {
|
||||
const inner = (
|
||||
<div className="rounded-2xl border border-orange-200/30 bg-orange-50/5 px-5 py-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-orange-400 bg-orange-400/10 px-2 py-0.5 rounded-full">
|
||||
Sponsored
|
||||
</span>
|
||||
{ad.targetUrl && <ExternalLink className="h-3 w-3 text-muted-foreground ml-auto" />}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">{ad.title}</p>
|
||||
{ad.caption && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{ad.caption}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (ad.targetUrl) {
|
||||
return (
|
||||
<a
|
||||
href={ad.targetUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
className="block no-underline hover:opacity-90 transition-opacity"
|
||||
aria-label={`Sponsored: ${ad.title}`}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return inner;
|
||||
}
|
||||
@@ -3,13 +3,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
import { PostCard } from "@/components/feed/PostCard";
|
||||
import { AdCard } from "@/components/feed/AdCard";
|
||||
import { SkeletonCard } from "@/components/feed/SkeletonCard";
|
||||
import { EmptyFeed } from "@/components/feed/EmptyFeed";
|
||||
import { WelcomeCard } from "@/components/feed/WelcomeCard";
|
||||
import { Sparkles, Clock } from "lucide-react";
|
||||
const AD_INTERVAL = 7;
|
||||
|
||||
type FeedMode = "chrono" | "algo";
|
||||
|
||||
@@ -62,8 +64,26 @@ export function FeedList() {
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const { data: activeAds } = useQuery(trpc.admin.getActiveAds.queryOptions());
|
||||
const ads = activeAds ?? [];
|
||||
|
||||
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
|
||||
|
||||
// Build interleaved feed: inject one ad every AD_INTERVAL posts (cycle through ads)
|
||||
type Post = (typeof allPosts)[number];
|
||||
type Ad = (typeof ads)[number];
|
||||
type FeedItem = { kind: "post"; post: Post } | { kind: "ad"; ad: Ad; key: string };
|
||||
|
||||
const feedItems: FeedItem[] = [];
|
||||
let adIndex = 0;
|
||||
allPosts.forEach((post, i) => {
|
||||
feedItems.push({ kind: "post", post });
|
||||
if (ads.length > 0 && (i + 1) % AD_INTERVAL === 0) {
|
||||
feedItems.push({ kind: "ad", ad: ads[adIndex % ads.length]!, key: `ad-${i}-${adIndex}` });
|
||||
adIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 py-4">
|
||||
@@ -109,11 +129,15 @@ export function FeedList() {
|
||||
{/* New-user welcome card */}
|
||||
{activePetId && <WelcomeCard petId={activePetId} />}
|
||||
|
||||
{/* Feed posts */}
|
||||
{/* Feed posts + injected ads */}
|
||||
<div className="flex flex-col gap-8">
|
||||
{allPosts.map((post) => (
|
||||
<PostCard key={post.id} post={{ ...post, createdAt: post.createdAt }} />
|
||||
))}
|
||||
{feedItems.map((item) =>
|
||||
item.kind === "post" ? (
|
||||
<PostCard key={item.post.id} post={{ ...item.post, createdAt: item.post.createdAt }} />
|
||||
) : (
|
||||
<AdCard key={item.key} ad={item.ad} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={sentinelRef} aria-hidden="true" className="h-4" />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SwitchPrimitive.Root.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,41 @@
|
||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const isPublicRoute = createRouteMatcher([
|
||||
"/log-in(.*)",
|
||||
"/sign-up(.*)",
|
||||
"/forgot-password(.*)",
|
||||
"/reset-password(.*)",
|
||||
"/verify-email(.*)",
|
||||
"/health-card/(.*)",
|
||||
"/impressum",
|
||||
"/datenschutz",
|
||||
"/api/webhooks/(.*)",
|
||||
]);
|
||||
|
||||
export default clerkMiddleware(async (auth, request) => {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Admin panel: validate secret path segment before anything else.
|
||||
// Returns 404 (not 403) — the panel's existence must not be revealed.
|
||||
if (pathname.startsWith("/p/")) {
|
||||
const segments = pathname.split("/");
|
||||
const providedSecret = segments[2] ?? "";
|
||||
const adminSecret = process.env.ADMIN_SECRET ?? "";
|
||||
|
||||
if (!adminSecret || providedSecret !== adminSecret) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPublicRoute(request)) {
|
||||
await auth.protect();
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
||||
"/(api|trpc)(.*)",
|
||||
],
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import { notificationsRouter } from "@/trpc/routers/notifications";
|
||||
import { videosRouter } from "@/trpc/routers/videos";
|
||||
import { conversationsRouter, messagesRouter } from "@/trpc/routers/messages";
|
||||
import { healthRouter } from "@/trpc/routers/health";
|
||||
import { adminRouter } from "@/trpc/routers/admin";
|
||||
|
||||
export const appRouter = router({
|
||||
pets: petsRouter,
|
||||
@@ -38,6 +39,7 @@ export const appRouter = router({
|
||||
conversations: conversationsRouter,
|
||||
messages: messagesRouter,
|
||||
health: healthRouter,
|
||||
admin: adminRouter,
|
||||
});
|
||||
|
||||
// Export the type for use in the client
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { AdminRoleType, type PrismaClient } from "@prisma/client";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
|
||||
async function assertAdmin(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
requireSuperAdmin = false
|
||||
) {
|
||||
const isSuperOwner = process.env.ADMIN_OWNER_ID === userId;
|
||||
if (isSuperOwner && !requireSuperAdmin) return;
|
||||
if (isSuperOwner) return;
|
||||
|
||||
const role = await prisma.adminRole.findUnique({ where: { ownerId: userId } });
|
||||
if (!role) throw new TRPCError({ code: "FORBIDDEN" });
|
||||
if (requireSuperAdmin && role.role !== AdminRoleType.SUPER_ADMIN) {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
}
|
||||
|
||||
export const adminRouter = router({
|
||||
// ─── Ban status check (no admin role required) ───────────────────────────────
|
||||
getActiveBan: protectedProcedure
|
||||
.input(z.object({ ownerId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Only the owner themselves or an admin should query ban status
|
||||
if (input.ownerId !== ctx.userId) {
|
||||
const isAdmin =
|
||||
process.env.ADMIN_OWNER_ID === ctx.userId ||
|
||||
(await ctx.prisma.adminRole.findUnique({ where: { ownerId: ctx.userId } })) !== null;
|
||||
if (!isAdmin) throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
const ban = await ctx.prisma.userBan.findUnique({ where: { ownerId: input.ownerId } });
|
||||
if (!ban) return null;
|
||||
// Check if a temporary ban has expired
|
||||
if (ban.expiresAt && ban.expiresAt < new Date()) {
|
||||
await ctx.prisma.userBan.delete({ where: { id: ban.id } }).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
return ban;
|
||||
}),
|
||||
|
||||
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
const [owners, pets, posts, bans, ads, reports] = await Promise.all([
|
||||
ctx.prisma.owner.count(),
|
||||
ctx.prisma.pet.count(),
|
||||
ctx.prisma.post.count(),
|
||||
ctx.prisma.userBan.count(),
|
||||
ctx.prisma.advertisement.count({ where: { isActive: true } }),
|
||||
ctx.prisma.report.count(),
|
||||
]);
|
||||
return { owners, pets, posts, bans, activeAds: ads, reports };
|
||||
}),
|
||||
|
||||
// ─── Post moderation ─────────────────────────────────────────────────────────
|
||||
listPosts: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
cursor: z.string().nullish(),
|
||||
limit: z.number().min(1).max(50).default(20),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
const posts = await ctx.prisma.post.findMany({
|
||||
where: input.search
|
||||
? { caption: { contains: input.search, mode: "insensitive" } }
|
||||
: undefined,
|
||||
include: {
|
||||
pet: { select: { id: true, name: true, avatarKey: true, ownerId: true } },
|
||||
images: { orderBy: { position: "asc" }, take: 1 },
|
||||
_count: { select: { reactions: true, comments: true, reports: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: input.limit + 1,
|
||||
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
|
||||
});
|
||||
const hasNext = posts.length > input.limit;
|
||||
const items = hasNext ? posts.slice(0, input.limit) : posts;
|
||||
return { posts: items, nextCursor: hasNext ? items[items.length - 1]!.id : null };
|
||||
}),
|
||||
|
||||
deletePost: protectedProcedure
|
||||
.input(z.object({ postId: z.string().min(1), reason: z.string().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
await ctx.prisma.post.delete({ where: { id: input.postId } });
|
||||
await ctx.prisma.moderationLog.create({
|
||||
data: {
|
||||
action: "DELETE_POST",
|
||||
targetType: "POST",
|
||||
targetId: input.postId,
|
||||
reason: input.reason ?? null,
|
||||
moderatorId: ctx.userId,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
listComments: protectedProcedure
|
||||
.input(z.object({ postId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
return ctx.prisma.comment.findMany({
|
||||
where: { postId: input.postId },
|
||||
include: { pet: { select: { id: true, name: true, avatarKey: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
}),
|
||||
|
||||
deleteComment: protectedProcedure
|
||||
.input(z.object({ commentId: z.string().min(1), reason: z.string().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
await ctx.prisma.comment.delete({ where: { id: input.commentId } });
|
||||
await ctx.prisma.moderationLog.create({
|
||||
data: {
|
||||
action: "DELETE_COMMENT",
|
||||
targetType: "COMMENT",
|
||||
targetId: input.commentId,
|
||||
reason: input.reason ?? null,
|
||||
moderatorId: ctx.userId,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// ─── Users / bans ────────────────────────────────────────────────────────────
|
||||
listUsers: protectedProcedure
|
||||
.input(z.object({ cursor: z.string().nullish(), limit: z.number().min(1).max(50).default(20) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
const owners = await ctx.prisma.owner.findMany({
|
||||
include: {
|
||||
pets: { select: { id: true, name: true, avatarKey: true }, take: 3 },
|
||||
ban: true,
|
||||
adminRole: true,
|
||||
_count: { select: { pets: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: input.limit + 1,
|
||||
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
|
||||
});
|
||||
const hasNext = owners.length > input.limit;
|
||||
const items = hasNext ? owners.slice(0, input.limit) : owners;
|
||||
return { users: items, nextCursor: hasNext ? items[items.length - 1]!.id : null };
|
||||
}),
|
||||
|
||||
banUser: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ownerId: z.string().min(1),
|
||||
reason: z.string().max(500).optional(),
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
if (input.ownerId === ctx.userId)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot ban yourself." });
|
||||
await ctx.prisma.userBan.upsert({
|
||||
where: { ownerId: input.ownerId },
|
||||
create: {
|
||||
ownerId: input.ownerId,
|
||||
reason: input.reason ?? null,
|
||||
bannedBy: ctx.userId,
|
||||
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
|
||||
},
|
||||
update: {
|
||||
reason: input.reason ?? null,
|
||||
bannedBy: ctx.userId,
|
||||
bannedAt: new Date(),
|
||||
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
|
||||
},
|
||||
});
|
||||
await ctx.prisma.moderationLog.create({
|
||||
data: {
|
||||
action: "BAN_USER",
|
||||
targetType: "USER",
|
||||
targetId: input.ownerId,
|
||||
reason: input.reason ?? null,
|
||||
moderatorId: ctx.userId,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
unbanUser: protectedProcedure
|
||||
.input(z.object({ ownerId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
await ctx.prisma.userBan.delete({ where: { ownerId: input.ownerId } });
|
||||
await ctx.prisma.moderationLog.create({
|
||||
data: {
|
||||
action: "UNBAN_USER",
|
||||
targetType: "USER",
|
||||
targetId: input.ownerId,
|
||||
reason: null,
|
||||
moderatorId: ctx.userId,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// ─── Reports ─────────────────────────────────────────────────────────────────
|
||||
listReports: protectedProcedure.query(async ({ ctx }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
return ctx.prisma.report.findMany({
|
||||
include: {
|
||||
reporter: { select: { id: true, name: true } },
|
||||
post: { select: { id: true, caption: true, petId: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
}),
|
||||
|
||||
// ─── Advertisements ──────────────────────────────────────────────────────────
|
||||
listAds: protectedProcedure.query(async ({ ctx }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
return ctx.prisma.advertisement.findMany({ orderBy: { createdAt: "desc" } });
|
||||
}),
|
||||
|
||||
createAd: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
caption: z.string().max(500).optional(),
|
||||
imageKey: z.string().optional(),
|
||||
targetUrl: z.string().url().or(z.literal("")).optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
species: z.array(z.string()).default([]),
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
return ctx.prisma.advertisement.create({
|
||||
data: {
|
||||
title: input.title,
|
||||
caption: input.caption ?? null,
|
||||
imageKey: input.imageKey ?? null,
|
||||
targetUrl: input.targetUrl || null,
|
||||
isActive: input.isActive,
|
||||
species: input.species,
|
||||
startDate: input.startDate ? new Date(input.startDate) : null,
|
||||
endDate: input.endDate ? new Date(input.endDate) : null,
|
||||
createdBy: ctx.userId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
updateAd: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1).max(100).optional(),
|
||||
caption: z.string().max(500).nullable().optional(),
|
||||
imageKey: z.string().nullable().optional(),
|
||||
targetUrl: z.string().url().or(z.literal("")).nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
species: z.array(z.string()).optional(),
|
||||
startDate: z.string().datetime().nullable().optional(),
|
||||
endDate: z.string().datetime().nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
const { id, targetUrl, startDate, endDate, ...rest } = input;
|
||||
return ctx.prisma.advertisement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
targetUrl: targetUrl === "" ? null : targetUrl,
|
||||
startDate: startDate ? new Date(startDate) : startDate === null ? null : undefined,
|
||||
endDate: endDate ? new Date(endDate) : endDate === null ? null : undefined,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
deleteAd: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
await ctx.prisma.advertisement.delete({ where: { id: input.id } });
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getActiveAds: protectedProcedure.query(async ({ ctx }) => {
|
||||
const now = new Date();
|
||||
return ctx.prisma.advertisement.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
OR: [{ startDate: null }, { startDate: { lte: now } }],
|
||||
AND: [{ OR: [{ endDate: null }, { endDate: { gte: now } }] }],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}),
|
||||
|
||||
// ─── Moderator management (super-admin only) ─────────────────────────────────
|
||||
listModerators: protectedProcedure.query(async ({ ctx }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
return ctx.prisma.adminRole.findMany({
|
||||
include: {
|
||||
owner: {
|
||||
include: { pets: { select: { name: true, avatarKey: true }, take: 1 } },
|
||||
},
|
||||
},
|
||||
orderBy: { grantedAt: "desc" },
|
||||
});
|
||||
}),
|
||||
|
||||
grantRole: protectedProcedure
|
||||
.input(z.object({ ownerId: z.string().min(1), role: z.nativeEnum(AdminRoleType) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId, true);
|
||||
return ctx.prisma.adminRole.upsert({
|
||||
where: { ownerId: input.ownerId },
|
||||
create: { ownerId: input.ownerId, role: input.role, grantedBy: ctx.userId },
|
||||
update: { role: input.role, grantedBy: ctx.userId, grantedAt: new Date() },
|
||||
});
|
||||
}),
|
||||
|
||||
revokeRole: protectedProcedure
|
||||
.input(z.object({ ownerId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId, true);
|
||||
await ctx.prisma.adminRole.delete({ where: { ownerId: input.ownerId } });
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getModerationLog: protectedProcedure
|
||||
.input(z.object({ limit: z.number().min(1).max(100).default(50) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
return ctx.prisma.moderationLog.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: input.limit,
|
||||
});
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user