feat: Gründungsmitglieder (erste 250 Tiere) und Kurator-Begrüßung

- Pet.isFoundingMember: in pets.create per bewachter SQL-Anweisung für die
  ersten 250 Tiere gesetzt, nie entzogen; Abzeichen (Spross) in Feed und
  Profil, Web + App, unabhängig vom Empfehlungsprogramm
- Kurator "PawFeed Team": folgt jedem neuen Tier und schickt eine
  Begrüßungs-Direktnachricht (DE/EN nach Sprache beim Anlegen), im
  Hintergrund — blockiert das Onboarding nicht
- CuratorSettings + Admin-Seite /p/[secret]/curator: Schalter, Texte mit
  Pflicht-Platzhalter {name} und Vorschau, Zähler der Gründungsmitglieder
- prisma/backfill_founding_members.sql, prisma/enable_rls_curator.sql

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-02 20:26:43 +02:00
1 parent 9f7acf1d98
commit 307b76e8fd
23 files changed
+554 -9

No files matched your search

+4
View File
@@ -850,5 +850,9 @@
"codeLabel": "Einladungscode",
"codeHint": "Gib den Code nach der Registrierung im Onboarding ein, dann seid ihr direkt verbunden.",
"webSignUp": "Im Browser registrieren"
},
"FoundingMember": {
"label": "Gründungsmitglied",
"tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed"
}
}
+4
View File
@@ -850,5 +850,9 @@
"codeLabel": "Invite code",
"codeHint": "Enter the code during onboarding after signing up and you are connected right away.",
"webSignUp": "Sign up in the browser"
},
"FoundingMember": {
"label": "Founding member",
"tooltip": "Founding member – one of the first 250 pets on PawFeed"
}
}
+3 -1
View File
@@ -145,8 +145,9 @@ export default function PetProfileScreen() {
</ReferralAvatarRing>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(pet)}</Text>
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} referral={pet.owner} />
<PetIdentityBadges isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
</View>
{pet.isFoundingMember && <Text style={styles.foundingHint}>{t("foundingMember.tooltip")}</Text>}
<Text style={styles.species}>{pet.species.name}</Text>
{pet.invitedByPet && (
<Text style={styles.invitedBy}>
@@ -228,6 +229,7 @@ function makeStyles(colors: ColorTokens) {
name: { ...typography.title },
species: { ...typography.body, color: colors.quietGray },
invitedBy: { ...typography.body, fontSize: 12, color: colors.quietGray },
foundingHint: { ...typography.body, fontSize: 12, color: "#059669" },
invitedByName: { color: colors.ink, fontWeight: "600" },
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
stat: { alignItems: "center" },
+10 -3
View File
@@ -1,5 +1,6 @@
import { View, StyleSheet } from "react-native";
import { BadgeCheck, Sparkles } from "lucide-react-native";
import { BadgeCheck, Sparkles, Sprout } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { isActiveSponsor } from "../lib/sponsor";
import { colors } from "../theme/tokens";
import { ReferralIcon } from "../lib/referral-icons";
@@ -7,21 +8,27 @@ import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useRefe
type Props = {
isVerified?: boolean;
/** Eines der ersten 250 Tiere (Pet.isFoundingMember). */
isFoundingMember?: boolean;
sponsor?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
/** Owner-Felder referralRankId/referralPerks — Rang-Abzeichen des Empfehlungsprogramms. */
referral?: ReferralOwnerFields | null;
};
/** Port of web's src/components/ui/pet-identity-badges.tsx. */
export function PetIdentityBadges({ isVerified, sponsor, referral }: Props) {
const FOUNDING_COLOR = "#059669"; // emerald-600, wie im Web
export function PetIdentityBadges({ isVerified, isFoundingMember, sponsor, referral }: Props) {
const { t } = useTranslation();
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
const rank = useOwnerRankWithPerk(referral, "NAME_BADGE");
if (!isVerified && !showSponsor && !rank) return null;
if (!isVerified && !isFoundingMember && !showSponsor && !rank) return null;
return (
<View style={styles.row}>
{isVerified && <BadgeCheck size={14} color={colors.marigoldOrange} />}
{showSponsor && <Sparkles size={14} color={colors.marigoldOrange} />}
{isFoundingMember && <Sprout size={14} color={FOUNDING_COLOR} accessibilityLabel={t("foundingMember.label")} />}
{rank && <ReferralIcon icon={rank.icon} size={14} color={rank.color} fill={rank.color} accessibilityLabel={rank.name} />}
</View>
);
+2 -1
View File
@@ -59,6 +59,7 @@ export type PostCardPost = {
nickname?: string | null;
avatarKey?: string | null;
isVerified?: boolean;
isFoundingMember?: boolean;
owner?: {
sponsorSince: Date | string | null;
sponsorPeriodEnd: Date | string | null;
@@ -148,7 +149,7 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
<View style={styles.headerText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} referral={post.pet.owner} />
<PetIdentityBadges isVerified={post.pet.isVerified} isFoundingMember={post.pet.isFoundingMember} sponsor={post.pet.owner} referral={post.pet.owner} />
{isTrending && (
<View style={styles.trendingBadge}>
<Flame size={11} color={colors.marigoldDeep} fill={colors.marigoldDeep} />
+4
View File
@@ -421,5 +421,9 @@
"readOnlyExpired": "Die Story ist abgelaufen. Kommentare kannst du noch lesen, aber nicht mehr schreiben.",
"pawCount_one": "{{count}} Pfote",
"pawCount_other": "{{count}} Pfoten"
},
"foundingMember": {
"label": "Gründungsmitglied",
"tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed"
}
}
+4
View File
@@ -421,5 +421,9 @@
"readOnlyExpired": "This story has expired. You can still read comments but not add new ones.",
"pawCount_one": "{{count}} paw",
"pawCount_other": "{{count}} paws"
},
"foundingMember": {
"label": "Founding member",
"tooltip": "Founding member – one of the first 250 pets on PawFeed"
}
}
+7
View File
@@ -0,0 +1,7 @@
-- Gründungsmitglieder: alle bestehenden Tiere unter den ersten 250 (nach
-- Erstellzeitpunkt) nachträglich markieren. Neue Tiere setzt pets.create
-- selbst (src/lib/new-pet-welcome.ts). Idempotent, nach dem prisma db push
-- einmal ausführen.
UPDATE "Pet" SET "isFoundingMember" = true
WHERE "id" IN (SELECT "id" FROM "Pet" ORDER BY "createdAt" ASC LIMIT 250)
AND "isFoundingMember" = false;
+3
View File
@@ -0,0 +1,3 @@
-- Kurator-Begrüßung: neue Tabelle für den PostgREST-Zugriff sperren (siehe enable_rls.sql).
-- Nach dem prisma db push einmal ausführen.
ALTER TABLE public."CuratorSettings" ENABLE ROW LEVEL SECURITY;
+15 -1
View File
@@ -117,6 +117,19 @@ model ReferralRankAchievement {
@@index([rankId])
}
// Kurator-Konto „PawFeed Team“: Begrüßung neuer Tiere. Genau eine Zeile
// (id = 1), im Admin-Panel unter /p/[secret]/curator bearbeitbar.
// {name} im Text wird durch den Tiernamen ersetzt.
model CuratorSettings {
id Int @id @default(1)
welcomeEnabled Boolean @default(true) // Begrüßungs-Direktnachricht an jedes neue Tier
autoFollowEnabled Boolean @default(true) // Team-Tier folgt jedem neuen Tier
welcomeTextDe String @db.VarChar(1000)
welcomeTextEn String @db.VarChar(1000)
updatedAt DateTime @updatedAt
updatedBy String? // ownerId des Admins
}
// Genau eine Zeile (id = 1), im Admin-Panel bearbeitbar.
model ReferralSettings {
id Int @id @default(1)
@@ -244,6 +257,7 @@ model Pet {
adoptionStory String?
avatarKey String? // Supabase Storage object key, NOT a full URL
isVerified Boolean @default(false) // admin-granted verification checkmark (Gitea #10 permission matrix)
isFoundingMember Boolean @default(false) // eines der ersten 250 Tiere — einmalig in pets.create gesetzt (src/lib/new-pet-welcome.ts), nie wieder entzogen
dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs
followerVisibility FollowerVisibility @default(EVERYONE) // who can view this pet's followers/following lists (Gitea #33)
birthday DateTime? @db.Date // actual birthday for anniversary notifications
@@ -975,7 +989,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 | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK
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 | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK | UPDATE_CURATOR_SETTINGS
targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET | SYSTEM | MUSIC_TRACK
targetId String
reason String? @db.VarChar(500)
+11
View File
@@ -351,6 +351,11 @@ interface MockReferralSettingsClient {
upsert: MockFn;
}
interface MockCuratorSettingsClient {
findUnique: MockFn;
upsert: MockFn;
}
interface MockPrismaClient {
pet: MockPetClient;
owner: MockOwnerClient;
@@ -400,6 +405,7 @@ interface MockPrismaClient {
referralRank: MockReferralRankClient;
referralRankAchievement: MockReferralRankAchievementClient;
referralSettings: MockReferralSettingsClient;
curatorSettings: MockCuratorSettingsClient;
$transaction: MockFn;
$executeRaw: MockFn;
$disconnect: MockFn;
@@ -700,6 +706,11 @@ export function createMockPrisma(): MockPrismaClient {
findUnique: vi.fn().mockResolvedValue(null),
upsert: vi.fn(),
},
// Ohne Zeile greifen die Standardwerte (getCuratorSettings in new-pet-welcome.ts).
curatorSettings: {
findUnique: vi.fn().mockResolvedValue(null),
upsert: vi.fn(),
},
$transaction: vi.fn(),
$executeRaw: vi.fn().mockResolvedValue(0),
$disconnect: vi.fn(),
+125
View File
@@ -0,0 +1,125 @@
/**
* Gründungsmitglieder + Kurator-Begrüßung beim Anlegen eines Tiers
* (src/lib/new-pet-welcome.ts, src/trpc/routers/curator-admin.ts).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/notify", () => ({ notify: vi.fn() }));
const mockSendTeamMessage = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/team-pet", () => ({
ensureTeamPet: vi.fn().mockResolvedValue({ id: "pet-team" }),
sendTeamMessage: (...args: unknown[]) => mockSendTeamMessage(...args),
}));
const mockAssertAdmin = vi.fn();
vi.mock("@/trpc/routers/admin", () => ({ assertAdmin: (...args: unknown[]) => mockAssertAdmin(...args) }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
import { notify } from "@/lib/notify";
import { FOUNDING_MEMBER_LIMIT, markFoundingMember, renderWelcomeText, welcomeNewPet } from "@/lib/new-pet-welcome";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const asDb = (m: ReturnType<typeof createMockPrisma>) => m as any;
beforeEach(() => {
vi.clearAllMocks();
mockAssertAdmin.mockResolvedValue("SUPER_ADMIN");
});
describe("renderWelcomeText", () => {
it("replaces every {name} with the pet's name", () => {
expect(renderWelcomeText("Hallo {name}! Schön, dass {name} da ist.", "Luna")).toBe("Hallo Luna! Schön, dass Luna da ist.");
});
});
describe("markFoundingMember", () => {
it("uses one guarded SQL update with the 250 limit", async () => {
const prisma = createMockPrisma();
prisma.$executeRaw.mockResolvedValue(1);
expect(await markFoundingMember(asDb(prisma), "pet-1")).toBe(true);
const values = prisma.$executeRaw.mock.calls[0].slice(1);
expect(values).toContain("pet-1");
expect(values).toContain(FOUNDING_MEMBER_LIMIT);
expect(FOUNDING_MEMBER_LIMIT).toBe(250);
});
it("reports false once the 250 places are taken", async () => {
const prisma = createMockPrisma();
prisma.$executeRaw.mockResolvedValue(0);
expect(await markFoundingMember(asDb(prisma), "pet-251")).toBe(false);
});
});
describe("welcomeNewPet", () => {
it("follows the new pet as the team and sends a German welcome with the pet's name", async () => {
const prisma = createMockPrisma();
prisma.follow.upsert.mockResolvedValue({});
await welcomeNewPet(asDb(prisma), { id: "pet-luna", name: "Luna" }, "de");
expect(prisma.follow.upsert).toHaveBeenCalledWith(
expect.objectContaining({ create: { followerPetId: "pet-team", followeePetId: "pet-luna" } })
);
expect(notify).toHaveBeenCalledWith(expect.anything(), { recipientPetId: "pet-luna", type: "FOLLOW", actorPetId: "pet-team" });
expect(mockSendTeamMessage).toHaveBeenCalledWith(expect.anything(), "pet-luna", expect.stringContaining("Hallo Luna"));
});
it("uses the English text for English users", async () => {
const prisma = createMockPrisma();
prisma.follow.upsert.mockResolvedValue({});
await welcomeNewPet(asDb(prisma), { id: "pet-max", name: "Max" }, "en");
expect(mockSendTeamMessage).toHaveBeenCalledWith(expect.anything(), "pet-max", expect.stringContaining("Hi Max"));
});
it("respects the admin switches and the edited text", async () => {
const prisma = createMockPrisma();
prisma.curatorSettings.findUnique.mockResolvedValue({
welcomeEnabled: true,
autoFollowEnabled: false,
welcomeTextDe: "Servus {name}!",
welcomeTextEn: "Hey {name}!",
});
await welcomeNewPet(asDb(prisma), { id: "pet-kiki", name: "Kiki" }, "de");
expect(prisma.follow.upsert).not.toHaveBeenCalled();
expect(mockSendTeamMessage).toHaveBeenCalledWith(expect.anything(), "pet-kiki", "Servus Kiki!");
});
it("never welcomes the team pet itself", async () => {
const prisma = createMockPrisma();
await welcomeNewPet(asDb(prisma), { id: "pet-team", name: "PawFeed Team" }, "de");
expect(prisma.follow.upsert).not.toHaveBeenCalled();
expect(mockSendTeamMessage).not.toHaveBeenCalled();
});
});
describe("curatorAdmin.update", () => {
async function caller(prisma = createMockPrisma()) {
const { curatorAdminRouter } = await import("@/trpc/routers/curator-admin");
const { initTRPC } = await import("@trpc/server");
const t = initTRPC.context<{ userId: string; prisma: unknown; ip: string }>().create();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { c: t.router({ curatorAdmin: curatorAdminRouter }).createCaller({ userId: "admin-1", prisma: prisma as any, ip: "127.0.0.1" } as any), prisma };
}
const valid = { welcomeEnabled: true, autoFollowEnabled: true, welcomeTextDe: "Hallo {name}", welcomeTextEn: "Hi {name}" };
it("rejects a text without the {name} placeholder", async () => {
const { c } = await caller();
await expect(c.curatorAdmin.update({ ...valid, welcomeTextDe: "Hallo du" })).rejects.toThrow("{name}");
});
it("requires SUPER_ADMIN and writes the audit log", async () => {
const { c, prisma } = await caller();
prisma.$transaction.mockResolvedValue([{}]);
await c.curatorAdmin.update(valid);
expect(mockAssertAdmin).toHaveBeenCalledWith(expect.anything(), "admin-1", "SUPER_ADMIN");
expect(prisma.moderationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "UPDATE_CURATOR_SETTINGS" }),
});
});
});
+5
View File
@@ -33,6 +33,7 @@ export default async function PetProfilePage({ params }: PageProps) {
const { petId } = await params;
const t = await getTranslations("PetProfile");
const tFounding = await getTranslations("FoundingMember");
const trpc = await createTRPCCaller();
let pet;
@@ -77,12 +78,16 @@ export default async function PetProfilePage({ params }: PageProps) {
{formatPetName(pet)}
<PetIdentityBadges
isVerified={pet.isVerified}
isFoundingMember={pet.isFoundingMember}
sponsor={pet.owner}
referral={pet.owner}
size="lg"
verifiedLabel={t("verified")}
/>
</h1>
{pet.isFoundingMember && (
<p className="text-xs text-emerald-700 dark:text-emerald-400">{tFounding("tooltip")}</p>
)}
<Badge variant="secondary">{pet.species.name}</Badge>
{pet.invitedByPet && (
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Sprout } from "lucide-react";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
const PLACEHOLDER = "{name}";
const PREVIEW_NAME = "Bubi";
type Form = {
welcomeEnabled: boolean;
autoFollowEnabled: boolean;
welcomeTextDe: string;
welcomeTextEn: string;
};
/**
* Begrüßung neuer Tiere durch das Kurator-Tier „PawFeed Team“ und Stand der
* Gründungsmitglieder. Ändern dürfen nur SUPER_ADMINs (curator-admin.ts).
*/
export default function AdminCuratorPage() {
const trpc = useTRPC();
const { data, isLoading } = useQuery(trpc.curatorAdmin.get.queryOptions());
if (isLoading || !data) return <Loader2 className="mx-auto my-12 h-5 w-5 animate-spin text-muted-foreground" />;
return (
<div className="max-w-2xl space-y-8">
<div>
<h1 className="text-2xl font-semibold">Begrüßung</h1>
<p className="text-sm text-muted-foreground">
Was das Kurator-Konto „PawFeed Team“ bei jedem neu angelegten Tier automatisch macht.
</p>
</div>
<section className="flex items-center gap-4 rounded-xl border p-4">
<Sprout className="h-6 w-6 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden="true" />
<div className="flex-1">
<p className="font-medium">Gründungsmitglieder</p>
<p className="text-sm text-muted-foreground">Die ersten {data.foundingLimit} Tiere bekommen das Abzeichen automatisch und dauerhaft.</p>
</div>
<p className="font-mono text-xl font-semibold tabular-nums">
{data.foundingCount} / {data.foundingLimit}
</p>
</section>
<CuratorForm key={String(data.settings.updatedAt)} initial={data.settings} />
</div>
);
}
function CuratorForm({ initial }: { initial: Form }) {
const trpc = useTRPC();
const qc = useQueryClient();
const [form, setForm] = useState<Form>({
welcomeEnabled: initial.welcomeEnabled,
autoFollowEnabled: initial.autoFollowEnabled,
welcomeTextDe: initial.welcomeTextDe,
welcomeTextEn: initial.welcomeTextEn,
});
const set = <K extends keyof Form>(key: K, value: Form[K]) => setForm((f) => ({ ...f, [key]: value }));
const save = useMutation(
trpc.curatorAdmin.update.mutationOptions({
onSuccess: () => {
toast.success("Begrüßung gespeichert");
void qc.invalidateQueries(trpc.curatorAdmin.get.queryFilter());
},
onError: (err) => toast.error(err.message),
})
);
const missingPlaceholder = !form.welcomeTextDe.includes(PLACEHOLDER) || !form.welcomeTextEn.includes(PLACEHOLDER);
return (
<div className="space-y-4">
<ToggleRow
title="Team folgt jedem neuen Tier"
hint="Das neue Tier bekommt „PawFeed Team“ als ersten Follower und eine Benachrichtigung."
checked={form.autoFollowEnabled}
onChange={(v) => set("autoFollowEnabled", v)}
/>
<ToggleRow
title="Begrüßungsnachricht senden"
hint="Direktnachricht vom Team an das neue Tier. Antworten landen unter „Messages“ im Team-Postfach."
checked={form.welcomeEnabled}
onChange={(v) => set("welcomeEnabled", v)}
/>
<TextBlock
id="welcome-de"
label="Begrüßungstext Deutsch"
value={form.welcomeTextDe}
onChange={(v) => set("welcomeTextDe", v)}
/>
<TextBlock
id="welcome-en"
label="Begrüßungstext Englisch"
value={form.welcomeTextEn}
onChange={(v) => set("welcomeTextEn", v)}
/>
<p className="text-xs text-muted-foreground">
<code className="rounded bg-muted px-1">{PLACEHOLDER}</code> wird durch den Namen des neuen Tiers ersetzt und muss in
beiden Texten vorkommen. Welche Sprache ein Tier bekommt, richtet sich nach der App- bzw. Seitensprache beim Anlegen.
</p>
{missingPlaceholder && (
<p className="text-sm text-destructive">Beide Texte brauchen den Platzhalter {PLACEHOLDER}.</p>
)}
<Button onClick={() => save.mutate(form)} disabled={save.isPending || missingPlaceholder}>
{save.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
</div>
);
}
function TextBlock({ id, label, value, onChange }: { id: string; label: string; value: string; onChange: (v: string) => void }) {
const preview = value.split(PLACEHOLDER).join(PREVIEW_NAME);
return (
<div className="space-y-2">
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
<Textarea id={id} value={value} maxLength={1000} rows={4} onChange={(e) => onChange(e.target.value)} />
<div className="rounded-lg border border-dashed bg-muted/40 p-3 text-sm">
<span className="block text-xs uppercase tracking-wide text-muted-foreground">Vorschau für „{PREVIEW_NAME}“</span>
<span className="whitespace-pre-wrap">{preview}</span>
</div>
</div>
);
}
function ToggleRow({ title, hint, checked, onChange }: { title: string; hint: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center justify-between gap-4 rounded-xl border p-4">
<span className="text-sm">
<span className="font-medium">{title}</span>
<span className="block text-xs text-muted-foreground">{hint}</span>
</span>
<Switch checked={checked} onCheckedChange={onChange} />
</label>
);
}
+2
View File
@@ -22,6 +22,7 @@ import {
Radio,
Music,
Gift,
Sprout,
} from "lucide-react";
interface AdminLayoutProps {
@@ -73,6 +74,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
{ href: `${base}/log`, label: "Audit Log", Icon: ScrollText },
{ href: `${base}/verification`, label: "Verification", Icon: BadgeCheck },
{ href: `${base}/referrals`, label: "Empfehlungen", Icon: Gift },
{ href: `${base}/curator`, label: "Begrüßung", Icon: Sprout },
{ href: `${base}/blacklist`, label: "Blacklist", Icon: Ban },
]
: []),
+2 -1
View File
@@ -81,6 +81,7 @@ type PostPet = {
name: string;
avatarKey: string | null;
isVerified?: boolean;
isFoundingMember?: boolean;
owner?: {
sponsorSince: Date | string | null;
sponsorPeriodEnd: Date | string | null;
@@ -253,7 +254,7 @@ function PostCardImpl({ post, onDeleted, variant = "feed" }: PostCardProps) {
<div className="flex-1 min-w-0">
<p className="flex items-center gap-1 text-base font-semibold text-foreground leading-tight truncate">
{formatPetName(pet)}
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} referral={pet.owner} />
<PetIdentityBadges isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
</p>
<p className="text-xs text-muted-foreground">
{formatRelativeTime(createdAt)}
@@ -0,0 +1,17 @@
"use client";
import { Sprout } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
const ICON_SIZE = { sm: "h-3 w-3", md: "h-3.5 w-3.5", lg: "h-4 w-4" } as const;
/** Gründungsmitglied — eines der ersten 250 Tiere (Pet.isFoundingMember). */
export function FoundingMemberBadge({ size = "md" }: { size?: keyof typeof ICON_SIZE }) {
const t = useTranslations("FoundingMember");
return (
<span title={t("tooltip")} className="inline-flex shrink-0">
<Sprout className={cn(ICON_SIZE[size], "text-emerald-600 dark:text-emerald-400")} aria-label={t("label")} />
</span>
);
}
+6 -1
View File
@@ -2,6 +2,7 @@ import { BadgeCheck, Sparkles } from "lucide-react";
import { isActiveSponsor } from "@/lib/sponsor";
import { cn } from "@/lib/utils";
import { ReferralRankBadge, type ReferralOwnerFields } from "@/components/referrals/ReferralRankBadge";
import { FoundingMemberBadge } from "@/components/ui/founding-member-badge";
type SponsorWindow = {
sponsorSince: Date | string | null;
@@ -10,6 +11,8 @@ type SponsorWindow = {
type Props = {
isVerified?: boolean;
/** Eines der ersten 250 Tiere (Pet.isFoundingMember). */
isFoundingMember?: boolean;
/** Omit entirely (rather than passing null) when the caller has no owner/sponsor data to show. */
sponsor?: SponsorWindow | null;
/** Owner-Felder referralRankId/referralPerks (petCardSelect.owner) — Rang-Abzeichen des Empfehlungsprogramms. */
@@ -39,6 +42,7 @@ function isSponsorActive(sponsor: SponsorWindow): boolean {
*/
export function PetIdentityBadges({
isVerified,
isFoundingMember,
sponsor,
referral,
size = "md",
@@ -48,7 +52,7 @@ export function PetIdentityBadges({
const iconClass = ICON_SIZE[size];
const showSponsor = sponsor != null && isSponsorActive(sponsor);
if (!isVerified && !showSponsor && !referral?.referralRankId) return null;
if (!isVerified && !isFoundingMember && !showSponsor && !referral?.referralRankId) return null;
return (
<>
@@ -64,6 +68,7 @@ export function PetIdentityBadges({
aria-label={sponsorLabel}
/>
)}
{isFoundingMember && <FoundingMemberBadge size={size} />}
<ReferralRankBadge owner={referral} size={size} />
</>
);
+95
View File
@@ -0,0 +1,95 @@
import "server-only";
import type { PrismaClient } from "@prisma/client";
import { ensureTeamPet, sendTeamMessage } from "@/lib/team-pet";
import { notify } from "@/lib/notify";
/**
* Was beim Anlegen eines neuen Tiers automatisch passiert (aufgerufen aus
* pets.create, nie blockierend):
* 1. Gründungsmitglied, wenn es eines der ersten FOUNDING_MEMBER_LIMIT Tiere ist.
* 2. Das Kurator-Tier „PawFeed Team“ folgt dem neuen Tier.
* 3. Das Kurator-Tier schickt eine Begrüßungs-Direktnachricht.
* 2 und 3 sind im Admin-Panel abschaltbar, der Text ist dort bearbeitbar
* (CuratorSettings). {name} im Text wird durch den Tiernamen ersetzt.
*/
export const FOUNDING_MEMBER_LIMIT = 250;
export const WELCOME_NAME_PLACEHOLDER = "{name}";
export const DEFAULT_WELCOME_TEXT_DE =
"Hallo {name}, willkommen bei PawFeed! Schön, dass du da bist. Zeig uns gern dein erstes Foto, die Community freut sich auf dich. Fragen oder Ideen? Schreib uns einfach hier zurück. Dein PawFeed-Team";
export const DEFAULT_WELCOME_TEXT_EN =
"Hi {name}, welcome to PawFeed! We are happy to have you here. Share your first photo, the community is looking forward to meeting you. Questions or ideas? Just reply to this message. Your PawFeed team";
export type CuratorSettingsValues = {
welcomeEnabled: boolean;
autoFollowEnabled: boolean;
welcomeTextDe: string;
welcomeTextEn: string;
};
export async function getCuratorSettings(prisma: Pick<PrismaClient, "curatorSettings">): Promise<CuratorSettingsValues> {
const row = await prisma.curatorSettings.findUnique({ where: { id: 1 } });
return (
row ?? {
welcomeEnabled: true,
autoFollowEnabled: true,
welcomeTextDe: DEFAULT_WELCOME_TEXT_DE,
welcomeTextEn: DEFAULT_WELCOME_TEXT_EN,
}
);
}
/** Setzt den Tiernamen für jeden {name}-Platzhalter ein. */
export function renderWelcomeText(template: string, petName: string): string {
return template.split(WELCOME_NAME_PLACEHOLDER).join(petName);
}
/**
* Markiert das Tier als Gründungsmitglied, wenn höchstens FOUNDING_MEMBER_LIMIT
* Tiere (inklusive diesem) bis zu seinem Erstellzeitpunkt existieren. In einer
* SQL-Anweisung, damit zwei gleichzeitig angelegte Tiere nicht beide den
* letzten Platz bekommen. Einmal gesetzt, wird der Status nie entzogen.
*/
export async function markFoundingMember(prisma: Pick<PrismaClient, "$executeRaw">, petId: string): Promise<boolean> {
const updated = await prisma.$executeRaw`
UPDATE "Pet" SET "isFoundingMember" = true
WHERE "id" = ${petId}
AND (
SELECT count(*) FROM "Pet" p
WHERE p."createdAt" <= (SELECT "createdAt" FROM "Pet" WHERE "id" = ${petId})
) <= ${FOUNDING_MEMBER_LIMIT}`;
return updated > 0;
}
export async function welcomeNewPet(
prisma: PrismaClient,
pet: { id: string; name: string },
locale: string
): Promise<void> {
await markFoundingMember(prisma, pet.id).catch((err) => console.error("[welcome] founding member failed", err));
const settings = await getCuratorSettings(prisma);
if (!settings.welcomeEnabled && !settings.autoFollowEnabled) return;
const teamPet = await ensureTeamPet(prisma);
if (teamPet.id === pet.id) return;
if (settings.autoFollowEnabled) {
await prisma.follow
.upsert({
where: { followerPetId_followeePetId: { followerPetId: teamPet.id, followeePetId: pet.id } },
create: { followerPetId: teamPet.id, followeePetId: pet.id },
update: {},
})
.then(() => notify(prisma, { recipientPetId: pet.id, type: "FOLLOW", actorPetId: teamPet.id }))
.catch((err) => console.error("[welcome] curator follow failed", err));
}
if (settings.welcomeEnabled) {
const template = locale.startsWith("de") ? settings.welcomeTextDe : settings.welcomeTextEn;
await sendTeamMessage(prisma, pet.id, renderWelcomeText(template, pet.name)).catch((err) =>
console.error("[welcome] welcome message failed", err)
);
}
}
+1
View File
@@ -21,6 +21,7 @@ export const petIdentitySelect = {
export const petCardSelect = {
...petIdentitySelect,
isVerified: true,
isFoundingMember: true,
owner: { select: { sponsorSince: true, sponsorPeriodEnd: true, referralRankId: true, referralPerks: true } },
} satisfies Prisma.PetSelect;
+2
View File
@@ -27,6 +27,7 @@ import { broadcastRouter } from "@/trpc/routers/broadcast";
import { insightsRouter } from "@/trpc/routers/insights";
import { referralsRouter } from "@/trpc/routers/referrals";
import { referralAdminRouter } from "@/trpc/routers/referral-admin";
import { curatorAdminRouter } from "@/trpc/routers/curator-admin";
export const appRouter = router({
pets: petsRouter,
@@ -58,6 +59,7 @@ export const appRouter = router({
insights: insightsRouter,
referrals: referralsRouter,
referralAdmin: referralAdminRouter,
curatorAdmin: curatorAdminRouter,
});
// Export the type for use in the client
+73
View File
@@ -0,0 +1,73 @@
import "server-only";
import { z } from "zod";
import { AdminRoleType } from "@prisma/client";
import { router, protectedProcedure } from "@/trpc/init";
import { assertAdmin } from "@/trpc/routers/admin";
import {
DEFAULT_WELCOME_TEXT_DE,
DEFAULT_WELCOME_TEXT_EN,
FOUNDING_MEMBER_LIMIT,
WELCOME_NAME_PLACEHOLDER,
} from "@/lib/new-pet-welcome";
/**
* Admin-Seite „Begrüßung“ (/p/[secret]/curator): Begrüßungsnachricht und
* Auto-Follow des Kurator-Tiers „PawFeed Team“, dazu der Stand der
* Gründungsmitglieder. Lesen ab MODERATOR, Ändern nur SUPER_ADMIN.
*/
const welcomeText = z
.string()
.trim()
.min(1)
.max(1000)
.refine((text) => text.includes(WELCOME_NAME_PLACEHOLDER), {
message: `Der Text muss den Platzhalter ${WELCOME_NAME_PLACEHOLDER} für den Tiernamen enthalten.`,
});
export const curatorAdminRouter = router({
get: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const [settings, foundingCount] = await Promise.all([
ctx.prisma.curatorSettings.upsert({
where: { id: 1 },
create: { id: 1, welcomeTextDe: DEFAULT_WELCOME_TEXT_DE, welcomeTextEn: DEFAULT_WELCOME_TEXT_EN },
update: {},
}),
ctx.prisma.pet.count({ where: { isFoundingMember: true } }),
]);
return { settings, foundingCount, foundingLimit: FOUNDING_MEMBER_LIMIT };
}),
update: protectedProcedure
.input(
z.object({
welcomeEnabled: z.boolean(),
autoFollowEnabled: z.boolean(),
welcomeTextDe: welcomeText,
welcomeTextEn: welcomeText,
})
)
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
const [settings] = await ctx.prisma.$transaction([
ctx.prisma.curatorSettings.upsert({
where: { id: 1 },
create: { id: 1, ...input, updatedBy: ctx.userId },
update: { ...input, updatedBy: ctx.userId },
}),
ctx.prisma.moderationLog.create({
data: {
action: "UPDATE_CURATOR_SETTINGS",
targetType: "SYSTEM",
targetId: "curator-settings",
reason: `welcome=${input.welcomeEnabled} follow=${input.autoFollowEnabled}`,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return settings;
}),
});
+10 -1
View File
@@ -4,6 +4,7 @@ import { router, protectedProcedure } from "@/trpc/init";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { pickLocalizedName } from "@/lib/localized-name";
import { petCardSelect } from "@/repositories/pet-repository";
import { welcomeNewPet } from "@/lib/new-pet-welcome";
export const petsRouter = router({
/**
@@ -32,7 +33,7 @@ export const petsRouter = router({
create: { id: ctx.userId, registrationIp: ctx.ip },
});
return ctx.prisma.pet.create({
const pet = await ctx.prisma.pet.create({
data: {
ownerId: ctx.userId,
name: input.name,
@@ -43,6 +44,14 @@ export const petsRouter = router({
adoptionStory: input.adoptionStory ?? null,
},
});
// Gründungsmitglied, Kurator-Follow und Begrüßungsnachricht — im
// Hintergrund, damit das Onboarding nicht auf Follow/DM warten muss.
void welcomeNewPet(ctx.prisma, { id: pet.id, name: pet.name }, ctx.locale).catch((err) =>
console.error("[pets] welcome failed", err)
);
return pet;
}),
/**