diff --git a/messages/de.json b/messages/de.json index 36dbf5e..8bb2219 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" } } diff --git a/messages/en.json b/messages/en.json index fbe049a..697af95 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" } } diff --git a/mobile/app/(app)/pets/[petId]/index.tsx b/mobile/app/(app)/pets/[petId]/index.tsx index a4af221..5a3e247 100644 --- a/mobile/app/(app)/pets/[petId]/index.tsx +++ b/mobile/app/(app)/pets/[petId]/index.tsx @@ -145,8 +145,9 @@ export default function PetProfileScreen() { {formatPetName(pet)} - + + {pet.isFoundingMember && {t("foundingMember.tooltip")}} {pet.species.name} {pet.invitedByPet && ( @@ -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" }, diff --git a/mobile/src/components/PetIdentityBadges.tsx b/mobile/src/components/PetIdentityBadges.tsx index 5a243ea..1685814 100644 --- a/mobile/src/components/PetIdentityBadges.tsx +++ b/mobile/src/components/PetIdentityBadges.tsx @@ -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 ( {isVerified && } {showSponsor && } + {isFoundingMember && } {rank && } ); diff --git a/mobile/src/components/PostCard.tsx b/mobile/src/components/PostCard.tsx index a2841f5..047ef6c 100644 --- a/mobile/src/components/PostCard.tsx +++ b/mobile/src/components/PostCard.tsx @@ -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 {formatPetName(post.pet)} - + {isTrending && ( diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 58d2b61..8c10988 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -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" } } diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index d764229..2cb3e4a 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -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" } } diff --git a/prisma/backfill_founding_members.sql b/prisma/backfill_founding_members.sql new file mode 100644 index 0000000..e6cffe2 --- /dev/null +++ b/prisma/backfill_founding_members.sql @@ -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; diff --git a/prisma/enable_rls_curator.sql b/prisma/enable_rls_curator.sql new file mode 100644 index 0000000..cace94a --- /dev/null +++ b/prisma/enable_rls_curator.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0e7b0bc..f1a8ccc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index 27e7de9..1cb62f6 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -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(), diff --git a/src/__tests__/new-pet-welcome.test.ts b/src/__tests__/new-pet-welcome.test.ts new file mode 100644 index 0000000..b8e0014 --- /dev/null +++ b/src/__tests__/new-pet-welcome.test.ts @@ -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) => 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" }), + }); + }); +}); diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx index dbbdb77..a0e0013 100644 --- a/src/app/(app)/pets/[petId]/page.tsx +++ b/src/app/(app)/pets/[petId]/page.tsx @@ -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)} + {pet.isFoundingMember && ( +

{tFounding("tooltip")}

+ )} {pet.species.name} {pet.invitedByPet && ( diff --git a/src/app/p/[secret]/curator/page.tsx b/src/app/p/[secret]/curator/page.tsx new file mode 100644 index 0000000..2a31eb4 --- /dev/null +++ b/src/app/p/[secret]/curator/page.tsx @@ -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 ; + + return ( +
+
+

Begrüßung

+

+ Was das Kurator-Konto „PawFeed Team“ bei jedem neu angelegten Tier automatisch macht. +

+
+ +
+
+ + +
+ ); +} + +function CuratorForm({ initial }: { initial: Form }) { + const trpc = useTRPC(); + const qc = useQueryClient(); + const [form, setForm] = useState
({ + welcomeEnabled: initial.welcomeEnabled, + autoFollowEnabled: initial.autoFollowEnabled, + welcomeTextDe: initial.welcomeTextDe, + welcomeTextEn: initial.welcomeTextEn, + }); + const set = (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 ( +
+ set("autoFollowEnabled", v)} + /> + set("welcomeEnabled", v)} + /> + + set("welcomeTextDe", v)} + /> + set("welcomeTextEn", v)} + /> + +

+ {PLACEHOLDER} 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. +

+ {missingPlaceholder && ( +

Beide Texte brauchen den Platzhalter {PLACEHOLDER}.

+ )} + + +
+ ); +} + +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 ( +
+ +