diff --git a/src/repositories/pet-repository.ts b/src/repositories/pet-repository.ts new file mode 100644 index 0000000..3d730a9 --- /dev/null +++ b/src/repositories/pet-repository.ts @@ -0,0 +1,27 @@ +import type { Prisma } from "@prisma/client"; + +/** + * Minimal pet identity — id/name/nickname/avatarKey. This is the shape every + * "who posted/commented/follows this" display needs; compose on top of it + * (spread + extra fields) rather than hand-writing the fragment again. + */ +export const petIdentitySelect = { + id: true, + name: true, + nickname: true, + avatarKey: true, +} satisfies Prisma.PetSelect; + +/** + * Pet identity plus the fields a feed/explore/profile card renders alongside + * the name: the verified checkmark and the owner's sponsor window (consumed + * via isActiveSponsor() in src/lib/sponsor.ts). + */ +export const petCardSelect = { + ...petIdentitySelect, + isVerified: true, + owner: { select: { sponsorSince: true, sponsorPeriodEnd: true } }, +} satisfies Prisma.PetSelect; + +export type PetIdentity = Prisma.PetGetPayload<{ select: typeof petIdentitySelect }>; +export type PetCardIdentity = Prisma.PetGetPayload<{ select: typeof petCardSelect }>; diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index 0fb3fdd..beeda41 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -5,6 +5,7 @@ import { TRPCError } from "@trpc/server"; import { AdminRoleType, BlacklistEntryType, type PrismaClient, type ReportReason } from "@prisma/client"; import { clerkClient } from "@clerk/nextjs/server"; import { router, protectedProcedure } from "@/trpc/init"; +import { petIdentitySelect } from "@/repositories/pet-repository"; import { createSignedUploadUrl, ALLOWED_CONTENT_TYPES, @@ -291,7 +292,7 @@ export const adminRouter = router({ } : undefined, include: { - pet: { select: { id: true, name: true, nickname: true, avatarKey: true, ownerId: true } }, + pet: { select: { ...petIdentitySelect, ownerId: true } }, images: { orderBy: { position: "asc" }, take: 1 }, videoPost: { select: { muxPlaybackId: true } }, milestone: true, @@ -557,7 +558,7 @@ export const adminRouter = router({ await assertAdmin(ctx.prisma, ctx.userId); return ctx.prisma.comment.findMany({ where: { postId: input.postId }, - include: { pet: { select: { id: true, name: true, nickname: true, avatarKey: true } } }, + include: { pet: { select: petIdentitySelect } }, orderBy: { createdAt: "asc" }, }); }), @@ -632,7 +633,7 @@ export const adminRouter = router({ ...(input.role !== "all" && input.role !== "none" ? { adminRole: { role: input.role } } : {}), }, include: { - pets: { select: { id: true, name: true, nickname: true, avatarKey: true, isVerified: true }, take: 3 }, + pets: { select: { ...petIdentitySelect, isVerified: true }, take: 3 }, ban: true, shadowban: true, adminRole: true, @@ -862,10 +863,7 @@ export const adminRouter = router({ include: { pet: { select: { - id: true, - name: true, - nickname: true, - avatarKey: true, + ...petIdentitySelect, isVerified: true, ownerId: true, bio: true, @@ -1061,8 +1059,8 @@ export const adminRouter = router({ const convs = await ctx.prisma.conversation.findMany({ where: { OR: [{ petAId: teamPet.id }, { petBId: teamPet.id }] }, include: { - petA: { select: { id: true, name: true, nickname: true, avatarKey: true } }, - petB: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + petA: { select: petIdentitySelect }, + petB: { select: petIdentitySelect }, messages: { orderBy: { createdAt: "desc" }, take: 1 }, _count: { select: { messages: { where: { senderPetId: { not: teamPet.id }, readAt: null } } }, @@ -1096,7 +1094,7 @@ export const adminRouter = router({ where: { conversationId: input.conversationId }, orderBy: { createdAt: "asc" }, take: 200, - include: { senderPet: { select: { id: true, name: true, nickname: true, avatarKey: true } } }, + include: { senderPet: { select: petIdentitySelect } }, }); // Fire-and-forget — any admin opening the thread marks it read for the whole team. @@ -1199,7 +1197,7 @@ export const adminRouter = router({ const reports = await ctx.prisma.report.findMany({ include: { - reporter: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + reporter: { select: petIdentitySelect }, }, orderBy: { createdAt: "desc" }, take: 100, @@ -1220,7 +1218,7 @@ export const adminRouter = router({ createdAt: true, reactionCount: true, commentCount: true, - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, images: { orderBy: { position: "asc" }, take: 4, @@ -1263,7 +1261,7 @@ export const adminRouter = router({ type: true, createdAt: true, hiddenAt: true, - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, images: { orderBy: { position: "asc" }, select: { storageKey: true, position: true, blurhash: true }, @@ -1272,7 +1270,7 @@ export const adminRouter = router({ orderBy: { createdAt: "desc" }, take: 200, select: { - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, }, }, }, diff --git a/src/trpc/routers/ads.ts b/src/trpc/routers/ads.ts index bcc1060..f5fd2de 100644 --- a/src/trpc/routers/ads.ts +++ b/src/trpc/routers/ads.ts @@ -2,6 +2,7 @@ import "server-only"; import { z } from "zod"; import { router, protectedProcedure } from "@/trpc/init"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; +import { petIdentitySelect } from "@/repositories/pet-repository"; export const adsRouter = router({ // Returns view/reaction/comment/repost counts + whether the given pet has interacted @@ -91,7 +92,7 @@ export const adsRouter = router({ return ctx.prisma.adComment.findMany({ where: { adId: input.adId }, include: { - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, }, orderBy: { createdAt: "asc" }, take: 100, @@ -113,7 +114,7 @@ export const adsRouter = router({ ctx.prisma.adComment.create({ data: { adId: input.adId, petId: input.petId, body: input.body }, include: { - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, }, }), ctx.prisma.advertisement.update({ diff --git a/src/trpc/routers/comments.ts b/src/trpc/routers/comments.ts index 132ee93..1b82dd7 100644 --- a/src/trpc/routers/comments.ts +++ b/src/trpc/routers/comments.ts @@ -5,6 +5,7 @@ import { router, protectedProcedure, rateLimited } from "@/trpc/init"; import { createMentionNotifications } from "@/lib/mention-helpers"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; import { notify } from "@/lib/notify"; +import { petIdentitySelect } from "@/repositories/pet-repository"; export const commentsRouter = router({ create: protectedProcedure @@ -27,7 +28,7 @@ export const commentsRouter = router({ body: input.body, }, include: { - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, }, }), ctx.prisma.post.update({ @@ -77,7 +78,7 @@ export const commentsRouter = router({ orderBy: { createdAt: "asc" }, take: input.limit + 1, include: { - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, }, }); diff --git a/src/trpc/routers/explore.ts b/src/trpc/routers/explore.ts index ec0c33d..c66caa0 100644 --- a/src/trpc/routers/explore.ts +++ b/src/trpc/routers/explore.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { router, publicProcedure, rateLimited } from "@/trpc/init"; import { pickLocalizedName } from "@/lib/localized-name"; import { getSpiderExclusionId } from "@/lib/spider-exclusion"; +import { petCardSelect } from "@/repositories/pet-repository"; const TRENDING_POSTS_WINDOW_MS = 48 * 60 * 60 * 1000; // 48 h const TRENDING_HASHTAGS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days @@ -75,16 +76,7 @@ export const exploreRouter = router({ }, include: { images: { orderBy: { position: "asc" } }, - pet: { - select: { - id: true, - name: true, - nickname: true, - avatarKey: true, - isVerified: true, - owner: { select: { sponsorSince: true, sponsorPeriodEnd: true } }, - }, - }, + pet: { select: petCardSelect }, milestone: true, }, orderBy: [{ pinnedAt: { sort: "desc", nulls: "last" } }, { reactionCount: "desc" }], diff --git a/src/trpc/routers/feed.ts b/src/trpc/routers/feed.ts index 6794ff4..53e4385 100644 --- a/src/trpc/routers/feed.ts +++ b/src/trpc/routers/feed.ts @@ -6,6 +6,7 @@ import { redis } from "@/lib/redis"; import { getFeedPage } from "@/lib/feed-helpers"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; import { getSpiderExclusionId } from "@/lib/spider-exclusion"; +import { petCardSelect } from "@/repositories/pet-repository"; // Engagement-based ranking score for the algorithmic feed. // Recency decays logarithmically; reaction/comment/repost weights are tuned @@ -27,16 +28,7 @@ function algoScore( } const postInclude = { - pet: { - select: { - id: true, - name: true, - nickname: true, - avatarKey: true, - isVerified: true, - owner: { select: { sponsorSince: true, sponsorPeriodEnd: true } }, - }, - }, + pet: { select: petCardSelect }, images: { orderBy: { position: "asc" } as const }, milestone: true, videoPost: { select: { status: true, muxPlaybackId: true } }, @@ -44,16 +36,7 @@ const postInclude = { include: { originalPost: { include: { - pet: { - select: { - id: true, - name: true, - nickname: true, - avatarKey: true, - isVerified: true, - owner: { select: { sponsorSince: true, sponsorPeriodEnd: true } }, - }, - }, + pet: { select: petCardSelect }, images: { orderBy: { position: "asc" } as const }, milestone: true, }, diff --git a/src/trpc/routers/follows.ts b/src/trpc/routers/follows.ts index 123a76f..1e233a8 100644 --- a/src/trpc/routers/follows.ts +++ b/src/trpc/routers/follows.ts @@ -8,6 +8,7 @@ import { assertPetOwnership } from "@/lib/assert-pet-ownership"; import { notify } from "@/lib/notify"; import { FOLLOWER_MILESTONE_THRESHOLDS as FOLLOWER_MILESTONES } from "@/lib/milestone-thresholds"; import { pickLocalizedName } from "@/lib/localized-name"; +import { petIdentitySelect } from "@/repositories/pet-repository"; /** * Follows tRPC router — pet-to-pet social graph (SOCL-01, SOCL-02, SOCL-03). @@ -83,7 +84,7 @@ export const followsRouter = router({ include: { images: { orderBy: { position: "asc" } }, milestone: true, - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, }, }); @@ -174,10 +175,7 @@ export const followsRouter = router({ include: { follower: { select: { - id: true, - name: true, - nickname: true, - avatarKey: true, + ...petIdentitySelect, species: { select: { name: true, translations: { where: { locale: ctx.locale } } } }, }, }, @@ -221,7 +219,7 @@ export const followsRouter = router({ }, }, select: { - followee: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + followee: { select: petIdentitySelect }, }, orderBy: { createdAt: "desc" }, take: 100, @@ -252,10 +250,7 @@ export const followsRouter = router({ include: { followee: { select: { - id: true, - name: true, - nickname: true, - avatarKey: true, + ...petIdentitySelect, species: { select: { name: true, translations: { where: { locale: ctx.locale } } } }, }, }, diff --git a/src/trpc/routers/messages.ts b/src/trpc/routers/messages.ts index ab44bd2..742d8f2 100644 --- a/src/trpc/routers/messages.ts +++ b/src/trpc/routers/messages.ts @@ -5,6 +5,7 @@ import { router, protectedProcedure, rateLimited } from "@/trpc/init"; import type { PrismaClient } from "@prisma/client"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; import { notify } from "@/lib/notify"; +import { petIdentitySelect } from "@/repositories/pet-repository"; /** * Messages tRPC router — Phase 6 Direct Messages. @@ -50,8 +51,8 @@ export const conversationsRouter = router({ const convs = await ctx.prisma.conversation.findMany({ where: { OR: [{ petAId: input.petId }, { petBId: input.petId }] }, include: { - petA: { select: { id: true, name: true, nickname: true, avatarKey: true } }, - petB: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + petA: { select: petIdentitySelect }, + petB: { select: petIdentitySelect }, messages: { orderBy: { createdAt: "desc" }, take: 1, @@ -188,7 +189,7 @@ export const messagesRouter = router({ orderBy: { createdAt: "asc" }, take: input.limit + 1, include: { - senderPet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + senderPet: { select: petIdentitySelect }, }, }); @@ -237,7 +238,7 @@ export const messagesRouter = router({ senderPetId: input.senderPetId, body: input.body, }, - include: { senderPet: { select: { id: true, name: true, nickname: true, avatarKey: true } } }, + include: { senderPet: { select: petIdentitySelect } }, }), // Touch conversation updatedAt so inbox re-orders correctly ctx.prisma.conversation.update({ diff --git a/src/trpc/routers/milestones.ts b/src/trpc/routers/milestones.ts index 15a17d0..1188a9e 100644 --- a/src/trpc/routers/milestones.ts +++ b/src/trpc/routers/milestones.ts @@ -5,6 +5,7 @@ import { redis } from "@/lib/redis"; import { fanOutPost } from "@/lib/feed-helpers"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; import { USER_INITIATED_MILESTONE_TYPES } from "@/lib/milestone-meta"; +import { petIdentitySelect } from "@/repositories/pet-repository"; /** * Milestones tRPC router — structured milestone posts (MILE-01, MILE-02). @@ -66,7 +67,7 @@ export const milestonesRouter = router({ include: { images: { orderBy: { position: "asc" } }, milestone: true, - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, }, }); @@ -97,7 +98,7 @@ export const milestonesRouter = router({ include: { milestone: true, images: { orderBy: { position: "asc" } }, - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, }, orderBy: { createdAt: "desc" }, take: input.limit, diff --git a/src/trpc/routers/notifications.ts b/src/trpc/routers/notifications.ts index b47b6d1..67a3a7b 100644 --- a/src/trpc/routers/notifications.ts +++ b/src/trpc/routers/notifications.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { router, protectedProcedure } from "@/trpc/init"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; +import { petIdentitySelect } from "@/repositories/pet-repository"; const PAGE_SIZE = 30; @@ -26,7 +27,7 @@ export const notificationsRouter = router({ orderBy: { createdAt: "desc" }, take: input.limit + 1, include: { - actorPet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + actorPet: { select: petIdentitySelect }, post: { select: { id: true, petId: true, images: { take: 1, orderBy: { position: "asc" } } } }, comment: { select: { id: true, body: true } }, }, diff --git a/src/trpc/routers/pets.ts b/src/trpc/routers/pets.ts index b72eb10..8f90d14 100644 --- a/src/trpc/routers/pets.ts +++ b/src/trpc/routers/pets.ts @@ -4,6 +4,7 @@ import { router, protectedProcedure } from "@/trpc/init"; import { generateInviteCode, INVITE_CODES_PER_USER } from "@/lib/invite-codes"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; import { pickLocalizedName } from "@/lib/localized-name"; +import { petCardSelect } from "@/repositories/pet-repository"; export const petsRouter = router({ /** @@ -124,7 +125,7 @@ export const petsRouter = router({ include: { species: { include: { translations: { where: { locale: ctx.locale } } } }, breed: { include: { translations: { where: { locale: ctx.locale } } } }, - owner: { select: { sponsorSince: true, sponsorPeriodEnd: true } }, + owner: petCardSelect.owner, }, }); if (!pet) throw new TRPCError({ code: "NOT_FOUND" }); diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts index 13cf101..1ec15ff 100644 --- a/src/trpc/routers/posts.ts +++ b/src/trpc/routers/posts.ts @@ -15,6 +15,7 @@ import { extractHashtags, saveHashtags } from "@/lib/hashtag-helpers"; import { checkAgainstBlacklist } from "@/lib/blacklist"; import { createMentionNotifications } from "@/lib/mention-helpers"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; +import { petIdentitySelect } from "@/repositories/pet-repository"; /** * Posts tRPC router — photo post creation, caption edit, deletion, feed hydration. @@ -129,7 +130,7 @@ export const postsRouter = router({ }, include: { images: { orderBy: { position: "asc" } }, - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, milestone: true, }, }); @@ -197,7 +198,7 @@ export const postsRouter = router({ data: { caption: input.caption }, include: { images: { orderBy: { position: "asc" } }, - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, milestone: true, }, }); @@ -249,14 +250,14 @@ export const postsRouter = router({ take: input.limit + 1, include: { images: { orderBy: { position: "asc" } }, - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, milestone: true, videoPost: { select: { status: true, muxPlaybackId: true } }, repost: { include: { originalPost: { include: { - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, images: { orderBy: { position: "asc" } }, milestone: true, }, @@ -291,14 +292,14 @@ export const postsRouter = router({ }, include: { images: { orderBy: { position: "asc" } }, - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, milestone: true, videoPost: { select: { status: true, muxPlaybackId: true } }, repost: { include: { originalPost: { include: { - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, images: { orderBy: { position: "asc" } }, milestone: true, }, diff --git a/src/trpc/routers/search.ts b/src/trpc/routers/search.ts index 2eb6d2b..eb4a36d 100644 --- a/src/trpc/routers/search.ts +++ b/src/trpc/routers/search.ts @@ -4,6 +4,7 @@ import { router, protectedProcedure } from "@/trpc/init"; import { clerkClient } from "@clerk/nextjs/server"; import { pickLocalizedName } from "@/lib/localized-name"; import { getSpiderExclusionId } from "@/lib/spider-exclusion"; +import { petIdentitySelect } from "@/repositories/pet-repository"; export const searchRouter = router({ pets: protectedProcedure @@ -85,7 +86,7 @@ export const searchRouter = router({ include: { post: { include: { - pet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + pet: { select: petIdentitySelect }, images: { orderBy: { position: "asc" }, take: 1 }, }, }, diff --git a/src/trpc/routers/stories.ts b/src/trpc/routers/stories.ts index cbc179c..cc3cff1 100644 --- a/src/trpc/routers/stories.ts +++ b/src/trpc/routers/stories.ts @@ -8,6 +8,7 @@ import { MAX_FILE_SIZE, } from "@/lib/supabase-storage"; import { randomUUID } from "crypto"; +import { petIdentitySelect } from "@/repositories/pet-repository"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; import { mux } from "@/lib/mux"; import { MAX_STORY_VIDEO_DURATION_SECS } from "@/lib/story-video-limits"; @@ -270,7 +271,7 @@ export const storiesRouter = router({ return ctx.prisma.storyView.findMany({ where: { storyId: input.storyId }, include: { - viewerPet: { select: { id: true, name: true, nickname: true, avatarKey: true } }, + viewerPet: { select: petIdentitySelect }, }, orderBy: { viewedAt: "desc" }, take: 200,