feat(insights): add owner-facing post/story analytics (Gitea #30)
New insights router aggregates the reactionCount/commentCount counters (Post pre-existing, Story added in #29) into a small per-pet dashboard — total paws/comments received and which post/story is resonating most. Reachable from the owner's own pet profile ("Meine Tiere"), owner-only.
This commit is contained in:
@@ -228,6 +228,26 @@
|
||||
"postDeleted": "Beitrag gelöscht",
|
||||
"deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuche es erneut."
|
||||
},
|
||||
"Insights": {
|
||||
"pageTitle": "Statistik — {petName}",
|
||||
"postsSection": "Beiträge",
|
||||
"storiesSection": "Storys",
|
||||
"statPostReactions": "Pfoten erhalten",
|
||||
"statPostComments": "Kommentare erhalten",
|
||||
"statStoryReactions": "Pfoten erhalten",
|
||||
"statStoryComments": "Kommentare erhalten",
|
||||
"mostLikedPost": "Meistgelikter Beitrag",
|
||||
"mostCommentedPost": "Meistkommentierter Beitrag",
|
||||
"mostLikedStory": "Meistgelikte Story",
|
||||
"mostCommentedStory": "Meistkommentierte Story",
|
||||
"pawsCountSingular": "1 Pfote",
|
||||
"pawsCountPlural": "{count} Pfoten",
|
||||
"commentsCountSingular": "1 Kommentar",
|
||||
"commentsCountPlural": "{count} Kommentare",
|
||||
"noPostsYet": "Noch keine Reaktionen",
|
||||
"noActiveStoriesYet": "Keine aktive Story mit Reaktionen",
|
||||
"storiesExpireHint": "Storys verschwinden nach 24 Stunden aus der Statistik."
|
||||
},
|
||||
"Comments": {
|
||||
"title": "Kommentare",
|
||||
"justNow": "jetzt",
|
||||
@@ -581,6 +601,7 @@
|
||||
"PetProfile": {
|
||||
"editProfile": "Profil bearbeiten",
|
||||
"health": "Gesundheit",
|
||||
"insights": "Statistik",
|
||||
"managedBy": "Verwaltet von @{username}",
|
||||
"statsPosts": "Posts",
|
||||
"statsFollowers": "Follower",
|
||||
|
||||
@@ -228,6 +228,26 @@
|
||||
"postDeleted": "Post deleted",
|
||||
"deleteError": "Couldn't delete post. Please try again."
|
||||
},
|
||||
"Insights": {
|
||||
"pageTitle": "Insights — {petName}",
|
||||
"postsSection": "Posts",
|
||||
"storiesSection": "Stories",
|
||||
"statPostReactions": "Paws received",
|
||||
"statPostComments": "Comments received",
|
||||
"statStoryReactions": "Paws received",
|
||||
"statStoryComments": "Comments received",
|
||||
"mostLikedPost": "Most liked post",
|
||||
"mostCommentedPost": "Most commented post",
|
||||
"mostLikedStory": "Most liked story",
|
||||
"mostCommentedStory": "Most commented story",
|
||||
"pawsCountSingular": "1 paw",
|
||||
"pawsCountPlural": "{count} paws",
|
||||
"commentsCountSingular": "1 comment",
|
||||
"commentsCountPlural": "{count} comments",
|
||||
"noPostsYet": "No reactions yet",
|
||||
"noActiveStoriesYet": "No active story with reactions",
|
||||
"storiesExpireHint": "Stories drop out of insights 24 hours after posting."
|
||||
},
|
||||
"Comments": {
|
||||
"title": "Comments",
|
||||
"justNow": "now",
|
||||
@@ -581,6 +601,7 @@
|
||||
"PetProfile": {
|
||||
"editProfile": "Edit profile",
|
||||
"health": "Health",
|
||||
"insights": "Insights",
|
||||
"managedBy": "Managed by @{username}",
|
||||
"statsPosts": "posts",
|
||||
"statsFollowers": "followers",
|
||||
|
||||
@@ -72,6 +72,7 @@ interface MockPostClient {
|
||||
update: MockFn;
|
||||
delete: MockFn;
|
||||
count: MockFn;
|
||||
aggregate: MockFn;
|
||||
}
|
||||
|
||||
interface MockPostImageClient {
|
||||
@@ -95,10 +96,12 @@ interface MockStoryClient {
|
||||
create: MockFn;
|
||||
findMany: MockFn;
|
||||
findUnique: MockFn;
|
||||
findFirst: MockFn;
|
||||
update: MockFn;
|
||||
delete: MockFn;
|
||||
count: MockFn;
|
||||
deleteMany: MockFn;
|
||||
aggregate: MockFn;
|
||||
}
|
||||
|
||||
interface MockStoryViewClient {
|
||||
@@ -392,6 +395,7 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
aggregate: vi.fn(),
|
||||
},
|
||||
postImage: {
|
||||
create: vi.fn(),
|
||||
@@ -412,10 +416,12 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
aggregate: vi.fn(),
|
||||
},
|
||||
storyView: {
|
||||
upsert: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Insights Unit Tests — Gitea #30 ("Nutzer Daten erweitern")
|
||||
*
|
||||
* Covers: insights.getPetInsights
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@clerk/nextjs/server", () => ({
|
||||
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
import { insightsRouter } from "@/trpc/routers/insights";
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
|
||||
function createCaller(overrides?: {
|
||||
userId?: string;
|
||||
prisma?: ReturnType<typeof createMockPrisma>;
|
||||
}) {
|
||||
const mockPrisma = overrides?.prisma ?? createMockPrisma();
|
||||
const ctx = {
|
||||
userId: overrides?.userId ?? "owner-1",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma: mockPrisma as any,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { caller: insightsRouter.createCaller(ctx as any), mockPrisma };
|
||||
}
|
||||
|
||||
describe("insights.getPetInsights", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("aggregates post/story totals and returns the top post/story", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||
mockPrisma.post.aggregate.mockResolvedValue({
|
||||
_sum: { reactionCount: 12, commentCount: 4 },
|
||||
_count: { _all: 3 },
|
||||
});
|
||||
mockPrisma.story.aggregate.mockResolvedValue({
|
||||
_sum: { reactionCount: 5, commentCount: 1 },
|
||||
_count: { _all: 2 },
|
||||
});
|
||||
const topPostByReactions = { id: "post-1", reactionCount: 10, commentCount: 2, images: [] };
|
||||
const topPostByComments = { id: "post-2", reactionCount: 2, commentCount: 4, images: [] };
|
||||
const topStoryByReactions = { id: "story-1", reactionCount: 5, commentCount: 0 };
|
||||
const topStoryByComments = { id: "story-2", reactionCount: 1, commentCount: 1 };
|
||||
mockPrisma.post.findFirst
|
||||
.mockResolvedValueOnce(topPostByReactions)
|
||||
.mockResolvedValueOnce(topPostByComments);
|
||||
mockPrisma.story.findFirst
|
||||
.mockResolvedValueOnce(topStoryByReactions)
|
||||
.mockResolvedValueOnce(topStoryByComments);
|
||||
|
||||
const result = await caller.getPetInsights({ petId: "pet-1" });
|
||||
|
||||
expect(result).toEqual({
|
||||
totalPosts: 3,
|
||||
totalPostReactions: 12,
|
||||
totalPostComments: 4,
|
||||
totalActiveStories: 2,
|
||||
totalStoryReactions: 5,
|
||||
totalStoryComments: 1,
|
||||
topPostByReactions,
|
||||
topPostByComments,
|
||||
topStoryByReactions,
|
||||
topStoryByComments,
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes story queries to non-expired stories only", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||
mockPrisma.post.aggregate.mockResolvedValue({
|
||||
_sum: { reactionCount: 0, commentCount: 0 },
|
||||
_count: { _all: 0 },
|
||||
});
|
||||
mockPrisma.story.aggregate.mockResolvedValue({
|
||||
_sum: { reactionCount: 0, commentCount: 0 },
|
||||
_count: { _all: 0 },
|
||||
});
|
||||
mockPrisma.post.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.story.findFirst.mockResolvedValue(null);
|
||||
|
||||
await caller.getPetInsights({ petId: "pet-1" });
|
||||
|
||||
expect(mockPrisma.story.aggregate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
petId: "pet-1",
|
||||
expiresAt: expect.objectContaining({ gt: expect.any(Date) }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null spotlight fields when nothing has been reacted to or commented on", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||
mockPrisma.post.aggregate.mockResolvedValue({
|
||||
_sum: { reactionCount: null, commentCount: null },
|
||||
_count: { _all: 0 },
|
||||
});
|
||||
mockPrisma.story.aggregate.mockResolvedValue({
|
||||
_sum: { reactionCount: null, commentCount: null },
|
||||
_count: { _all: 0 },
|
||||
});
|
||||
mockPrisma.post.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.story.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await caller.getPetInsights({ petId: "pet-1" });
|
||||
|
||||
expect(result.totalPostReactions).toBe(0);
|
||||
expect(result.totalStoryComments).toBe(0);
|
||||
expect(result.topPostByReactions).toBeNull();
|
||||
expect(result.topStoryByComments).toBeNull();
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN when the caller does not own the pet", async () => {
|
||||
const { caller, mockPrisma } = createCaller({ userId: "owner-2" });
|
||||
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||
|
||||
await expect(caller.getPetInsights({ petId: "pet-1" })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentUser } from "@clerk/nextjs/server";
|
||||
import { createTRPCCaller } from "@/trpc/server";
|
||||
import { InsightsDashboard } from "@/components/insights/InsightsDashboard";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ petId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea #30 — owner-only pet insights ("hinterlegt bei Meine Tiere").
|
||||
* Same ownership-gate shape as health/page.tsx.
|
||||
*/
|
||||
export default async function InsightsPage({ params }: PageProps) {
|
||||
const { petId } = await params;
|
||||
|
||||
const [user, trpc] = await Promise.all([currentUser(), createTRPCCaller()]);
|
||||
|
||||
let pet;
|
||||
try {
|
||||
pet = await trpc.pets.getProfile({ petId });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!user || pet.ownerId !== user.id) {
|
||||
redirect(`/pets/${petId}`);
|
||||
}
|
||||
|
||||
return <InsightsDashboard petId={petId} petName={pet.name} />;
|
||||
}
|
||||
@@ -103,6 +103,12 @@ export default async function PetProfilePage({ params }: PageProps) {
|
||||
>
|
||||
{t("health")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/pets/${pet.id}/insights`}
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
{t("insights")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { PawPrint, MessageCircle, ImageOff, Loader2 } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
import { BlurImage } from "@/components/ui/blur-image";
|
||||
|
||||
interface InsightsDashboardProps {
|
||||
petId: string;
|
||||
petName: string;
|
||||
}
|
||||
|
||||
function StatTile({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-lg border bg-card px-4 py-3">
|
||||
<span className="text-2xl font-semibold">{value}</span>
|
||||
<span className="text-center text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpotlightCardProps {
|
||||
title: string;
|
||||
thumbnailUrl: string | null;
|
||||
thumbnailBlurhash: string | null;
|
||||
subtitle: string | null;
|
||||
emptyLabel: string;
|
||||
}
|
||||
|
||||
function SpotlightCard({ title, thumbnailUrl, thumbnailBlurhash, subtitle, emptyLabel }: SpotlightCardProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-card p-3">
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-md bg-muted">
|
||||
{thumbnailUrl ? (
|
||||
<BlurImage
|
||||
src={thumbnailUrl}
|
||||
blurhash={thumbnailBlurhash}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">{title}</p>
|
||||
{subtitle ? (
|
||||
<p className="text-sm font-semibold truncate">{subtitle}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{emptyLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea #30 — small owner-facing analytics tool: overview of paw-reactions
|
||||
* and comments across a pet's posts + active stories, plus which post/story
|
||||
* is resonating most. Reads insights.getPetInsights (owner-only).
|
||||
*/
|
||||
export function InsightsDashboard({ petId, petName }: InsightsDashboardProps) {
|
||||
const t = useTranslations("Insights");
|
||||
const trpc = useTRPC();
|
||||
|
||||
const { data, isLoading } = useQuery(trpc.insights.getPetInsights.queryOptions({ petId }));
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const topPostImage = data.topPostByReactions?.images[0] ?? null;
|
||||
const topCommentedPostImage = data.topPostByComments?.images[0] ?? null;
|
||||
|
||||
const formatPaws = (count: number) =>
|
||||
count === 1 ? t("pawsCountSingular") : t("pawsCountPlural", { count });
|
||||
const formatComments = (count: number) =>
|
||||
count === 1 ? t("commentsCountSingular") : t("commentsCountPlural", { count });
|
||||
|
||||
return (
|
||||
<div className="max-w-[640px] mx-auto px-4 py-6">
|
||||
<h1 className="mb-6 text-xl font-semibold">{t("pageTitle", { petName })}</h1>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-muted-foreground">
|
||||
<PawPrint className="h-4 w-4" aria-hidden="true" />
|
||||
{t("postsSection")}
|
||||
</h2>
|
||||
<div className="mb-4 grid grid-cols-2 gap-3">
|
||||
<StatTile label={t("statPostReactions")} value={data.totalPostReactions} />
|
||||
<StatTile label={t("statPostComments")} value={data.totalPostComments} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<SpotlightCard
|
||||
title={t("mostLikedPost")}
|
||||
thumbnailUrl={topPostImage ? getMediaUrl(topPostImage.storageKey) : null}
|
||||
thumbnailBlurhash={topPostImage?.blurhash ?? null}
|
||||
subtitle={
|
||||
data.topPostByReactions
|
||||
? formatPaws(data.topPostByReactions.reactionCount)
|
||||
: null
|
||||
}
|
||||
emptyLabel={t("noPostsYet")}
|
||||
/>
|
||||
<SpotlightCard
|
||||
title={t("mostCommentedPost")}
|
||||
thumbnailUrl={topCommentedPostImage ? getMediaUrl(topCommentedPostImage.storageKey) : null}
|
||||
thumbnailBlurhash={topCommentedPostImage?.blurhash ?? null}
|
||||
subtitle={
|
||||
data.topPostByComments
|
||||
? formatComments(data.topPostByComments.commentCount)
|
||||
: null
|
||||
}
|
||||
emptyLabel={t("noPostsYet")}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-muted-foreground">
|
||||
<MessageCircle className="h-4 w-4" aria-hidden="true" />
|
||||
{t("storiesSection")}
|
||||
</h2>
|
||||
<div className="mb-4 grid grid-cols-2 gap-3">
|
||||
<StatTile label={t("statStoryReactions")} value={data.totalStoryReactions} />
|
||||
<StatTile label={t("statStoryComments")} value={data.totalStoryComments} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<SpotlightCard
|
||||
title={t("mostLikedStory")}
|
||||
thumbnailUrl={
|
||||
data.topStoryByReactions?.storageKey ? getMediaUrl(data.topStoryByReactions.storageKey) : null
|
||||
}
|
||||
thumbnailBlurhash={data.topStoryByReactions?.blurhash ?? null}
|
||||
subtitle={
|
||||
data.topStoryByReactions
|
||||
? formatPaws(data.topStoryByReactions.reactionCount)
|
||||
: null
|
||||
}
|
||||
emptyLabel={t("noActiveStoriesYet")}
|
||||
/>
|
||||
<SpotlightCard
|
||||
title={t("mostCommentedStory")}
|
||||
thumbnailUrl={
|
||||
data.topStoryByComments?.storageKey ? getMediaUrl(data.topStoryByComments.storageKey) : null
|
||||
}
|
||||
thumbnailBlurhash={data.topStoryByComments?.blurhash ?? null}
|
||||
subtitle={
|
||||
data.topStoryByComments
|
||||
? formatComments(data.topStoryByComments.commentCount)
|
||||
: null
|
||||
}
|
||||
emptyLabel={t("noActiveStoriesYet")}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{t("storiesExpireHint")}</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { ownerRouter } from "@/trpc/routers/owner";
|
||||
import { sponsorRouter } from "@/trpc/routers/sponsor";
|
||||
import { statsRouter } from "@/trpc/routers/stats";
|
||||
import { broadcastRouter } from "@/trpc/routers/broadcast";
|
||||
import { insightsRouter } from "@/trpc/routers/insights";
|
||||
|
||||
export const appRouter = router({
|
||||
pets: petsRouter,
|
||||
@@ -54,6 +55,7 @@ export const appRouter = router({
|
||||
sponsor: sponsorRouter,
|
||||
stats: statsRouter,
|
||||
broadcast: broadcastRouter,
|
||||
insights: insightsRouter,
|
||||
});
|
||||
|
||||
// Export the type for use in the client
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
|
||||
|
||||
/**
|
||||
* Gitea #30 — small owner-facing analytics tool ("hinterlegt bei Meine
|
||||
* Tiere"): how the pet's own posts/stories are resonating with the
|
||||
* community. Owner-only, reads the denormalized reactionCount/commentCount
|
||||
* columns (Post: pre-existing; Story: added in #29) instead of live
|
||||
* aggregation over Reaction/Comment/StoryReaction/StoryComment rows.
|
||||
*
|
||||
* Stories are scoped to expiresAt > now — an expired story's media is no
|
||||
* longer viewable anywhere else in the app, so surfacing it as "your top
|
||||
* story" here would point at dead content.
|
||||
*/
|
||||
export const insightsRouter = router({
|
||||
getPetInsights: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
|
||||
const postWhere = { petId: input.petId, hiddenAt: null };
|
||||
const storyWhere = { petId: input.petId, expiresAt: { gt: new Date() } };
|
||||
const thumbnailInclude = { images: { take: 1, orderBy: { position: "asc" as const } } };
|
||||
|
||||
const [
|
||||
postTotals,
|
||||
storyTotals,
|
||||
topPostByReactions,
|
||||
topPostByComments,
|
||||
topStoryByReactions,
|
||||
topStoryByComments,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.post.aggregate({
|
||||
where: postWhere,
|
||||
_sum: { reactionCount: true, commentCount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
ctx.prisma.story.aggregate({
|
||||
where: storyWhere,
|
||||
_sum: { reactionCount: true, commentCount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
ctx.prisma.post.findFirst({
|
||||
where: { ...postWhere, reactionCount: { gt: 0 } },
|
||||
orderBy: { reactionCount: "desc" },
|
||||
include: thumbnailInclude,
|
||||
}),
|
||||
ctx.prisma.post.findFirst({
|
||||
where: { ...postWhere, commentCount: { gt: 0 } },
|
||||
orderBy: { commentCount: "desc" },
|
||||
include: thumbnailInclude,
|
||||
}),
|
||||
ctx.prisma.story.findFirst({
|
||||
where: { ...storyWhere, reactionCount: { gt: 0 } },
|
||||
orderBy: { reactionCount: "desc" },
|
||||
}),
|
||||
ctx.prisma.story.findFirst({
|
||||
where: { ...storyWhere, commentCount: { gt: 0 } },
|
||||
orderBy: { commentCount: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalPosts: postTotals._count._all,
|
||||
totalPostReactions: postTotals._sum.reactionCount ?? 0,
|
||||
totalPostComments: postTotals._sum.commentCount ?? 0,
|
||||
totalActiveStories: storyTotals._count._all,
|
||||
totalStoryReactions: storyTotals._sum.reactionCount ?? 0,
|
||||
totalStoryComments: storyTotals._sum.commentCount ?? 0,
|
||||
topPostByReactions,
|
||||
topPostByComments,
|
||||
topStoryByReactions,
|
||||
topStoryByComments,
|
||||
};
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user