diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f7179f8..80d8225 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -571,7 +571,8 @@ model Advertisement { imageKey String? targetUrl String? @db.VarChar(500) isActive Boolean @default(true) - species String[] + species String[] // targeted Species ids; empty = all species (no restriction) + breeds String[] // targeted Breed ids, narrows WITHIN the species selection above; empty = no breed restriction startDate DateTime? endDate DateTime? createdBy String diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index f5cd197..0e27cbd 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -431,3 +431,53 @@ describe("admin.exportUserData", () => { expect(mockBuildDataExport).not.toHaveBeenCalled(); }); }); + +describe("admin.getActiveAds", () => { + const adUnfiltered = { id: "ad-all", species: [], breeds: [] }; + const adDogsOnly = { id: "ad-dogs", species: ["species-dog"], breeds: [] }; + const adSiameseCatsOnly = { id: "ad-siamese", species: ["species-cat"], breeds: ["breed-siamese"] }; + + it("returns [] when the pet does not exist", async () => { + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.pet.findUnique.mockResolvedValue(null); + + const result = await caller.admin.getActiveAds({ petId: "missing-pet" }); + + expect(result).toEqual([]); + }); + + it("returns untargeted ads and matching-species ads, excludes non-matching species", async () => { + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.pet.findUnique.mockResolvedValue({ speciesId: "species-dog", breedId: null }); + mockPrisma.advertisement.findMany.mockResolvedValue([adUnfiltered, adDogsOnly, adSiameseCatsOnly]); + + const result = await caller.admin.getActiveAds({ petId: "pet-dog-1" }); + + expect(result.map((a) => a.id)).toEqual(["ad-all", "ad-dogs"]); + }); + + it("only shows a breed-narrowed ad to a pet with the matching breed", async () => { + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.advertisement.findMany.mockResolvedValue([adUnfiltered, adSiameseCatsOnly]); + + mockPrisma.pet.findUnique.mockResolvedValue({ speciesId: "species-cat", breedId: "breed-siamese" }); + const matching = await caller.admin.getActiveAds({ petId: "pet-siamese" }); + expect(matching.map((a) => a.id)).toEqual(["ad-all", "ad-siamese"]); + + mockPrisma.pet.findUnique.mockResolvedValue({ speciesId: "species-cat", breedId: "breed-persian" }); + const nonMatching = await caller.admin.getActiveAds({ petId: "pet-persian" }); + expect(nonMatching.map((a) => a.id)).toEqual(["ad-all"]); + + mockPrisma.pet.findUnique.mockResolvedValue({ speciesId: "species-cat", breedId: null }); + const noBreedSet = await caller.admin.getActiveAds({ petId: "pet-mixed-cat" }); + expect(noBreedSet.map((a) => a.id)).toEqual(["ad-all"]); + }); + + it("does not require admin role — any authenticated owner can fetch ads for their pet", async () => { + const { caller, mockPrisma } = await createAdminCaller({ userId: "regular-owner" }); + mockPrisma.pet.findUnique.mockResolvedValue({ speciesId: "species-dog", breedId: null }); + mockPrisma.advertisement.findMany.mockResolvedValue([adUnfiltered]); + + await expect(caller.admin.getActiveAds({ petId: "pet-dog-1" })).resolves.toEqual([adUnfiltered]); + }); +}); diff --git a/src/app/p/[secret]/ads/page.tsx b/src/app/p/[secret]/ads/page.tsx index 9e39655..a8e2cd2 100644 --- a/src/app/p/[secret]/ads/page.tsx +++ b/src/app/p/[secret]/ads/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useRef, useCallback } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueries, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { getMediaUrl } from "@/lib/media-url"; import { Plus, Pencil, Trash2, Loader2, Check, X, ImagePlus, ImageOff, Eye, PawPrint, MessageCircle, Repeat2 } from "lucide-react"; @@ -21,6 +21,8 @@ type AdForm = { startDate: string; endDate: string; imageKey: string; + species: string[]; + breeds: string[]; }; const emptyForm: AdForm = { @@ -31,6 +33,8 @@ const emptyForm: AdForm = { startDate: "", endDate: "", imageKey: "", + species: [], + breeds: [], }; const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"] as const; @@ -51,6 +55,18 @@ export default function AdminAdsPage() { const { data: rawAds, isLoading } = useQuery(trpc.admin.listAds.queryOptions()); const ads = rawAds ?? []; + // Targeting (Gitea #9): species list is small and fixed (dogs/cats/birds — + // see CLAUDE.md v1 scope), so fetching it unconditionally is cheap. Breeds + // are only fetched for currently-checked species, one query per species. + const { data: speciesList } = useQuery(trpc.pets.listSpecies.queryOptions()); + const breedQueries = useQueries({ + queries: form.species.map((speciesId) => trpc.pets.listBreeds.queryOptions({ speciesId })), + }); + const breedsBySpecies: Record = {}; + form.species.forEach((speciesId, i) => { + breedsBySpecies[speciesId] = breedQueries[i]?.data ?? []; + }); + const invalidate = () => void qc.invalidateQueries({ queryKey: trpc.admin.listAds.queryKey() }); const getPresignedUrl = useMutation(trpc.admin.getAdImagePresignedUrl.mutationOptions()); @@ -81,6 +97,8 @@ export default function AdminAdsPage() { startDate: ad.startDate ? new Date(ad.startDate).toISOString().slice(0, 16) : "", endDate: ad.endDate ? new Date(ad.endDate).toISOString().slice(0, 16) : "", imageKey: ad.imageKey ?? "", + species: ad.species, + breeds: ad.breeds, }); setEditingId(ad.id); setImagePreview(ad.imageKey ? (getMediaUrl(ad.imageKey) ?? null) : null); @@ -103,14 +121,35 @@ export default function AdminAdsPage() { imageKey: form.imageKey || undefined, startDate: form.startDate ? new Date(form.startDate).toISOString() : undefined, endDate: form.endDate ? new Date(form.endDate).toISOString() : undefined, + species: form.species, + breeds: form.breeds, }; if (editingId) { updateAd.mutate({ id: editingId, ...payload }); } else { - createAd.mutate({ ...payload, species: [] }); + createAd.mutate(payload); } } + function toggleSpecies(speciesId: string, checked: boolean) { + setForm((f) => ({ + ...f, + species: checked ? [...f.species, speciesId] : f.species.filter((id) => id !== speciesId), + // Unchecking a species drops any breed selections that belonged to it — + // breeds only narrow WITHIN a checked species (see admin.ts getActiveAds). + breeds: checked + ? f.breeds + : f.breeds.filter((id) => !(breedsBySpecies[speciesId] ?? []).some((b) => b.id === id)), + })); + } + + function toggleBreed(breedId: string, checked: boolean) { + setForm((f) => ({ + ...f, + breeds: checked ? [...f.breeds, breedId] : f.breeds.filter((id) => id !== breedId), + })); + } + const uploadImage = useCallback( async (file: File) => { if (file.size > MAX_SIZE) { @@ -206,6 +245,14 @@ export default function AdminAdsPage() { ) : ( Inaktiv )} + {ad.species.length > 0 && ( + + {ad.species + .map((id) => speciesList?.find((s) => s.id === id)?.name ?? "?") + .join(", ")} + {ad.breeds.length > 0 && ` · ${ad.breeds.length} Rasse${ad.breeds.length !== 1 ? "n" : ""}`} + + )} {ad.caption && (

{ad.caption}

@@ -334,6 +381,52 @@ export default function AdminAdsPage() { onChange={(e) => setForm({ ...form, targetUrl: e.target.value })} className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500" /> + + {/* Targeting (Gitea #9) — species checkboxes, each expandable into + its own breed checklist. Empty selection = shown to everyone. */} +
+ +
+ {(speciesList ?? []).map((s) => { + const checked = form.species.includes(s.id); + const breeds = breedsBySpecies[s.id] ?? []; + return ( +
+ + {checked && breeds.length > 0 && ( +
+ {breeds.map((b) => ( + + ))} +
+ )} +
+ ); + })} +
+
+
diff --git a/src/components/feed/FeedList.tsx b/src/components/feed/FeedList.tsx index 1c15786..0e35a6d 100644 --- a/src/components/feed/FeedList.tsx +++ b/src/components/feed/FeedList.tsx @@ -70,7 +70,12 @@ export function FeedList() { } }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); - const { data: activeAds } = useQuery(trpc.admin.getActiveAds.queryOptions()); + const { data: activeAds } = useQuery( + trpc.admin.getActiveAds.queryOptions( + { petId: activePetId ?? "" }, + { enabled: !!activePetId } + ) + ); const ads = useMemo(() => activeAds ?? [], [activeAds]); const allPosts = useMemo(() => data?.pages.flatMap((page) => page.posts) ?? [], [data]); diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index ac7b5ee..4343081 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -642,6 +642,7 @@ export const adminRouter = router({ targetUrl: z.string().url().or(z.literal("")).optional(), isActive: z.boolean().default(true), species: z.array(z.string()).default([]), + breeds: z.array(z.string()).default([]), startDate: z.string().datetime().optional(), endDate: z.string().datetime().optional(), }) @@ -656,6 +657,7 @@ export const adminRouter = router({ targetUrl: input.targetUrl || null, isActive: input.isActive, species: input.species, + breeds: input.breeds, startDate: input.startDate ? new Date(input.startDate) : null, endDate: input.endDate ? new Date(input.endDate) : null, createdBy: ctx.userId, @@ -673,6 +675,7 @@ export const adminRouter = router({ targetUrl: z.string().url().or(z.literal("")).nullable().optional(), isActive: z.boolean().optional(), species: z.array(z.string()).optional(), + breeds: z.array(z.string()).optional(), startDate: z.string().datetime().nullable().optional(), endDate: z.string().datetime().nullable().optional(), }) @@ -699,17 +702,37 @@ export const adminRouter = router({ 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" }, - }); - }), + // Species/breed targeting (Gitea #9): species is the primary filter — empty + // means "all species", a non-empty match required otherwise. breeds, if set, + // narrows WITHIN an already-matched species (e.g. "Cats" + "Siamese" shows + // only to Siamese cats, while "Cats" alone with no breeds shows to any cat). + // A pet with no breed set never matches a breed-narrowed ad. + getActiveAds: protectedProcedure + .input(z.object({ petId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const pet = await ctx.prisma.pet.findUnique({ + where: { id: input.petId }, + select: { speciesId: true, breedId: true }, + }); + if (!pet) return []; + + const now = new Date(); + const ads = await ctx.prisma.advertisement.findMany({ + where: { + isActive: true, + OR: [{ startDate: null }, { startDate: { lte: now } }], + AND: [{ OR: [{ endDate: null }, { endDate: { gte: now } }] }], + }, + orderBy: { createdAt: "desc" }, + }); + + return ads.filter((ad) => { + if (ad.species.length === 0) return true; + if (!ad.species.includes(pet.speciesId)) return false; + if (ad.breeds.length === 0) return true; + return !!pet.breedId && ad.breeds.includes(pet.breedId); + }); + }), // ─── Moderator management (super-admin only) ───────────────────────────────── listModerators: protectedProcedure.query(async ({ ctx }) => {