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:
1 parent
f638239711
commit
efdf407642
5 files changed
+187
-15
No files matched your search
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user