feat(admin): add species/breed ad targeting (Gitea #9)

Advertisement.species existed in the schema but was never wired up —
the admin form never let you set it and the feed's ad query ignored it
entirely, showing every active ad to every pet regardless of species.

Adds a new Advertisement.breeds field alongside it and wires both
through the whole path: a targeting UI in the ad create/edit dialog
(species checkboxes, each expandable into its own breed checklist),
and a filter in admin.getActiveAds (now scoped to the viewing pet) so
cat food ads stop reaching dog owners. Empty species/breeds = shown to
everyone, unchanged from today's behavior.
This commit is contained in:
admin committed 2026-08-11 15:09:56 +02:00
1 parent f638239711
commit efdf407642
5 files changed
+187 -15

No files matched your search

+2 -1
View File
@@ -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
+50
View File
@@ -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]);
});
});
+95 -2
View File
@@ -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<string, { id: string; name: string }[]> = {};
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() {
) : (
<Badge variant="secondary" className="text-[10px]">Inaktiv</Badge>
)}
{ad.species.length > 0 && (
<Badge variant="outline" className="text-[10px] text-zinc-400 border-zinc-700">
{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" : ""}`}
</Badge>
)}
</div>
{ad.caption && (
<p className="text-xs text-zinc-500 truncate mt-0.5">{ad.caption}</p>
@@ -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. */}
<div className="space-y-2">
<label className="text-xs text-zinc-500">
Zielgruppe (leer = alle Tierarten &amp; Rassen)
</label>
<div className="space-y-2 rounded-lg border border-zinc-800 p-3">
{(speciesList ?? []).map((s) => {
const checked = form.species.includes(s.id);
const breeds = breedsBySpecies[s.id] ?? [];
return (
<div key={s.id} className="space-y-1.5">
<label className="flex items-center gap-2 text-sm text-zinc-200">
<input
type="checkbox"
checked={checked}
onChange={(e) => toggleSpecies(s.id, e.target.checked)}
className="h-4 w-4 rounded border-zinc-600 bg-zinc-800 accent-orange-500"
/>
{s.name}
</label>
{checked && breeds.length > 0 && (
<div className="ml-6 flex flex-wrap gap-x-4 gap-y-1.5">
{breeds.map((b) => (
<label
key={b.id}
className="flex items-center gap-1.5 text-xs text-zinc-400"
>
<input
type="checkbox"
checked={form.breeds.includes(b.id)}
onChange={(e) => toggleBreed(b.id, e.target.checked)}
className="h-3.5 w-3.5 rounded border-zinc-600 bg-zinc-800 accent-orange-500"
/>
{b.name}
</label>
))}
</div>
)}
</div>
);
})}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs text-zinc-500">Startdatum</label>
+6 -1
View File
@@ -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]);
+34 -11
View File
@@ -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 }) => {