diff --git a/src/__tests__/media-upload.test.ts b/src/__tests__/media-upload.test.ts index 38a7e1d..0f99886 100644 --- a/src/__tests__/media-upload.test.ts +++ b/src/__tests__/media-upload.test.ts @@ -79,10 +79,12 @@ function createTestCaller( type CallerCtx = { userId: string | null; prisma: import("@prisma/client").PrismaClient; + locale: string; }; const ctx: CallerCtx = { userId, prisma: mockPrisma as unknown as import("@prisma/client").PrismaClient, + locale: "en", }; return mediaRouter.createCaller(ctx); } diff --git a/src/__tests__/pet-profile.test.ts b/src/__tests__/pet-profile.test.ts index d39f659..1ae066f 100644 --- a/src/__tests__/pet-profile.test.ts +++ b/src/__tests__/pet-profile.test.ts @@ -54,10 +54,11 @@ function createTestCaller(userId: string, mockPrisma: ReturnType; @@ -41,8 +42,8 @@ export default async function HealthCardPage({ params, searchParams }: PageProps include: { pet: { include: { - species: true, - breed: true, + species: { include: { translations: { where: { locale: "de" } } } }, + breed: { include: { translations: { where: { locale: "de" } } } }, weightLogs: { orderBy: { date: "desc" }, take: 20 }, vetVisits: { orderBy: { date: "desc" }, take: 10 }, vaccines: { orderBy: { dateGiven: "desc" } }, @@ -81,6 +82,8 @@ export default async function HealthCardPage({ params, searchParams }: PageProps const { pet } = card; const avatarUrl = getAvatarUrl(pet.avatarKey); + const speciesName = pickLocalizedName(pet.species.name, pet.species.translations, "de"); + const breedName = pet.breed ? pickLocalizedName(pet.breed.name, pet.breed.translations, "de") : null; return (
@@ -110,8 +113,8 @@ export default async function HealthCardPage({ params, searchParams }: PageProps

{pet.name}

- {pet.species.name} - {pet.breed && {pet.breed.name}} + {speciesName} + {breedName && {breedName}}
diff --git a/src/i18n/request.ts b/src/i18n/request.ts index fad0a15..3e5b72e 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -1,7 +1,7 @@ import { getRequestConfig } from "next-intl/server"; import { cookies } from "next/headers"; -const LOCALES = ["en", "de"] as const; +export const LOCALES = ["en", "de"] as const; export type Locale = (typeof LOCALES)[number]; export const defaultLocale: Locale = "en"; diff --git a/src/lib/localized-name.ts b/src/lib/localized-name.ts new file mode 100644 index 0000000..f093280 --- /dev/null +++ b/src/lib/localized-name.ts @@ -0,0 +1,20 @@ +import "server-only"; + +interface NameTranslation { + locale: string; + name: string; +} + +/** + * Resolves a translated display name from a SpeciesTranslation/BreedTranslation + * row set, falling back to the canonical (English) `name` field when no + * translation matches the requested locale (e.g. locale is "en", or the + * translation just hasn't been seeded for that locale). + */ +export function pickLocalizedName( + name: string, + translations: NameTranslation[] | null | undefined, + locale: string +): string { + return translations?.find((t) => t.locale === locale)?.name ?? name; +} diff --git a/src/trpc/init.ts b/src/trpc/init.ts index f2efad4..e00d826 100644 --- a/src/trpc/init.ts +++ b/src/trpc/init.ts @@ -1,14 +1,24 @@ import { initTRPC, TRPCError } from "@trpc/server"; import { auth } from "@clerk/nextjs/server"; +import { cookies } from "next/headers"; import { prisma } from "@/lib/prisma"; +import { LOCALES, defaultLocale } from "@/i18n/request"; /** * tRPC context — created per request in the API route handler. * Reads the authenticated owner's Clerk userId and provides the Prisma client. + * + * `locale` mirrors the NEXT_LOCALE cookie resolution in i18n/request.ts, so + * procedures that return DB-stored, translatable text (Species/Breed names — + * see SpeciesTranslation/BreedTranslation) can resolve the right language + * without every caller having to pass a locale input param explicitly. */ export const createTRPCContext = async () => { const { userId } = await auth(); - return { userId, prisma }; + const cookieStore = await cookies(); + const raw = cookieStore.get("NEXT_LOCALE")?.value ?? ""; + const locale = (LOCALES as readonly string[]).includes(raw) ? raw : defaultLocale; + return { userId, prisma, locale }; }; type Context = Awaited>; diff --git a/src/trpc/routers/explore.ts b/src/trpc/routers/explore.ts index fb7117b..ccf294d 100644 --- a/src/trpc/routers/explore.ts +++ b/src/trpc/routers/explore.ts @@ -1,6 +1,7 @@ import "server-only"; import { z } from "zod"; import { router, publicProcedure } from "@/trpc/init"; +import { pickLocalizedName } from "@/lib/localized-name"; const TRENDING_POSTS_WINDOW_MS = 48 * 60 * 60 * 1000; // 48 h const TRENDING_HASHTAGS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days @@ -25,14 +26,20 @@ export const exploreRouter = router({ take: input.limit + 1, ...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}), include: { - species: true, - breed: true, + species: { include: { translations: { where: { locale: ctx.locale } } } }, + breed: { include: { translations: { where: { locale: ctx.locale } } } }, _count: { select: { followers: true } }, }, }); const hasNextPage = pets.length > input.limit; - const items = hasNextPage ? pets.slice(0, input.limit) : pets; + const items = (hasNextPage ? pets.slice(0, input.limit) : pets).map((pet) => ({ + ...pet, + species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) }, + breed: pet.breed + ? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) } + : pet.breed, + })); return { pets: items, nextCursor: hasNextPage ? items[items.length - 1]!.id : null, diff --git a/src/trpc/routers/follows.ts b/src/trpc/routers/follows.ts index a355973..a7666c1 100644 --- a/src/trpc/routers/follows.ts +++ b/src/trpc/routers/follows.ts @@ -7,6 +7,7 @@ import { backfillOnFollow, fanOutPost } from "@/lib/feed-helpers"; 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"; /** * Follows tRPC router — pet-to-pet social graph (SOCL-01, SOCL-02, SOCL-03). @@ -173,7 +174,7 @@ export const followsRouter = router({ id: true, name: true, avatarKey: true, - species: { select: { name: true } }, + species: { select: { name: true, translations: { where: { locale: ctx.locale } } } }, }, }, }, @@ -182,7 +183,16 @@ export const followsRouter = router({ }); const hasNextPage = rows.length > input.limit; - const items = hasNextPage ? rows.slice(0, input.limit) : rows; + const items = (hasNextPage ? rows.slice(0, input.limit) : rows).map((row) => ({ + ...row, + follower: { + ...row.follower, + species: { + ...row.follower.species, + name: pickLocalizedName(row.follower.species.name, row.follower.species.translations, ctx.locale), + }, + }, + })); const nextCursor = hasNextPage ? items[items.length - 1]!.createdAt.toISOString() : null; @@ -241,7 +251,7 @@ export const followsRouter = router({ id: true, name: true, avatarKey: true, - species: { select: { name: true } }, + species: { select: { name: true, translations: { where: { locale: ctx.locale } } } }, }, }, }, @@ -250,7 +260,16 @@ export const followsRouter = router({ }); const hasNextPage = rows.length > input.limit; - const items = hasNextPage ? rows.slice(0, input.limit) : rows; + const items = (hasNextPage ? rows.slice(0, input.limit) : rows).map((row) => ({ + ...row, + followee: { + ...row.followee, + species: { + ...row.followee.species, + name: pickLocalizedName(row.followee.species.name, row.followee.species.translations, ctx.locale), + }, + }, + })); const nextCursor = hasNextPage ? items[items.length - 1]!.createdAt.toISOString() : null; diff --git a/src/trpc/routers/pets.ts b/src/trpc/routers/pets.ts index df4e4e5..88f9df4 100644 --- a/src/trpc/routers/pets.ts +++ b/src/trpc/routers/pets.ts @@ -3,6 +3,7 @@ import { TRPCError } from "@trpc/server"; 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"; export const petsRouter = router({ /** @@ -60,14 +61,21 @@ export const petsRouter = router({ * Scoped by ownerId — never returns other owners' pets. */ list: protectedProcedure.query(async ({ ctx }) => { - return ctx.prisma.pet.findMany({ + const pets = await ctx.prisma.pet.findMany({ where: { ownerId: ctx.userId }, include: { - species: true, - breed: true, + species: { include: { translations: { where: { locale: ctx.locale } } } }, + breed: { include: { translations: { where: { locale: ctx.locale } } } }, }, orderBy: { createdAt: "asc" }, }); + return pets.map((pet) => ({ + ...pet, + species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) }, + breed: pet.breed + ? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) } + : pet.breed, + })); }), /** @@ -79,7 +87,10 @@ export const petsRouter = router({ .query(async ({ ctx, input }) => { const pet = await ctx.prisma.pet.findUnique({ where: { id: input.petId }, - include: { species: true, breed: true }, + include: { + species: { include: { translations: { where: { locale: ctx.locale } } } }, + breed: { include: { translations: { where: { locale: ctx.locale } } } }, + }, }); if (!pet || pet.ownerId !== ctx.userId) { @@ -89,7 +100,13 @@ export const petsRouter = router({ }); } - return pet; + return { + ...pet, + species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) }, + breed: pet.breed + ? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) } + : pet.breed, + }; }), /** @@ -102,10 +119,19 @@ export const petsRouter = router({ .query(async ({ ctx, input }) => { const pet = await ctx.prisma.pet.findUnique({ where: { id: input.petId }, - include: { species: true, breed: true }, + include: { + species: { include: { translations: { where: { locale: ctx.locale } } } }, + breed: { include: { translations: { where: { locale: ctx.locale } } } }, + }, }); if (!pet) throw new TRPCError({ code: "NOT_FOUND" }); - return pet; + return { + ...pet, + species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) }, + breed: pet.breed + ? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) } + : pet.breed, + }; }), /** @@ -144,14 +170,21 @@ export const petsRouter = router({ if (data.adoptedAt !== undefined) updateData.adoptedAt = data.adoptedAt ? new Date(data.adoptedAt) : null; - return ctx.prisma.pet.update({ + const pet = await ctx.prisma.pet.update({ where: { id: petId }, data: updateData, include: { - species: true, - breed: true, + species: { include: { translations: { where: { locale: ctx.locale } } } }, + breed: { include: { translations: { where: { locale: ctx.locale } } } }, }, }); + return { + ...pet, + species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) }, + breed: pet.breed + ? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) } + : pet.breed, + }; }), /** diff --git a/src/trpc/routers/search.ts b/src/trpc/routers/search.ts index d46b68c..56b1715 100644 --- a/src/trpc/routers/search.ts +++ b/src/trpc/routers/search.ts @@ -2,6 +2,7 @@ import "server-only"; import { z } from "zod"; import { router, protectedProcedure } from "@/trpc/init"; import { clerkClient } from "@clerk/nextjs/server"; +import { pickLocalizedName } from "@/lib/localized-name"; export const searchRouter = router({ pets: protectedProcedure @@ -24,14 +25,20 @@ export const searchRouter = router({ orderBy: { name: "asc" }, take: input.limit + 1, include: { - species: true, - breed: true, + species: { include: { translations: { where: { locale: ctx.locale } } } }, + breed: { include: { translations: { where: { locale: ctx.locale } } } }, _count: { select: { followers: true } }, }, }); const hasNextPage = pets.length > input.limit; - const items = hasNextPage ? pets.slice(0, input.limit) : pets; + const items = (hasNextPage ? pets.slice(0, input.limit) : pets).map((pet) => ({ + ...pet, + species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) }, + breed: pet.breed + ? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) } + : pet.breed, + })); return { pets: items, nextCursor: hasNextPage ? items[items.length - 1]!.id : null, @@ -103,8 +110,8 @@ export const searchRouter = router({ include: { pets: { include: { - species: true, - breed: true, + species: { include: { translations: { where: { locale: ctx.locale } } } }, + breed: { include: { translations: { where: { locale: ctx.locale } } } }, _count: { select: { followers: true } }, }, orderBy: { name: "asc" }, @@ -123,7 +130,13 @@ export const searchRouter = router({ firstName: cu.firstName ?? null, lastName: cu.lastName ?? null, imageUrl: cu.imageUrl ?? null, - pets: owner.pets, + pets: owner.pets.map((pet) => ({ + ...pet, + species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) }, + breed: pet.breed + ? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) } + : pet.breed, + })), }; }), };