feat(i18n): resolve Species/Breed display names via SpeciesTranslation/BreedTranslation

User found "Cat" showing English on a German-locale profile page. Root
cause: Species.name/Breed.name are the canonical English values; a
SpeciesTranslation/BreedTranslation table + seed data (Hund/Katze/Vogel
etc.) already existed in the schema, and PetForm's species/breed picker
already used it correctly via an explicit locale input param — but every
*display* surface (pet profile, PetCard, Sidebar/MobileNav, search,
explore, followers/following lists, mention @-search, health-card) just
read the raw untranslated name.

Adds `locale` to the tRPC context (createTRPCContext reads the
NEXT_LOCALE cookie the same way i18n/request.ts does), so read
procedures can resolve translations without every caller needing to
pass a locale param. New src/lib/localized-name.ts::pickLocalizedName()
does the lookup with a same-shape fallback to the raw name.

Updated: pets.ts (list/byId/getProfile/update), search.ts (pets/byOwner),
explore.ts (listPets), follows.ts (listFollowers/listFollowing).
health-card/[token]/page.tsx (public, unauthenticated, intentionally
all-German) resolves with a fixed "de" locale instead of ctx.locale,
consistent with its existing hardcoded WeightChart locale.

Verified the seed data is actually present in the production DB
(SpeciesTranslation has Dog→Hund, Cat→Katze, Bird→Vogel) — this fix is
immediately effective on deploy, not blocked on a missing seed run.
This commit is contained in:
admin committed 2026-07-23 15:19:55 +02:00
1 parent b7719397e6
commit 25fa2a3bec
10 files changed
+138 -30

No files matched your search

+11 -1
View File
@@ -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<ReturnType<typeof createTRPCContext>>;
+10 -3
View File
@@ -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,
+23 -4
View File
@@ -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;
+43 -10
View File
@@ -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,
};
}),
/**
+19 -6
View File
@@ -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,
})),
};
}),
};