diff --git a/.env.example b/.env.example index 4c0872e..4bfd067 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,11 @@ CLERK_SECRET_KEY="sk_test_xxxxxxxxxxxxxxxxxxxx" NEXT_PUBLIC_SUPABASE_URL="https://xxxxxxxxxxxxxxxxxxxx.supabase.co" # Supabase Dashboard -> Project Settings -> API -> service_role (secret!) SUPABASE_SERVICE_ROLE_KEY="eyJxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# ── Admin Panel ─────────────────────────────────────────────── +# NEVER use NEXT_PUBLIC_ prefix — these MUST stay server-only! +# ADMIN_SECRET: random UUID path segment (/p/[secret]/) — generate with: +# node -e "console.log(require('crypto').randomUUID())" +ADMIN_SECRET="your-random-uuid-here" +# ADMIN_OWNER_ID: Your Clerk user ID (Clerk dashboard). Bootstraps the super-admin. +ADMIN_OWNER_ID="user_xxxxxxxxxxxxxxxxxxxx" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 72d492a..4975db9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,6 +10,8 @@ model Owner { id String @id // = Clerk userId (e.g. "user_xxx") createdAt DateTime @default(now()) pets Pet[] + adminRole AdminRole? + ban UserBan? } model Species { @@ -443,3 +445,61 @@ model Notification { @@index([recipientPetId, read, createdAt(sort: Desc)]) } + +// ─── Admin / Moderation Models ──────────────────────────────────────────────── + +enum AdminRoleType { + SUPER_ADMIN + MODERATOR +} + +model AdminRole { + id String @id @default(cuid()) + owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String @unique + role AdminRoleType @default(MODERATOR) + grantedAt DateTime @default(now()) + grantedBy String? // ownerId of the granting super admin +} + +model UserBan { + id String @id @default(cuid()) + owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String @unique + reason String? @db.VarChar(500) + bannedAt DateTime @default(now()) + bannedBy String // ownerId of moderator who issued the ban + expiresAt DateTime? // null = permanent ban + + @@index([ownerId]) +} + +model Advertisement { + id String @id @default(cuid()) + title String @db.VarChar(100) + caption String? @db.VarChar(500) + imageKey String? // Supabase Storage key + targetUrl String? @db.VarChar(500) + isActive Boolean @default(true) + species String[] // [] = all species; ["Dog"] = dogs only (future targeting) + startDate DateTime? + endDate DateTime? + createdBy String // ownerId of admin who created it + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([isActive, startDate, endDate]) +} + +model ModerationLog { + id String @id @default(cuid()) + action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE + targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE + targetId String + reason String? @db.VarChar(500) + moderatorId String // ownerId + createdAt DateTime @default(now()) + + @@index([moderatorId, createdAt(sort: Desc)]) + @@index([targetType, targetId]) +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 85fd47d..6ef3e17 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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) { diff --git a/src/app/banned/page.tsx b/src/app/banned/page.tsx new file mode 100644 index 0000000..13f1e9d --- /dev/null +++ b/src/app/banned/page.tsx @@ -0,0 +1,21 @@ +export default function BannedPage() { + return ( +
+ Your account has been suspended due to a violation of our community + guidelines. If you believe this is an error, please contact us at{" "} + + support@pawfeed.org + + . +
+{name}
+{street}
+{city}
+ {revealed ? ( +E-Mail: {email}
+ ) : ( +E-Mail: ––
+ )} + + ++ Wir erheben und verarbeiten personenbezogene Daten nur, soweit dies zur Bereitstellung + unserer Plattform erforderlich ist oder Sie uns Daten freiwillig mitgeteilt haben. +
++ 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). +
+ ++ Wir verwenden technisch notwendige Cookies zur Sitzungsverwaltung. Tracking-Cookies oder + Cookies zu Werbezwecken werden ohne Ihre ausdrückliche Einwilligung nicht gesetzt. +
+ ++ 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. +
+ ++ Zur Ausübung Ihrer Rechte wenden Sie sich per E-Mail an{" "} + {revealed ? {email} : "––"}. +
+ ++ Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren. Zuständig + ist das Bayerische Landesamt für Datenschutzaufsicht (BayLDA),{" "} + + www.lda.bayern.de + + . +
+ ++ Unser Angebot richtet sich nicht an Personen unter 13 Jahren. Wir erheben wissentlich keine + personenbezogenen Daten von Kindern unter 13 Jahren. +
+ +Stand: Juni 2026. Durch die Weiterentwicklung unserer Plattform kann es notwendig werden, + diese Erklärung zu ändern.
+{decode(IMP_NAME)}
+{decode(IMP_STREET)}
+{decode(IMP_CITY)}
+ + ) : ( + +
+
+
+ + )} + ++ E-Mail:{" "} + {decode(IMP_EMAIL)} +
+ ) : ( ++ +
+ )} + +{decode(IMP_NAME)}
+{decode(IMP_STREET)}
+{decode(IMP_CITY)}
+ + ) : ( + +
+ + )} + ++ Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:{" "} + + https://ec.europa.eu/consumers/odr/ + + . Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer + Verbraucherschlichtungsstelle teilzunehmen. +
+ ++ 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. +
+ ++ 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. +
+ ++ 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. +
+No ads yet — create one above
+ ) : ( +{ad.title}
+ {ad.isActive ? ( +{ad.caption}
+ )} + {ad.targetUrl && ( +{ad.targetUrl}
+ )} +Only super-admins can manage roles
+No moderators yet
+ ) : ( +{mod.ownerId}
+Pet: {pet.name}
} ++ Granted {new Date(mod.grantedAt).toLocaleDateString()} + {mod.grantedBy && ` by ${mod.grantedBy.slice(0, 12)}…`} +
+Platform overview
+{value.toLocaleString()}
+{label}
++ {post.caption ?? No caption} +
++ {post.pet.name} · {post._count.reactions} 🐾 · {post._count.comments} 💬 + {post._count.reports > 0 && ( + + ⚑ {post._count.reports} report{post._count.reports !== 1 ? "s" : ""} + + )} +
+{user.id}
+ {user.adminRole && ( ++ {user._count.pets} pet{user._count.pets !== 1 ? "s" : ""} + {user.pets.length > 0 && " · " + user.pets.map((p) => p.name).join(", ")} +
+ {user.ban?.reason && ( +Reason: {user.ban.reason}
+ )} +{ad.title}
+ {ad.caption && ( +{ad.caption}
+ )} +