diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f2ea13..71b841ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,14 @@ This project uses a user-facing changelog format. ### Added +- Koirien viralliset ja historialliset värikoodit säilyvät tuonnissa, näkyvät suomeksi ja ruotsiksi koiraprofiileissa ja ovat hallittavissa ylläpidon koiralomakkeella. + ### Changed ### Fixed +- Uudelle koiralle voi valita vain tällä hetkellä käytössä olevan värin, mutta vanha piilotettu värikoodi säilyy koiraa muokattaessa. + ### Removed - Ylläpidon koiranäkymistä piilotettiin kasvattajatiedot, kunnes kasvattajan historiatiedon ja kennelrekisterin suhde on päätetty. diff --git a/apps/web/app/actions/admin/dogs/manage/__tests__/get-admin-dogs.test.ts b/apps/web/app/actions/admin/dogs/manage/__tests__/get-admin-dogs.test.ts index 6e05e8e0..988148dc 100644 --- a/apps/web/app/actions/admin/dogs/manage/__tests__/get-admin-dogs.test.ts +++ b/apps/web/app/actions/admin/dogs/manage/__tests__/get-admin-dogs.test.ts @@ -116,6 +116,7 @@ describe("getAdminDogsAction", () => { showCount: 4, titlesText: null, ekNo: 5588, + colorCode: 121, note: null, }, ], @@ -144,6 +145,7 @@ describe("getAdminDogsAction", () => { showCount: 4, titlesText: null, ekNo: 5588, + colorCode: 121, note: null, }, ], diff --git a/apps/web/app/api/admin/dogs/lookups/colors/__tests__/route.test.ts b/apps/web/app/api/admin/dogs/lookups/colors/__tests__/route.test.ts new file mode 100644 index 00000000..809ed15b --- /dev/null +++ b/apps/web/app/api/admin/dogs/lookups/colors/__tests__/route.test.ts @@ -0,0 +1,146 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + listAdminDogColorOptionsMock, + getSessionCurrentUserMock, + toAdminUserContextMock, +} = vi.hoisted(() => ({ + listAdminDogColorOptionsMock: vi.fn(), + getSessionCurrentUserMock: vi.fn(), + toAdminUserContextMock: vi.fn(), +})); + +vi.mock("@beagle/server", () => ({ + listAdminDogColorOptions: listAdminDogColorOptionsMock, +})); + +vi.mock("@/lib/server/current-user", () => ({ + getSessionCurrentUser: getSessionCurrentUserMock, +})); + +vi.mock("@/lib/server/admin-user-context", () => ({ + toAdminUserContext: toAdminUserContextMock, +})); + +describe("admin dog color lookup api route", () => { + beforeEach(() => { + listAdminDogColorOptionsMock.mockReset(); + getSessionCurrentUserMock.mockReset(); + toAdminUserContextMock.mockReset(); + }); + + it("returns CORS preflight responses", async () => { + const { OPTIONS } = await import("../route"); + const request = new NextRequest( + "http://localhost/api/admin/dogs/lookups/colors", + { + headers: { origin: "http://localhost:3000" }, + }, + ); + + const response = await OPTIONS(request); + + expect(response.status).toBe(204); + }); + + it("returns color options for admins", async () => { + getSessionCurrentUserMock.mockResolvedValue({ + id: "u_1", + email: "admin@example.com", + name: "Admin", + role: "ADMIN", + }); + toAdminUserContextMock.mockReturnValue({ + id: "u_1", + email: "admin@example.com", + username: "admin", + role: "ADMIN", + }); + listAdminDogColorOptionsMock.mockResolvedValue({ + status: 200, + body: { + ok: true, + data: { + items: [ + { + code: 121, + nameFi: "Kolmivärinen", + nameSv: "Trefärgad", + nameEn: null, + status: "SELECTABLE", + }, + ], + }, + }, + }); + + const { GET } = await import("../route"); + const request = new NextRequest( + "http://localhost/api/admin/dogs/lookups/colors", + { + headers: { origin: "http://localhost:3000" }, + }, + ); + const response = await GET(request); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + data: { + items: [ + { + code: 121, + nameFi: "Kolmivärinen", + nameSv: "Trefärgad", + nameEn: null, + status: "SELECTABLE", + }, + ], + }, + }); + expect(listAdminDogColorOptionsMock).toHaveBeenCalledWith({ + id: "u_1", + email: "admin@example.com", + username: "admin", + role: "ADMIN", + }); + }); + + it("returns structured errors when the server use case fails", async () => { + getSessionCurrentUserMock.mockResolvedValue({ + id: "u_2", + email: "admin@example.com", + name: "Admin", + role: "ADMIN", + }); + toAdminUserContextMock.mockReturnValue({ + id: "u_2", + email: "admin@example.com", + username: "admin", + role: "ADMIN", + }); + listAdminDogColorOptionsMock.mockResolvedValue({ + status: 500, + body: { + ok: false, + error: "Failed to load dog color options.", + code: "INTERNAL_ERROR", + }, + }); + + const { GET } = await import("../route"); + const response = await GET( + new NextRequest("http://localhost/api/admin/dogs/lookups/colors", { + headers: { origin: "http://localhost:3000" }, + }), + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + ok: false, + error: "Failed to load dog color options.", + code: "INTERNAL_ERROR", + }); + }); +}); diff --git a/apps/web/app/api/admin/dogs/lookups/colors/route.ts b/apps/web/app/api/admin/dogs/lookups/colors/route.ts new file mode 100644 index 00000000..23a5ee80 --- /dev/null +++ b/apps/web/app/api/admin/dogs/lookups/colors/route.ts @@ -0,0 +1,39 @@ +import type { NextRequest } from "next/server"; +import { listAdminDogColorOptions } from "@beagle/server"; +import { getSessionCurrentUser } from "@/lib/server/current-user"; +import { toAdminUserContext } from "@/lib/server/admin-user-context"; +import { jsonResponse, optionsResponse } from "@/lib/server/cors"; + +export async function OPTIONS(request: NextRequest) { + return optionsResponse("GET,OPTIONS", { + origin: request.headers.get("origin"), + }); +} + +export async function GET(request: NextRequest) { + try { + const currentUser = await getSessionCurrentUser(); + const result = await listAdminDogColorOptions( + toAdminUserContext(currentUser), + ); + + return jsonResponse(result.body, { + status: result.status, + methods: "GET,OPTIONS", + origin: request.headers.get("origin"), + }); + } catch { + return jsonResponse( + { + ok: false, + error: "Failed to load dog color options.", + code: "INTERNAL_ERROR", + }, + { + status: 500, + methods: "GET,OPTIONS", + origin: request.headers.get("origin"), + }, + ); + } +} diff --git a/apps/web/components/admin/dogs/__tests__/admin-dogs-page-client.test.tsx b/apps/web/components/admin/dogs/__tests__/admin-dogs-page-client.test.tsx index f1b1e61f..b88ebbc2 100644 --- a/apps/web/components/admin/dogs/__tests__/admin-dogs-page-client.test.tsx +++ b/apps/web/components/admin/dogs/__tests__/admin-dogs-page-client.test.tsx @@ -5,6 +5,7 @@ import type { AdminDogFormValues } from "../types"; const { useAdminDogsQueryMock, + useAdminDogColorOptionsQueryMock, useAdminDogOwnerOptionsQueryMock, useAdminDogParentOptionsQueryMock, useCalculateAdminDogInbreedingMutationMock, @@ -18,6 +19,7 @@ const { dogResultsPropsMock, } = vi.hoisted(() => ({ useAdminDogsQueryMock: vi.fn(), + useAdminDogColorOptionsQueryMock: vi.fn(), useAdminDogOwnerOptionsQueryMock: vi.fn(), useAdminDogParentOptionsQueryMock: vi.fn(), useCalculateAdminDogInbreedingMutationMock: vi.fn(), @@ -43,6 +45,7 @@ vi.mock("@/hooks/admin/dogs/manage", () => ({ vi.mock("@/queries/admin/dogs", () => ({ useAdminDogsQuery: useAdminDogsQueryMock, + useAdminDogColorOptionsQuery: useAdminDogColorOptionsQueryMock, useAdminDogOwnerOptionsQuery: useAdminDogOwnerOptionsQueryMock, useAdminDogParentOptionsQuery: useAdminDogParentOptionsQueryMock, useCalculateAdminDogInbreedingMutation: @@ -110,6 +113,7 @@ function buildFormValues(): AdminDogFormValues { ownershipNames: ["Tiina Virtanen"], ekNo: "5588", inbreedingCoefficientPct: null, + colorCode: "121", note: "", registrationNo: "FI12345/21", secondaryRegistrationNos: [], @@ -124,6 +128,7 @@ function buildFormValues(): AdminDogFormValues { describe("AdminDogsPageClient", () => { beforeEach(() => { useAdminDogsQueryMock.mockReset(); + useAdminDogColorOptionsQueryMock.mockReset(); useAdminDogOwnerOptionsQueryMock.mockReset(); useAdminDogParentOptionsQueryMock.mockReset(); useCalculateAdminDogInbreedingMutationMock.mockReset(); @@ -171,6 +176,7 @@ describe("AdminDogsPageClient", () => { showCount: 1, titlesText: null, ekNo: 5588, + colorCode: 121, note: null, titles: [], }, @@ -179,6 +185,17 @@ describe("AdminDogsPageClient", () => { isLoading: false, isError: false, }); + useAdminDogColorOptionsQueryMock.mockReturnValue({ + data: [ + { + code: 121, + nameFi: "Kolmivärinen", + nameSv: "Trefärgad", + nameEn: null, + status: "SELECTABLE", + }, + ], + }); useAdminDogOwnerOptionsQueryMock.mockReturnValue({ data: [{ id: "o_1", name: "Tiina Virtanen" }], }); @@ -259,6 +276,13 @@ describe("AdminDogsPageClient", () => { { registrationNo: "FI77777/18", name: "Havupolun Helmi" }, { registrationNo: "FI54321/20", name: "Korven Aatos" }, ]); + expect(dogFormProps.colorOptions).toEqual([ + { + value: "121", + label: "121 - Kolmivärinen", + keywords: ["121", "Kolmivärinen", "Trefärgad", ""], + }, + ]); expect(dogResultsProps.dogs[0]?.titlesText).toBeNull(); }); }); diff --git a/apps/web/components/admin/dogs/__tests__/dog-form-modal.test.ts b/apps/web/components/admin/dogs/__tests__/dog-form-modal.test.ts index ea23ebfe..661faf9f 100644 --- a/apps/web/components/admin/dogs/__tests__/dog-form-modal.test.ts +++ b/apps/web/components/admin/dogs/__tests__/dog-form-modal.test.ts @@ -46,6 +46,7 @@ function buildEditValues(): AdminDogFormValues { ownershipNames: ["Tiina Virtanen", "Antti Virtanen"], ekNo: "5588", inbreedingCoefficientPct: null, + colorCode: "121", note: "Important note", registrationNo: "FI12345/21", secondaryRegistrationNos: ["FI54321/21"], @@ -72,6 +73,7 @@ function buildCreateValues(): AdminDogFormValues { ownershipNames: [], ekNo: "", inbreedingCoefficientPct: null, + colorCode: "", note: "", registrationNo: "", secondaryRegistrationNos: [], @@ -104,6 +106,7 @@ function buildDog(values: AdminDogFormValues): AdminDogRecord { titlesText: values.titles.map((title) => title.titleCode).join(", ") || null, ekNo: Number(values.ekNo), + colorCode: values.colorCode ? Number(values.colorCode) : null, note: values.note, registrationNo: values.registrationNo, secondaryRegistrationNos: values.secondaryRegistrationNos, @@ -125,6 +128,7 @@ describe("DogFormModal", () => { mode: "edit", dog: buildDog(values), values, + colorOptions: [{ value: "121", label: "121 - Kolmivärinen" }], ownerOptions: [ { id: "o_1", name: "Tiina Virtanen" }, { id: "o_2", name: "Antti Virtanen" }, @@ -170,6 +174,7 @@ describe("DogFormModal", () => { mode: "edit", dog: buildDog(values), values, + colorOptions: [{ value: "121", label: "121 - Kolmivärinen" }], ownerOptions: [ { id: "o_1", name: "Tiina Virtanen" }, { id: "o_2", name: "Antti Virtanen" }, @@ -198,6 +203,7 @@ describe("DogFormModal", () => { mode: "create", dog: null, values, + colorOptions: [], ownerOptions: [], parentOptions: [], onOwnerSearchChange: vi.fn(), diff --git a/apps/web/components/admin/dogs/__tests__/dog-results.test.tsx b/apps/web/components/admin/dogs/__tests__/dog-results.test.tsx index e25998c6..1b963ea2 100644 --- a/apps/web/components/admin/dogs/__tests__/dog-results.test.tsx +++ b/apps/web/components/admin/dogs/__tests__/dog-results.test.tsx @@ -51,6 +51,7 @@ function buildDog(overrides: Partial = {}): AdminDogRecord { titlesText: "FI JVA, SE JCH", ownershipPreview: ["Tiina Virtanen"], ekNo: 5588, + colorCode: 121, note: "Important", sirePreview: { name: "Korven Aatos", registrationNo: "FI54321/20" }, damPreview: { name: "Havupolun Helmi", registrationNo: "FI77777/18" }, diff --git a/apps/web/components/admin/dogs/admin-dogs-page-client.tsx b/apps/web/components/admin/dogs/admin-dogs-page-client.tsx index 4b0ed3be..66023076 100644 --- a/apps/web/components/admin/dogs/admin-dogs-page-client.tsx +++ b/apps/web/components/admin/dogs/admin-dogs-page-client.tsx @@ -10,7 +10,9 @@ import { toAdminDogOwnerOptions, toAdminDogParentOptions, } from "@/lib/admin/dogs/manage"; +import { formatDogColor } from "@/lib/dogs/color"; import { + useAdminDogColorOptionsQuery, useDeleteAdminDogMutation, useAdminDogOwnerOptionsQuery, useAdminDogParentOptionsQuery, @@ -26,7 +28,7 @@ import { DogResults } from "./dog-results"; import type { AdminDogSex } from "./types"; export function AdminDogsPageClient() { - const { t } = useI18n(); + const { t, locale } = useI18n(); const [query, setQuery] = useState(""); const [sex, setSex] = useState<"all" | AdminDogSex>("all"); @@ -65,6 +67,9 @@ export function AdminDogsPageClient() { limit: 100, enabled: dogFormFlow.formState.open, }); + const colorOptionsQuery = useAdminDogColorOptionsQuery( + dogFormFlow.formState.open, + ); const dogs = useMemo( () => (dogsQuery.data?.items ?? []).map(mapAdminDogFromQuery), @@ -94,6 +99,37 @@ export function AdminDogsPageClient() { dogFormFlow.formValues.damPreviewName, ], ); + const colorOptions = useMemo( + () => + (colorOptionsQuery.data ?? []) + .filter( + (option) => + option.status === "SELECTABLE" || + String(option.code) === dogFormFlow.formValues.colorCode, + ) + .map((option) => { + const localizedName = + formatDogColor(option, locale) ?? String(option.code); + const hiddenSuffix = + option.status === "SELECTABLE" + ? "" + : locale === "sv" + ? " (dold)" + : " (piilotettu)"; + + return { + value: String(option.code), + label: `${option.code} - ${localizedName}${hiddenSuffix}`, + keywords: [ + String(option.code), + option.nameFi, + option.nameSv ?? "", + option.nameEn ?? "", + ], + }; + }), + [colorOptionsQuery.data, dogFormFlow.formValues.colorCode, locale], + ); const resultCount = dogs.length; @@ -142,6 +178,7 @@ export function AdminDogsPageClient() { mode={dogFormFlow.formState.mode} dog={dogFormFlow.formState.target} values={dogFormFlow.formValues} + colorOptions={colorOptions} ownerOptions={ownerOptions} parentOptions={parentOptions} onOwnerSearchChange={dogFormFlow.setOwnerLookupQuery} diff --git a/apps/web/components/admin/dogs/dog-form-modal.tsx b/apps/web/components/admin/dogs/dog-form-modal.tsx index ccafeb9e..12a6cffb 100644 --- a/apps/web/components/admin/dogs/dog-form-modal.tsx +++ b/apps/web/components/admin/dogs/dog-form-modal.tsx @@ -18,6 +18,7 @@ type DogFormModalProps = { mode: "create" | "edit"; dog: AdminDogRecord | null; values: AdminDogFormValues; + colorOptions: ComboboxOption[]; ownerOptions: NamedEntityOption[]; parentOptions: DogParentOption[]; onOwnerSearchChange: (value: string) => void; @@ -35,6 +36,7 @@ export function DogFormModal({ mode, dog, values, + colorOptions, ownerOptions, parentOptions, onOwnerSearchChange, @@ -166,6 +168,7 @@ export function DogFormModal({ diff --git a/apps/web/components/admin/dogs/internal/dog-form-metadata-section.tsx b/apps/web/components/admin/dogs/internal/dog-form-metadata-section.tsx index fd5bdc78..e8e546d6 100644 --- a/apps/web/components/admin/dogs/internal/dog-form-metadata-section.tsx +++ b/apps/web/components/admin/dogs/internal/dog-form-metadata-section.tsx @@ -1,15 +1,18 @@ +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; import type { MessageKey } from "@/lib/i18n/messages"; import type { AdminDogFormValues } from "../types"; type DogFormMetadataSectionProps = { values: AdminDogFormValues; + colorOptions: ComboboxOption[]; onValuesChange: (values: AdminDogFormValues) => void; t: (key: MessageKey) => string; }; export function DogFormMetadataSection({ values, + colorOptions, onValuesChange, t, }: DogFormMetadataSectionProps) { @@ -29,6 +32,15 @@ export function DogFormMetadataSection({ placeholder={t("admin.dogs.form.ekNoPlaceholder")} maxLength={10} /> + onValuesChange({ ...values, colorCode: value })} + placeholder={t("admin.dogs.form.colorSelectLabel")} + searchPlaceholder={t("admin.dogs.form.searchPlaceholder")} + emptyLabel={t("admin.dogs.form.noOptions")} + clearLabel={t("admin.dogs.form.selectNone")} + /> diff --git a/apps/web/components/admin/dogs/profile/__tests__/admin-dog-profile-page.test.tsx b/apps/web/components/admin/dogs/profile/__tests__/admin-dog-profile-page.test.tsx index 225f92aa..972e240a 100644 --- a/apps/web/components/admin/dogs/profile/__tests__/admin-dog-profile-page.test.tsx +++ b/apps/web/components/admin/dogs/profile/__tests__/admin-dog-profile-page.test.tsx @@ -80,7 +80,7 @@ describe("AdminDogProfilePage", () => { expect(html).toContain("25.5.2001"); expect(html).toContain("Vanhemmat"); expect(html).toContain("Väri"); - expect(html).toContain("Tulossa"); + expect(html).not.toContain("Tulossa"); expect(html).toContain("Jälkeläisiä(EK)[2p]"); expect(html).toContain("3.0724 %"); expect(html).toContain("0.3750 -----"); diff --git a/apps/web/components/admin/dogs/profile/admin-dog-profile-page.tsx b/apps/web/components/admin/dogs/profile/admin-dog-profile-page.tsx index 86517113..d15669cb 100644 --- a/apps/web/components/admin/dogs/profile/admin-dog-profile-page.tsx +++ b/apps/web/components/admin/dogs/profile/admin-dog-profile-page.tsx @@ -4,6 +4,8 @@ import { ListingSectionShell } from "@/components/listing"; import { beagleTheme } from "@/components/ui/beagle-theme"; import { TooltipProvider } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { formatDogColor } from "@/lib/dogs/color"; +import { useI18n } from "@/hooks/i18n"; import type { AdminDogProfileDto } from "@beagle/contracts"; import { type ReactNode } from "react"; import { EpiLukuWithFlag } from "@web/components/admin/dogs/shared/epi-flag"; @@ -129,6 +131,7 @@ function DetailRow({ } function AdminDogProfileBasicsSection({ dog }: { dog: AdminDogProfileDto }) { + const { locale } = useI18n(); return (
@@ -143,7 +146,10 @@ function AdminDogProfileBasicsSection({ dog }: { dog: AdminDogProfileDto }) { value={formatBirthDateWithAge(dog.birthDate)} /> - + { trialCount: 3, showCount: 1, litterCount: 0, + color: null, }, ], titles: [ @@ -113,6 +114,7 @@ describe("BeagleDogProfilePage", () => { trialCount: 4, showCount: 2, litterCount: 1, + color: null, }, { id: "puppy_2", @@ -124,6 +126,7 @@ describe("BeagleDogProfilePage", () => { trialCount: 0, showCount: 0, litterCount: 0, + color: null, }, ], }, diff --git a/apps/web/components/beagle-dog-profile/dog-profile-details-card.tsx b/apps/web/components/beagle-dog-profile/dog-profile-details-card.tsx index f552572b..fd2ce7a8 100644 --- a/apps/web/components/beagle-dog-profile/dog-profile-details-card.tsx +++ b/apps/web/components/beagle-dog-profile/dog-profile-details-card.tsx @@ -9,6 +9,7 @@ import { } from "@/lib/public/beagle/dogs/profile"; import type { MessageKey } from "@/lib/i18n"; import { cn } from "@/lib/utils"; +import { formatDogColor } from "@/lib/dogs/color"; import type { BeagleDogProfileDto, BeagleDogProfileParentDto, @@ -184,10 +185,7 @@ export function DogProfileDetailsCard({ /> {profile.ekNo != null && ( string): string { - return `${FALLBACK_VALUE} ${t("dog.profile.field.comingSoon")}`; -} - function LitterDesktopTable({ litter, t, + locale, }: { litter: BeagleDogProfileLitterDto; t: (key: MessageKey) => string; + locale: "fi" | "sv"; }) { return (
@@ -146,7 +145,9 @@ function LitterDesktopTable({ {mapSexLabel(puppy.sex, t)} - {formatColorPlaceholder(t)} + + {formatDogColor(puppy.color, locale) ?? FALLBACK_VALUE} + {puppy.trialCount} {puppy.showCount} {puppy.litterCount} @@ -162,9 +163,11 @@ function LitterDesktopTable({ function LitterMobileCards({ litter, t, + locale, }: { litter: BeagleDogProfileLitterDto; t: (key: MessageKey) => string; + locale: "fi" | "sv"; }) { return (
@@ -234,7 +237,9 @@ function LitterMobileCards({ {t("dog.profile.litters.col.color")}:{" "} - {formatColorPlaceholder(t)} + + {formatDogColor(puppy.color, locale) ?? FALLBACK_VALUE} +

@@ -280,8 +285,8 @@ function LitterBlock({
} - mobile={} + desktop={} + mobile={} />
diff --git a/apps/web/components/beagle-dog-profile/dog-profile-siblings-card.tsx b/apps/web/components/beagle-dog-profile/dog-profile-siblings-card.tsx index f9cf52e4..2cdf1170 100644 --- a/apps/web/components/beagle-dog-profile/dog-profile-siblings-card.tsx +++ b/apps/web/components/beagle-dog-profile/dog-profile-siblings-card.tsx @@ -8,6 +8,7 @@ import { useI18n } from "@/hooks/i18n"; import type { MessageKey } from "@/lib/i18n"; import { getDogProfileHref } from "@/lib/public/beagle/dogs/profile"; import { cn } from "@/lib/utils"; +import { formatDogColor } from "@/lib/dogs/color"; import type { BeagleDogProfileDto, BeagleDogProfileSex, @@ -35,16 +36,14 @@ function formatEkNo(value: number | null): string { return value == null ? FALLBACK_VALUE : String(value); } -function formatColorPlaceholder(t: (key: MessageKey) => string): string { - return `${FALLBACK_VALUE} ${t("dog.profile.field.comingSoon")}`; -} - function SiblingsDesktopTable({ siblings, t, + locale, }: { siblings: BeagleDogProfileSiblingRowDto[]; t: (key: MessageKey) => string; + locale: "fi" | "sv"; }) { return (
@@ -100,7 +99,9 @@ function SiblingsDesktopTable({ {mapSexLabel(sibling.sex, t)} - {formatColorPlaceholder(t)} + + {formatDogColor(sibling.color, locale) ?? FALLBACK_VALUE} + {sibling.trialCount} {sibling.showCount} {sibling.litterCount} @@ -116,9 +117,11 @@ function SiblingsDesktopTable({ function SiblingsMobileCards({ siblings, t, + locale, }: { siblings: BeagleDogProfileSiblingRowDto[]; t: (key: MessageKey) => string; + locale: "fi" | "sv"; }) { return (
@@ -188,7 +191,9 @@ function SiblingsMobileCards({ {t("dog.profile.litters.col.color")}:{" "} - {formatColorPlaceholder(t)} + + {formatDogColor(sibling.color, locale) ?? FALLBACK_VALUE} +

@@ -202,7 +207,7 @@ export function DogProfileSiblingsCard({ }: { profile: BeagleDogProfileDto; }) { - const { t } = useI18n(); + const { t, locale } = useI18n(); return ( ) : ( } - mobile={} + desktop={ + + } + mobile={ + + } /> )} diff --git a/apps/web/hooks/admin/dogs/manage/__tests__/use-admin-dog-form-flow.test.ts b/apps/web/hooks/admin/dogs/manage/__tests__/use-admin-dog-form-flow.test.ts index 60231638..a1fc43eb 100644 --- a/apps/web/hooks/admin/dogs/manage/__tests__/use-admin-dog-form-flow.test.ts +++ b/apps/web/hooks/admin/dogs/manage/__tests__/use-admin-dog-form-flow.test.ts @@ -36,6 +36,7 @@ function buildFormValues(): AdminDogFormValues { ownershipNames: ["Tiina Virtanen"], ekNo: "5588", inbreedingCoefficientPct: null, + colorCode: "121", note: "Important note", registrationNo: "fi12345/21", secondaryRegistrationNos: [" fi54321/21 "], @@ -65,6 +66,7 @@ function buildTargetDog(): AdminDogRecord { titlesText: null, ownershipPreview: ["Tiina Virtanen"], ekNo: 5588, + colorCode: 121, note: "Important note", registrationNo: "FI12345/21", secondaryRegistrationNos: ["FI54321/21"], @@ -116,6 +118,7 @@ describe("useAdminDogFormFlow", () => { breederNameText: "Metsapolun", ownerNames: ["Tiina Virtanen"], ekNo: 5588, + colorCode: 121, note: "Important note", registrationNo: "fi12345/21", secondaryRegistrationNos: ["FI54321/21"], diff --git a/apps/web/lib/admin/dogs/manage/__tests__/dog-form-section-updates.test.ts b/apps/web/lib/admin/dogs/manage/__tests__/dog-form-section-updates.test.ts index 783b3776..d73e9bda 100644 --- a/apps/web/lib/admin/dogs/manage/__tests__/dog-form-section-updates.test.ts +++ b/apps/web/lib/admin/dogs/manage/__tests__/dog-form-section-updates.test.ts @@ -22,6 +22,7 @@ function createValues(): AdminDogFormValues { ownershipNames: ["Tiina Virtanen"], ekNo: "5588", inbreedingCoefficientPct: null, + colorCode: "", note: "", registrationNo: "FI12345/21", secondaryRegistrationNos: ["FI54321/21"], diff --git a/apps/web/lib/admin/dogs/manage/__tests__/dog-manage-lib.test.ts b/apps/web/lib/admin/dogs/manage/__tests__/dog-manage-lib.test.ts index 4f3201a9..02640478 100644 --- a/apps/web/lib/admin/dogs/manage/__tests__/dog-manage-lib.test.ts +++ b/apps/web/lib/admin/dogs/manage/__tests__/dog-manage-lib.test.ts @@ -51,6 +51,7 @@ describe("admin dog manage lib", () => { ownershipNames: ["Tiina Virtanen"], ekNo: " 5588 ", inbreedingCoefficientPct: 12.5, + colorCode: "121", note: " Important note ", registrationNo: "FI12345/21 ", secondaryRegistrationNos: [" fi54321/21 ", "", " FI77777/18 "], @@ -69,6 +70,7 @@ describe("admin dog manage lib", () => { breederNameText: "Metsapolun", ownerNames: ["Tiina Virtanen"], ekNo: 5588, + colorCode: 121, note: "Important note", registrationNo: "FI12345/21", secondaryRegistrationNos: ["FI54321/21", "FI77777/18"], @@ -102,6 +104,7 @@ describe("admin dog manage lib", () => { showCount: 2, titlesText: "FI JVA, SE JCH", ekNo: 5588, + colorCode: null, note: null, titles: [], }); diff --git a/apps/web/lib/admin/dogs/manage/dog-form-mappers.ts b/apps/web/lib/admin/dogs/manage/dog-form-mappers.ts index fd257096..f859a079 100644 --- a/apps/web/lib/admin/dogs/manage/dog-form-mappers.ts +++ b/apps/web/lib/admin/dogs/manage/dog-form-mappers.ts @@ -30,6 +30,7 @@ export function createEmptyAdminDogFormValues(): AdminDogFormValues { ownershipNames: [], ekNo: "", inbreedingCoefficientPct: null, + colorCode: "", note: "", registrationNo: "", secondaryRegistrationNos: [], @@ -52,6 +53,7 @@ export function mapAdminDogToFormValues( ownershipNames: dog.ownershipPreview, ekNo: dog.ekNo === null ? "" : String(dog.ekNo), inbreedingCoefficientPct: null, + colorCode: dog.colorCode === null ? "" : String(dog.colorCode), note: dog.note ?? "", registrationNo: dog.registrationNo ?? "", secondaryRegistrationNos: dog.secondaryRegistrationNos, @@ -93,6 +95,7 @@ export function mapAdminDogFromQuery(item: AdminDogListItem): AdminDogRecord { showCount: item.showCount, titlesText: item.titlesText, ekNo: item.ekNo, + colorCode: item.colorCode, note: item.note, titles: (item.titles ?? []).map((title) => ({ id: title.id, diff --git a/apps/web/lib/admin/dogs/manage/dog-mutation-flow.ts b/apps/web/lib/admin/dogs/manage/dog-mutation-flow.ts index 368b8af5..1f5605cd 100644 --- a/apps/web/lib/admin/dogs/manage/dog-mutation-flow.ts +++ b/apps/web/lib/admin/dogs/manage/dog-mutation-flow.ts @@ -44,6 +44,16 @@ function normalizeEkNo(value: string): number | null { return Number.isNaN(parsed) ? null : parsed; } +function normalizeColorCode(value: string): number | null { + const normalized = value.trim(); + if (normalized.length === 0) { + return null; + } + + const parsed = Number.parseInt(normalized, 10); + return Number.isNaN(parsed) ? null : parsed; +} + function normalizeSecondaryRegistrations(values: string[]): string[] { return values .map((value) => value.trim().toUpperCase()) @@ -74,6 +84,7 @@ export function toCreateAdminDogRequest( breederNameText: normalizeOptionalText(values.breederNameText) ?? undefined, ownerNames: values.ownershipNames, ekNo: normalizeEkNo(values.ekNo) ?? undefined, + colorCode: normalizeColorCode(values.colorCode), note: normalizeOptionalText(values.note) ?? undefined, registrationNo: values.registrationNo.trim(), secondaryRegistrationNos: normalizeSecondaryRegistrations( @@ -99,6 +110,7 @@ export function toUpdateAdminDogRequest( breederNameText: normalizeOptionalText(values.breederNameText), ownerNames: values.ownershipNames, ekNo: normalizeEkNo(values.ekNo), + colorCode: normalizeColorCode(values.colorCode), note: normalizeOptionalText(values.note), registrationNo: values.registrationNo.trim(), secondaryRegistrationNos: normalizeSecondaryRegistrations( @@ -147,6 +159,8 @@ export function getAdminDogMutationErrorMessageKey( return "admin.dogs.mutation.errorInvalidBirthDate"; case "INVALID_EK_NO": return "admin.dogs.mutation.errorInvalidEkNo"; + case "INVALID_COLOR_CODE": + return "admin.dogs.mutation.errorInvalidColorCode"; case "REGISTRATION_NO_TOO_LONG": return "admin.dogs.mutation.errorRegistrationTooLong"; case "NOTE_TOO_LONG": diff --git a/apps/web/lib/dogs/__tests__/color.test.ts b/apps/web/lib/dogs/__tests__/color.test.ts new file mode 100644 index 00000000..2ce74fc8 --- /dev/null +++ b/apps/web/lib/dogs/__tests__/color.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { formatDogColor } from "../color"; + +describe("formatDogColor", () => { + it("uses the requested language", () => { + const color = { + code: 121, + nameFi: "Kolmivärinen", + nameSv: "Trefärgad", + nameEn: null, + status: "SELECTABLE" as const, + }; + + expect(formatDogColor(color, "fi")).toBe("Kolmivärinen"); + expect(formatDogColor(color, "sv")).toBe("Trefärgad"); + }); + + it("includes the source code for an unnamed legacy color", () => { + expect( + formatDogColor( + { + code: 391, + nameFi: "Tuntematon väri", + nameSv: "Okänd färg", + nameEn: "Unknown color", + status: "LEGACY_UNKNOWN", + }, + "sv", + ), + ).toBe("Okänd färg (391)"); + }); +}); diff --git a/apps/web/lib/dogs/color.ts b/apps/web/lib/dogs/color.ts new file mode 100644 index 00000000..dc0551af --- /dev/null +++ b/apps/web/lib/dogs/color.ts @@ -0,0 +1,21 @@ +import type { DogColorDto } from "@beagle/contracts"; + +export function formatDogColor( + color: DogColorDto | null, + locale: "fi" | "sv", +): string | null { + if (!color) { + return null; + } + + const name = + locale === "sv" + ? (color.nameSv ?? color.nameFi ?? color.nameEn) + : (color.nameFi ?? color.nameSv ?? color.nameEn); + + if (color.status === "LEGACY_UNKNOWN") { + return `${name ?? String(color.code)} (${color.code})`; + } + + return name ?? String(color.code); +} diff --git a/apps/web/lib/i18n/messages/admin/dogs/common.ts b/apps/web/lib/i18n/messages/admin/dogs/common.ts index b7beee8f..a0dbd957 100644 --- a/apps/web/lib/i18n/messages/admin/dogs/common.ts +++ b/apps/web/lib/i18n/messages/admin/dogs/common.ts @@ -29,6 +29,7 @@ export const fiAdminDogsCommonMessages = { "Syntymäajan muoto on virheellinen.", "admin.dogs.mutation.errorInvalidEkNo": "EK-numero tulee olla positiivinen kokonaisluku.", + "admin.dogs.mutation.errorInvalidColorCode": "Valittua väriä ei löytynyt.", "admin.dogs.mutation.errorRegistrationTooLong": "Rekisterinumero on liian pitkä (max 40 merkkiä).", "admin.dogs.mutation.errorNoteTooLong": @@ -92,6 +93,8 @@ export const svAdminDogsCommonMessages = { "Fodelsedatum har ogiltigt format.", "admin.dogs.mutation.errorInvalidEkNo": "EK-nummer maste vara ett positivt heltal.", + "admin.dogs.mutation.errorInvalidColorCode": + "Den valda fargen hittades inte.", "admin.dogs.mutation.errorRegistrationTooLong": "Registreringsnumret ar for langt (max 40 tecken).", "admin.dogs.mutation.errorNoteTooLong": diff --git a/apps/web/lib/i18n/messages/admin/dogs/form.ts b/apps/web/lib/i18n/messages/admin/dogs/form.ts index 4a0040e8..9d9a1df7 100644 --- a/apps/web/lib/i18n/messages/admin/dogs/form.ts +++ b/apps/web/lib/i18n/messages/admin/dogs/form.ts @@ -37,6 +37,7 @@ export const fiAdminDogsFormMessages = { "admin.dogs.form.inbreedingCalculate": "Laske sukusiitosaste", "admin.dogs.form.inbreedingCalculating": "Lasketaan...", "admin.dogs.form.ekNoPlaceholder": "EK-numero", + "admin.dogs.form.colorSelectLabel": "Väri", "admin.dogs.form.notePlaceholder": "Muistiinpano", "admin.dogs.form.titlesLabel": "Tittelit", "admin.dogs.form.titlesAdd": "+ Lisää titteli", @@ -97,6 +98,7 @@ export const svAdminDogsFormMessages = { "admin.dogs.form.inbreedingCalculate": "Beräkna avelsgrad", "admin.dogs.form.inbreedingCalculating": "Beräknar...", "admin.dogs.form.ekNoPlaceholder": "EK-nummer", + "admin.dogs.form.colorSelectLabel": "Farg", "admin.dogs.form.notePlaceholder": "Anteckning", "admin.dogs.form.titlesLabel": "Titlar", "admin.dogs.form.titlesAdd": "+ Lagg till titel", diff --git a/apps/web/queries/admin/dogs/index.ts b/apps/web/queries/admin/dogs/index.ts index 47ccb877..f7b2e662 100644 --- a/apps/web/queries/admin/dogs/index.ts +++ b/apps/web/queries/admin/dogs/index.ts @@ -5,6 +5,7 @@ export * from "./manage/use-create-admin-dog-mutation"; export * from "./manage/use-delete-admin-dog-mutation"; export * from "./manage/use-update-admin-dog-mutation"; export * from "./lookups/use-admin-dog-breeder-options-query"; +export * from "./lookups/use-admin-dog-color-options-query"; export * from "./lookups/use-admin-dog-owner-options-query"; export * from "./lookups/use-admin-dog-parent-options-query"; export * from "./diseases"; diff --git a/apps/web/queries/admin/dogs/lookups/__tests__/use-admin-dog-color-options-query.test.ts b/apps/web/queries/admin/dogs/lookups/__tests__/use-admin-dog-color-options-query.test.ts new file mode 100644 index 00000000..8c55e3b0 --- /dev/null +++ b/apps/web/queries/admin/dogs/lookups/__tests__/use-admin-dog-color-options-query.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { adminDogColorOptionsQueryKey } from "@/queries/admin/dogs/manage/query-keys"; +import { useAdminDogColorOptionsQuery } from "../use-admin-dog-color-options-query"; + +const { + useQueryMock, + createAdminDogsApiClientMock, + listAdminDogColorOptionsMock, +} = vi.hoisted(() => ({ + useQueryMock: vi.fn(), + createAdminDogsApiClientMock: vi.fn(() => ({ + listAdminDogColorOptions: listAdminDogColorOptionsMock, + })), + listAdminDogColorOptionsMock: vi.fn(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: useQueryMock, +})); + +vi.mock("@beagle/api-client", () => ({ + createAdminDogsApiClient: createAdminDogsApiClientMock, +})); + +describe("useAdminDogColorOptionsQuery", () => { + beforeEach(() => { + useQueryMock.mockReset(); + listAdminDogColorOptionsMock.mockReset(); + }); + + it("uses expected query key and options", () => { + useQueryMock.mockImplementation((options) => options); + + useAdminDogColorOptionsQuery(false); + + const options = useQueryMock.mock.calls[0]?.[0] as { + queryKey: readonly unknown[]; + staleTime: number; + refetchOnWindowFocus: boolean; + enabled: boolean; + }; + + expect(options.queryKey).toEqual(adminDogColorOptionsQueryKey()); + expect(options.staleTime).toBe(300_000); + expect(options.refetchOnWindowFocus).toBe(true); + expect(options.enabled).toBe(false); + }); + + it("returns options when action succeeds", async () => { + useQueryMock.mockImplementation((options) => options); + listAdminDogColorOptionsMock.mockResolvedValue({ + ok: true, + data: { + items: [{ id: "dc_1", name: "Musta" }], + }, + }); + + useAdminDogColorOptionsQuery(); + const options = useQueryMock.mock.calls[0]?.[0] as { + queryFn: () => Promise; + }; + + await expect(options.queryFn()).resolves.toEqual([ + { id: "dc_1", name: "Musta" }, + ]); + }); + + it("throws when action returns error", async () => { + useQueryMock.mockImplementation((options) => options); + listAdminDogColorOptionsMock.mockResolvedValue({ + ok: false, + error: "Failed to load dog color options.", + code: "INTERNAL_ERROR", + }); + + useAdminDogColorOptionsQuery(); + const options = useQueryMock.mock.calls[0]?.[0] as { + queryFn: () => Promise; + }; + + await expect(options.queryFn()).rejects.toThrow( + "Failed to load dog color options.", + ); + }); +}); diff --git a/apps/web/queries/admin/dogs/lookups/use-admin-dog-color-options-query.ts b/apps/web/queries/admin/dogs/lookups/use-admin-dog-color-options-query.ts new file mode 100644 index 00000000..29816a8a --- /dev/null +++ b/apps/web/queries/admin/dogs/lookups/use-admin-dog-color-options-query.ts @@ -0,0 +1,25 @@ +"use client"; + +import { createAdminDogsApiClient } from "@beagle/api-client"; +import type { AdminDogColorLookupOption } from "@beagle/contracts"; +import { useQuery } from "@tanstack/react-query"; +import { adminDogColorOptionsQueryKey } from "@/queries/admin/dogs/manage/query-keys"; + +const adminDogsApiClient = createAdminDogsApiClient(); + +export function useAdminDogColorOptionsQuery(enabled = true) { + return useQuery({ + queryKey: adminDogColorOptionsQueryKey(), + queryFn: async () => { + const result = await adminDogsApiClient.listAdminDogColorOptions(); + if (!result.ok) { + throw new Error("Failed to load dog color options."); + } + + return result.data.items; + }, + staleTime: 300_000, + refetchOnWindowFocus: true, + enabled, + }); +} diff --git a/apps/web/queries/admin/dogs/manage/__tests__/use-admin-dogs-query.test.ts b/apps/web/queries/admin/dogs/manage/__tests__/use-admin-dogs-query.test.ts index cc25b0c3..5ab6e6f3 100644 --- a/apps/web/queries/admin/dogs/manage/__tests__/use-admin-dogs-query.test.ts +++ b/apps/web/queries/admin/dogs/manage/__tests__/use-admin-dogs-query.test.ts @@ -63,6 +63,7 @@ describe("useAdminDogsQuery", () => { showCount: 4, titlesText: null, ekNo: 5588, + colorCode: 121, note: null, }, ], @@ -94,6 +95,7 @@ describe("useAdminDogsQuery", () => { showCount: 4, titlesText: null, ekNo: 5588, + colorCode: 121, note: null, }, ], diff --git a/apps/web/queries/admin/dogs/manage/query-keys.ts b/apps/web/queries/admin/dogs/manage/query-keys.ts index eaadacf2..6d1471aa 100644 --- a/apps/web/queries/admin/dogs/manage/query-keys.ts +++ b/apps/web/queries/admin/dogs/manage/query-keys.ts @@ -13,6 +13,10 @@ export const adminDogParentOptionsQueryKeyRoot = [ "admin-dogs", "parent-options", ] as const; +export const adminDogColorOptionsQueryKeyRoot = [ + "admin-dogs", + "color-options", +] as const; export function adminDogsQueryKey(filters: AdminDogListRequest) { return [ @@ -36,3 +40,7 @@ export function adminDogOwnerOptionsQueryKey(query: string, limit: number) { export function adminDogParentOptionsQueryKey(query: string, limit: number) { return [...adminDogParentOptionsQueryKeyRoot, query, limit] as const; } + +export function adminDogColorOptionsQueryKey() { + return adminDogColorOptionsQueryKeyRoot; +} diff --git a/docs/features/admin-dog-management.md b/docs/features/admin-dog-management.md index 41099c81..9f50ba13 100644 --- a/docs/features/admin-dog-management.md +++ b/docs/features/admin-dog-management.md @@ -26,7 +26,7 @@ Developer notes for the admin dog management flow. 1. `AdminDogsPageClient` loads dog results with `useAdminDogsQuery`. 2. Admin dog search matches existing fields plus title row `titleCode` and `titleName`. 3. The page delegates modal/form state and submit/delete handlers to `useAdminDogFormFlow`. -4. Lookup queries for breeder/owner/parent options are enabled while the form modal is open. +4. Lookup queries for breeder/owner/parent/color options are enabled while the form modal is open. 5. Lookup response data and current form values are shaped via pure feature-local `lib` helpers before rendering `DogFormModal`. 6. `DogFormModal` delegates large form areas to private `internal/*` sections while keeping controlled form values in the parent flow. 7. Create/update/delete actions call admin dog mutation hooks, then rely on existing query invalidation behavior from those hooks. @@ -36,7 +36,8 @@ Developer notes for the admin dog management flow. - `AdminDogsPageClient` stays a composition shell. - Non-UI form flow logic stays in feature-local hook/lib modules. - `DogFormModal` remains presentational and receives prepared options/handlers as props. -- No contract/API payload shapes changed in this refactor. +- Admin create/update payloads include optional `colorCode`; the form lists only `SELECTABLE` `DogColor` rows in the active UI language. +- Existing hidden or legacy-unknown colors remain visible on edit and may be preserved, but cannot be newly assigned. ## Tests diff --git a/docs/features/beagle-dog-profile.md b/docs/features/beagle-dog-profile.md index 197f1bed..9f1de7f1 100644 --- a/docs/features/beagle-dog-profile.md +++ b/docs/features/beagle-dog-profile.md @@ -42,6 +42,7 @@ The public dog profile contract includes: - `shows[]` - `trials[]` - `titles[]` +- `color` - `offspringSummary` - `litters[]` - `siblingsSummary` @@ -52,9 +53,11 @@ Current note: - grouped litter data is already part of the profile contract and backend mapping - title rows are stored and rendered as structured row data (`awardedOn`, `titleCode`, `titleName`) - `inbreedingCoefficientPct` is derived dynamically from current pedigree ancestry in the public profile read path +- dog colors come from the multilingual `DogColor` lookup linked by `Dog.colorCode` and render in the active Finnish/Swedish locale +- legacy codes without a known name render as a localized unknown color with the original numeric code - the UI renders litters as grouped pentue blocks with summary counts, co-parent links, and puppy profile links - each litter uses the shared desktop/mobile listing pattern instead of a custom flat row list -- puppy rows currently include registration number, name, sex, EK number, trial count, show count, litter count, and a placeholder color column +- puppy and sibling rows include registration number, name, sex, color, EK number, trial count, show count, and litter count - siblings use the same row columns/pattern as litter puppy rows, without litter-group blocks ## Sibling resolution rules diff --git a/docs/features/schema/schema.md b/docs/features/schema/schema.md index 85725e60..5d83dbe5 100644 --- a/docs/features/schema/schema.md +++ b/docs/features/schema/schema.md @@ -65,7 +65,8 @@ erDiagram ### Core dog data -- `Dog`: central dog entity (pedigree links, breeder link, timestamps). +- `Dog`: central dog entity (pedigree links, breeder link, color code, timestamps). +- `DogColor`: multilingual dog color lookup referenced by `Dog.colorCode`; status distinguishes selectable, hidden, and unnamed legacy colors. - `DogRegistration`: unique registration numbers per dog. - `Breeder`: breeder registry and metadata. - `Owner`: normalized owner identity (`name + postalCode + city` unique). @@ -113,6 +114,7 @@ erDiagram - `BetterAuthUser -> BetterAuthSession/BetterAuthAccount`: `Cascade` - `BetterAuthUser -> ImportRun(createdByUser)`: `SetNull` - `Dog -> DogRegistration/DogOwnership/TrialResult`: `Cascade` +- `Dog -> DogColor`: `SetNull` for unknown or removed lookup values. - `Dog -> TrialEntry`: `SetNull` (allows trial rows without local dog) - `TrialEvent -> TrialEntry`: `Cascade` - `TrialEntry -> TrialEra`: `Cascade` @@ -128,6 +130,7 @@ erDiagram ## Identity and unique constraints (key ones) - `Dog.ekNo` unique +- `DogColor.code` primary key - `DogRegistration.registrationNo` unique - `TrialResult.sourceKey` unique - `TrialEvent.sklKoeId` unique (nullable in legacy phase2 fallback) diff --git a/docs/legacy-import/phase1.md b/docs/legacy-import/phase1.md index 7e832571..07411d23 100644 --- a/docs/legacy-import/phase1.md +++ b/docs/legacy-import/phase1.md @@ -27,6 +27,8 @@ Phase 1 imports foundation entities and link structures. It does not import tria - `SUKUP` -> `Dog.sex` - `SYNTY` -> `Dog.birthDate` - `KASVA` -> breeder text + breeder link candidate + - `COLCODE` -> `Dog.colorCode` +- the canonical typed dog-color catalog -> `DogColor` lookup rows before dog writes - `kennel` -> `Breeder` details (`name`, short code, city, granted/raw fields). - `bea_apu` -> `Dog.ekNo` by registration lookup. - `beaom` -> `Owner` + `DogOwnership` rows by registration lookup. @@ -36,6 +38,7 @@ Phase 1 imports foundation entities and link structures. It does not import tria ## Main writes - `Dog` +- `DogColor` - `DogRegistration` - `Breeder` - `Owner` @@ -60,6 +63,11 @@ Phase 1 imports foundation entities and link structures. It does not import tria - rows without `EKNO` do not update `Dog.ekNo` - malformed `registrationNo` values are still recorded as issues before the `EKNO` check - the phase log reports both the raw `bea_apu` row count and the subset that has a non-empty `EKNO` +- Color rows: + - the canonical typed catalog is seeded before dogs and is the single color label/status source + - legacy source codes without a known label use hidden `LEGACY_UNKNOWN` lookup rows, preserving the original code without making it selectable + - `bearek_id.COLCODE` values of `0`, empty, or null are treated as unknown + - invalid or codes outside the canonical catalog are reported as issues and stored as unknown ## Idempotency and rerun behavior diff --git a/docs/ops-env-safety.md b/docs/ops-env-safety.md index 72e47984..0d2949c1 100644 --- a/docs/ops-env-safety.md +++ b/docs/ops-env-safety.md @@ -205,6 +205,16 @@ pass-cli run --env-file .env.staging -- pnpm --filter @beagle/db seed:show-resul CONFIRM_PROD=YES pass-cli run --env-file .env.prod -- pnpm --filter @beagle/db seed:show-result-definitions ``` +Dog color seed (canonical registration colors): + +```bash +pass-cli run --env-file .env.local -- pnpm --filter @beagle/db seed:dog-colors +pass-cli run --env-file .env.staging -- pnpm --filter @beagle/db seed:dog-colors +CONFIRM_PROD=YES pass-cli run --env-file .env.prod -- pnpm --filter @beagle/db seed:dog-colors +``` + +Phase 1 runs this seed before importing dogs. The standalone command applies catalog updates without running the legacy import. + Show workbook import schema seed (Kennelliitto workbook metadata): ```bash diff --git a/docs/planning/dog-color-catalog-import.md b/docs/planning/dog-color-catalog-import.md new file mode 100644 index 00000000..b525a870 --- /dev/null +++ b/docs/planning/dog-color-catalog-import.md @@ -0,0 +1,20 @@ +# Dog Color Catalog Import + +## Goal + +Use one canonical multilingual dog-color catalog for legacy import, profile rendering, and admin dog registration while preserving unnamed historical source codes. + +## Decisions + +- `DogColorStatus` has `SELECTABLE`, `HIDDEN`, and `LEGACY_UNKNOWN` states. +- A typed TypeScript catalog is seeded before phase-1 dog writes; legacy `beacolor` is not a label source. +- Code `0` remains no color. +- Unnamed workbook codes are retained as hidden lookup rows and displayed with their numeric code. +- New assignments require `SELECTABLE`; an existing hidden value may be preserved or cleared. +- Public and admin profile contracts carry multilingual color data so locale changes do not require refetching. + +## Validation + +- Catalog contains 54 unique positive codes: 7 selectable, 21 hidden, and 26 legacy unknown. +- A fresh phase-1 import links every color code in the reviewed missing-code workbook. +- Finnish and Swedish profile/admin rendering is covered by tests. diff --git a/package.json b/package.json index a9bfce71..5380ab8d 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "db:deploy": "pnpm --filter @beagle/db exec prisma migrate deploy", "db:migrate:deploy": "pnpm db:deploy", "db:seed:initial-test-data": "pnpm --filter @beagle/db seed:initial-test-data", + "db:seed:dog-colors": "pnpm --filter @beagle/db seed:dog-colors", "db:dump": "bash ./scripts/db/dump.sh", "db:restore": "bash ./scripts/db/restore.sh", "legacy:restore": "bash ./scripts/db/restore-legacy.sh", diff --git a/packages/api-client/admin/dogs/__tests__/admin-dogs.test.ts b/packages/api-client/admin/dogs/__tests__/admin-dogs.test.ts new file mode 100644 index 00000000..78d9a61b --- /dev/null +++ b/packages/api-client/admin/dogs/__tests__/admin-dogs.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAdminDogsApiClient } from "../create-admin-dogs-api-client"; +import { listAdminDogColorOptions } from "../list-admin-dog-color-options"; + +describe("admin dogs api helpers", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("calls the color lookup endpoint", async () => { + const requestMock = vi.fn().mockResolvedValue({ ok: true, data: {} }); + + await listAdminDogColorOptions(requestMock); + + expect(requestMock).toHaveBeenCalledWith("/api/admin/dogs/lookups/colors", { + method: "GET", + }); + }); + + it("builds the color lookup endpoint via createAdminDogsApiClient", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ ok: true, data: { items: [] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const client = createAdminDogsApiClient({ + baseUrl: "http://example.test", + }); + + await client.listAdminDogColorOptions(); + + const [url, init] = fetchMock.mock.calls[0] ?? []; + expect(url).toBe("http://example.test/api/admin/dogs/lookups/colors"); + expect(init?.method).toBe("GET"); + }); +}); diff --git a/packages/api-client/admin/dogs/create-admin-dogs-api-client.ts b/packages/api-client/admin/dogs/create-admin-dogs-api-client.ts index c62a6e83..62a9e180 100644 --- a/packages/api-client/admin/dogs/create-admin-dogs-api-client.ts +++ b/packages/api-client/admin/dogs/create-admin-dogs-api-client.ts @@ -1,6 +1,7 @@ import type { ClientOptions } from "@api-client/core/client-options"; import { createRequest } from "@api-client/core/request"; import { getAdminDogProfile } from "@api-client/admin/dogs/get-admin-dog-profile"; +import { listAdminDogColorOptions as listAdminDogColorOptionsRequest } from "@api-client/admin/dogs/list-admin-dog-color-options"; import { listAdminDogDiseases as listAdminDogDiseasesRequest } from "@api-client/admin/dogs/list-admin-dog-diseases"; import type { AdminDogDiseaseBrowseRequest, @@ -15,6 +16,10 @@ export function createAdminDogsApiClient(options: ClientOptions = {}) { return getAdminDogProfile(request, input); }, + listAdminDogColorOptions() { + return listAdminDogColorOptionsRequest(request); + }, + listAdminDogDiseases(input: AdminDogDiseaseBrowseRequest = {}) { return listAdminDogDiseasesRequest(request, input); }, diff --git a/packages/api-client/admin/dogs/index.ts b/packages/api-client/admin/dogs/index.ts index 016cf8dc..bbf72696 100644 --- a/packages/api-client/admin/dogs/index.ts +++ b/packages/api-client/admin/dogs/index.ts @@ -1,3 +1,4 @@ export { createAdminDogsApiClient } from "./create-admin-dogs-api-client"; export { getAdminDogProfile } from "./get-admin-dog-profile"; +export { listAdminDogColorOptions } from "./list-admin-dog-color-options"; export { listAdminDogDiseases } from "./list-admin-dog-diseases"; diff --git a/packages/api-client/admin/dogs/list-admin-dog-color-options.ts b/packages/api-client/admin/dogs/list-admin-dog-color-options.ts new file mode 100644 index 00000000..25e9f072 --- /dev/null +++ b/packages/api-client/admin/dogs/list-admin-dog-color-options.ts @@ -0,0 +1,11 @@ +import type { AdminDogColorLookupResponse } from "@beagle/contracts"; +import type { RequestFn } from "@api-client/core/request"; + +export function listAdminDogColorOptions(request: RequestFn) { + return request( + "/api/admin/dogs/lookups/colors", + { + method: "GET", + }, + ); +} diff --git a/packages/contracts/admin/dogs/lookups/index.ts b/packages/contracts/admin/dogs/lookups/index.ts index aa29a7e4..4bc6dc47 100644 --- a/packages/contracts/admin/dogs/lookups/index.ts +++ b/packages/contracts/admin/dogs/lookups/index.ts @@ -3,7 +3,9 @@ export type { AdminBreederLookupOption, AdminOwnerLookupOption, AdminDogParentLookupOption, + AdminDogColorLookupOption, AdminBreederLookupResponse, AdminOwnerLookupResponse, AdminDogParentLookupResponse, + AdminDogColorLookupResponse, } from "./lookup-options"; diff --git a/packages/contracts/admin/dogs/lookups/lookup-options.ts b/packages/contracts/admin/dogs/lookups/lookup-options.ts index 3f7bf5fc..525362d6 100644 --- a/packages/contracts/admin/dogs/lookups/lookup-options.ts +++ b/packages/contracts/admin/dogs/lookups/lookup-options.ts @@ -1,4 +1,5 @@ import type { AdminDogListSex } from "../manage/admin-dogs-list"; +import type { DogColorDto } from "@contracts/dogs/colors"; export type AdminDogLookupRequest = { query?: string; @@ -22,6 +23,8 @@ export type AdminDogParentLookupOption = { registrationNo: string | null; }; +export type AdminDogColorLookupOption = DogColorDto; + export type AdminBreederLookupResponse = { items: AdminBreederLookupOption[]; }; @@ -33,3 +36,7 @@ export type AdminOwnerLookupResponse = { export type AdminDogParentLookupResponse = { items: AdminDogParentLookupOption[]; }; + +export type AdminDogColorLookupResponse = { + items: AdminDogColorLookupOption[]; +}; diff --git a/packages/contracts/admin/dogs/manage/admin-dogs-list.ts b/packages/contracts/admin/dogs/manage/admin-dogs-list.ts index bfe3d81c..ef435fe3 100644 --- a/packages/contracts/admin/dogs/manage/admin-dogs-list.ts +++ b/packages/contracts/admin/dogs/manage/admin-dogs-list.ts @@ -36,6 +36,7 @@ export type AdminDogListItem = { showCount: number; titlesText: string | null; ekNo: number | null; + colorCode: number | null; note: string | null; titles?: AdminDogTitleItem[]; }; diff --git a/packages/contracts/admin/dogs/manage/create-admin-dog.ts b/packages/contracts/admin/dogs/manage/create-admin-dog.ts index 9dbf08cc..68e52c96 100644 --- a/packages/contracts/admin/dogs/manage/create-admin-dog.ts +++ b/packages/contracts/admin/dogs/manage/create-admin-dog.ts @@ -7,6 +7,7 @@ export type CreateAdminDogRequest = { breederNameText?: string; ownerNames?: string[]; ekNo?: number; + colorCode?: number | null; note?: string; registrationNo: string; secondaryRegistrationNos?: string[]; diff --git a/packages/contracts/admin/dogs/manage/update-admin-dog.ts b/packages/contracts/admin/dogs/manage/update-admin-dog.ts index ec08984c..f652eb4f 100644 --- a/packages/contracts/admin/dogs/manage/update-admin-dog.ts +++ b/packages/contracts/admin/dogs/manage/update-admin-dog.ts @@ -8,6 +8,7 @@ export type UpdateAdminDogRequest = { breederNameText?: string | null; ownerNames?: string[]; ekNo?: number | null; + colorCode?: number | null; note?: string | null; registrationNo: string; secondaryRegistrationNos?: string[]; diff --git a/packages/contracts/admin/dogs/profile/admin-dog-profile.ts b/packages/contracts/admin/dogs/profile/admin-dog-profile.ts index 864f7591..06b1f2e5 100644 --- a/packages/contracts/admin/dogs/profile/admin-dog-profile.ts +++ b/packages/contracts/admin/dogs/profile/admin-dog-profile.ts @@ -1,3 +1,5 @@ +import type { DogColorDto } from "@contracts/dogs/colors"; + export type AdminDogProfileRequest = { dogId: string; }; @@ -39,7 +41,7 @@ export type AdminDogProfileDto = { registrationNos: string[]; birthDate: string | null; sex: AdminDogProfileSex; - color: string | null; + color: DogColorDto | null; ekNo: number | null; offspringCount: number; offspringLitterCount: number; diff --git a/packages/contracts/dogs/colors/dog-color.ts b/packages/contracts/dogs/colors/dog-color.ts new file mode 100644 index 00000000..5b236109 --- /dev/null +++ b/packages/contracts/dogs/colors/dog-color.ts @@ -0,0 +1,9 @@ +export type DogColorStatus = "SELECTABLE" | "HIDDEN" | "LEGACY_UNKNOWN"; + +export type DogColorDto = { + code: number; + nameFi: string; + nameSv: string | null; + nameEn: string | null; + status: DogColorStatus; +}; diff --git a/packages/contracts/dogs/colors/index.ts b/packages/contracts/dogs/colors/index.ts new file mode 100644 index 00000000..350ddc62 --- /dev/null +++ b/packages/contracts/dogs/colors/index.ts @@ -0,0 +1 @@ +export type { DogColorDto, DogColorStatus } from "./dog-color"; diff --git a/packages/contracts/dogs/index.ts b/packages/contracts/dogs/index.ts index e721e815..ad97c4a4 100644 --- a/packages/contracts/dogs/index.ts +++ b/packages/contracts/dogs/index.ts @@ -1,3 +1,4 @@ +export type { DogColorDto, DogColorStatus } from "./colors"; export type { BeagleSearchMode, BeagleSearchRequest, diff --git a/packages/contracts/dogs/profile/beagle-profile.ts b/packages/contracts/dogs/profile/beagle-profile.ts index a5e7d281..a33495a2 100644 --- a/packages/contracts/dogs/profile/beagle-profile.ts +++ b/packages/contracts/dogs/profile/beagle-profile.ts @@ -1,4 +1,5 @@ import type { BeagleShowStructuredResultDto } from "@contracts/shows/beagle-shows"; +import type { DogColorDto } from "../colors"; export type BeagleDogProfileSex = "U" | "N" | "-"; @@ -70,6 +71,7 @@ export type BeagleDogProfileOffspringRowDto = { trialCount: number; showCount: number; litterCount: number; + color: DogColorDto | null; }; export type BeagleDogProfileLitterDto = { @@ -94,6 +96,7 @@ export type BeagleDogProfileSiblingRowDto = { trialCount: number; showCount: number; litterCount: number; + color: DogColorDto | null; }; export type BeagleDogProfileDto = { @@ -104,7 +107,7 @@ export type BeagleDogProfileDto = { registrationNos: string[]; birthDate: string | null; sex: BeagleDogProfileSex; - color: string | null; + color: DogColorDto | null; ekNo: number | null; inbreedingCoefficientPct: number | null; sire: BeagleDogProfileParentDto | null; diff --git a/packages/contracts/index.ts b/packages/contracts/index.ts index 18a7e00e..7e1b7102 100644 --- a/packages/contracts/index.ts +++ b/packages/contracts/index.ts @@ -94,6 +94,8 @@ export type ImportRunIssuesResponse = { export type { BeagleNewestRequest, BeagleNewestResponse, + DogColorDto, + DogColorStatus, BeagleSearchMode, BeagleSearchRequest, BeagleSearchResponse, @@ -212,6 +214,7 @@ export type { AdminBreederLookupOption, AdminOwnerLookupOption, AdminDogParentLookupOption, + AdminDogColorLookupOption, AdminBreederLookupResponse, AdminOwnerLookupResponse, AdminDogParentLookupResponse, @@ -229,6 +232,7 @@ export type { AdminVirtualPairingDiagnosticsDto, CalculateAdminVirtualPairingRequest, CalculateAdminVirtualPairingResponse, + AdminDogColorLookupResponse, AdminShowDetailsEvent, AdminShowDetailsRequest, AdminShowDetailsResponse, diff --git a/packages/db/admin/dogs/lookups/find-color-option.ts b/packages/db/admin/dogs/lookups/find-color-option.ts new file mode 100644 index 00000000..11104f70 --- /dev/null +++ b/packages/db/admin/dogs/lookups/find-color-option.ts @@ -0,0 +1,8 @@ +import { prisma } from "@db/core/prisma"; + +export async function findAdminDogColorOptionDb(code: number) { + return prisma.dogColor.findUnique({ + where: { code }, + select: { code: true, status: true }, + }); +} diff --git a/packages/db/admin/dogs/lookups/index.ts b/packages/db/admin/dogs/lookups/index.ts index 1a03cca2..62b75373 100644 --- a/packages/db/admin/dogs/lookups/index.ts +++ b/packages/db/admin/dogs/lookups/index.ts @@ -16,3 +16,9 @@ export { type AdminDogParentLookupOptionDb, type AdminDogParentLookupResponseDb, } from "./list-parent-options"; +export { + listAdminDogColorOptionsDb, + type AdminDogColorLookupOptionDb, + type AdminDogColorLookupResponseDb, +} from "./list-color-options"; +export { findAdminDogColorOptionDb } from "./find-color-option"; diff --git a/packages/db/admin/dogs/lookups/list-color-options.ts b/packages/db/admin/dogs/lookups/list-color-options.ts new file mode 100644 index 00000000..7567f1a0 --- /dev/null +++ b/packages/db/admin/dogs/lookups/list-color-options.ts @@ -0,0 +1,28 @@ +import { prisma } from "@db/core/prisma"; + +export type AdminDogColorLookupOptionDb = { + code: number; + nameFi: string; + nameSv: string | null; + nameEn: string | null; + status: "SELECTABLE" | "HIDDEN" | "LEGACY_UNKNOWN"; +}; + +export type AdminDogColorLookupResponseDb = { + items: AdminDogColorLookupOptionDb[]; +}; + +export async function listAdminDogColorOptionsDb(): Promise { + const rows = await prisma.dogColor.findMany({ + select: { + code: true, + nameFi: true, + nameSv: true, + nameEn: true, + status: true, + }, + orderBy: { code: "asc" }, + }); + + return { items: rows }; +} diff --git a/packages/db/admin/dogs/manage/__tests__/create-dog.test.ts b/packages/db/admin/dogs/manage/__tests__/create-dog.test.ts index 189a5daa..e436b28b 100644 --- a/packages/db/admin/dogs/manage/__tests__/create-dog.test.ts +++ b/packages/db/admin/dogs/manage/__tests__/create-dog.test.ts @@ -61,6 +61,7 @@ describe("createAdminDogWriteDb", () => { damId: null, ownerNames: ["Owner One"], ekNo: null, + colorCode: null, note: null, registrationNo: "FI12345/21", titles: [ @@ -112,6 +113,7 @@ describe("createAdminDogWriteDb", () => { damId: null, ownerNames: [], ekNo: null, + colorCode: null, note: null, registrationNo: "FI12345/21", }, diff --git a/packages/db/admin/dogs/manage/create-dog.ts b/packages/db/admin/dogs/manage/create-dog.ts index da03605e..65a18424 100644 --- a/packages/db/admin/dogs/manage/create-dog.ts +++ b/packages/db/admin/dogs/manage/create-dog.ts @@ -14,6 +14,7 @@ export type CreateAdminDogDbInput = { damId: string | null; ownerNames: string[]; ekNo: number | null; + colorCode: number | null; note: string | null; registrationNo: string; secondaryRegistrationNos?: string[]; @@ -123,6 +124,7 @@ async function createAdminDogDb( damId: input.damId, breederId, ekNo: input.ekNo, + colorCode: input.colorCode, note: input.note, }, select: { diff --git a/packages/db/admin/dogs/manage/find-dog-by-id.ts b/packages/db/admin/dogs/manage/find-dog-by-id.ts index 4c44329f..bfc30874 100644 --- a/packages/db/admin/dogs/manage/find-dog-by-id.ts +++ b/packages/db/admin/dogs/manage/find-dog-by-id.ts @@ -9,6 +9,7 @@ function resolveDbClient(dbClient?: AdminDogDbClient): AdminDogDbClient { export type DogByIdLookupDb = { id: string; + colorCode: number | null; sire: { id: string; sex: DogSex } | null; dam: { id: string; sex: DogSex } | null; }; @@ -21,6 +22,7 @@ export async function findDogByIdDb( where: { id }, select: { id: true, + colorCode: true, sire: { select: { id: true, @@ -42,6 +44,7 @@ export async function findDogByIdDb( return { id: row.id, + colorCode: row.colorCode, sire: row.sire ? { id: row.sire.id, sex: row.sire.sex } : null, dam: row.dam ? { id: row.dam.id, sex: row.dam.sex } : null, }; diff --git a/packages/db/admin/dogs/manage/list-dogs.ts b/packages/db/admin/dogs/manage/list-dogs.ts index 8298f8ca..68a7f42c 100644 --- a/packages/db/admin/dogs/manage/list-dogs.ts +++ b/packages/db/admin/dogs/manage/list-dogs.ts @@ -41,6 +41,7 @@ export type AdminDogListRowDb = { showCount: number; titlesText: string | null; ekNo: number | null; + colorCode: number | null; note: string | null; titles: AdminDogTitleItemDb[]; }; @@ -218,6 +219,7 @@ export async function listAdminDogsDb( breederNameText: true, note: true, ekNo: true, + colorCode: true, breeder: { select: { name: true, @@ -323,6 +325,7 @@ export async function listAdminDogsDb( showCount: row._count.showEntries, titlesText: titleCodes.length > 0 ? titleCodes.join(", ") : null, ekNo: row.ekNo, + colorCode: row.colorCode, note: row.note, titles: row.titles.map((title) => ({ id: title.id, diff --git a/packages/db/admin/dogs/manage/update-dog.ts b/packages/db/admin/dogs/manage/update-dog.ts index e34a0d8d..01b7d96b 100644 --- a/packages/db/admin/dogs/manage/update-dog.ts +++ b/packages/db/admin/dogs/manage/update-dog.ts @@ -11,6 +11,7 @@ export type UpdateAdminDogDbInput = { damId: string | null | undefined; ownerNames?: string[]; ekNo?: number | null; + colorCode?: number | null; note?: string | null; registrationNo: string; secondaryRegistrationNos?: string[]; @@ -312,6 +313,7 @@ export async function updateAdminDogWriteDb( ...(input.sireId === undefined ? {} : { sireId: input.sireId }), ...(input.damId === undefined ? {} : { damId: input.damId }), ...(input.ekNo === undefined ? {} : { ekNo: input.ekNo }), + ...(input.colorCode === undefined ? {} : { colorCode: input.colorCode }), ...(input.note === undefined ? {} : { note: input.note }), }, select: { diff --git a/packages/db/admin/dogs/profile/internal/profile-db-mappers.ts b/packages/db/admin/dogs/profile/internal/profile-db-mappers.ts index bcd2f8dc..b4947170 100644 --- a/packages/db/admin/dogs/profile/internal/profile-db-mappers.ts +++ b/packages/db/admin/dogs/profile/internal/profile-db-mappers.ts @@ -32,7 +32,7 @@ export function mapAdminDogProfileDbRow( registrationNos: dog.registrations, birthDate: dog.birthDate, sex: dog.sex, - color: null, + color: dog.color, ekNo: dog.ekNo, sire: dog.sire ? { diff --git a/packages/db/admin/dogs/profile/internal/profile-select.ts b/packages/db/admin/dogs/profile/internal/profile-select.ts index 8e54329d..a778b8e7 100644 --- a/packages/db/admin/dogs/profile/internal/profile-select.ts +++ b/packages/db/admin/dogs/profile/internal/profile-select.ts @@ -12,6 +12,7 @@ export const adminDogProfileSelect = Prisma.validator()({ birthDate: true, sex: true, ekNo: true, + color: true, sire: { select: { id: true, @@ -40,6 +41,7 @@ export const adminDogProfileSelect = Prisma.validator()({ }, whelpedPuppies: { include: { + color: true, registrations: true, sire: { include: { registrations: true } }, dam: { include: { registrations: true } }, @@ -89,6 +91,7 @@ export const adminDogProfileSelect = Prisma.validator()({ }, siredPuppies: { include: { + color: true, registrations: true, sire: { include: { registrations: true } }, dam: { include: { registrations: true } }, diff --git a/packages/db/admin/dogs/profile/types.ts b/packages/db/admin/dogs/profile/types.ts index b22371e8..70479c84 100644 --- a/packages/db/admin/dogs/profile/types.ts +++ b/packages/db/admin/dogs/profile/types.ts @@ -1,4 +1,5 @@ import type { + DogColorDb, OffspringDogNode, ParentDogNode, RegistrationNode, @@ -33,7 +34,7 @@ export type AdminDogProfileDb = { registrationNos: RegistrationNode[]; birthDate: Date | null; sex: DogSex; - color: string | null; + color: DogColorDb | null; ekNo: number | null; sire: ParentDogNode | null; dam: ParentDogNode | null; diff --git a/packages/db/dogs/colors/__tests__/definitions.test.ts b/packages/db/dogs/colors/__tests__/definitions.test.ts new file mode 100644 index 00000000..04643a32 --- /dev/null +++ b/packages/db/dogs/colors/__tests__/definitions.test.ts @@ -0,0 +1,36 @@ +import { DogColorStatus } from "@prisma/client"; +import { describe, expect, it } from "vitest"; +import { DOG_COLOR_DEFINITIONS } from "../definitions"; + +describe("DOG_COLOR_DEFINITIONS", () => { + it("contains the complete canonical and legacy color catalog", () => { + const codes = DOG_COLOR_DEFINITIONS.map((definition) => definition.code); + const counts = DOG_COLOR_DEFINITIONS.reduce>( + (result, definition) => ({ + ...result, + [definition.status]: (result[definition.status] ?? 0) + 1, + }), + {}, + ); + + expect(DOG_COLOR_DEFINITIONS).toHaveLength(54); + expect(new Set(codes).size).toBe(54); + expect(codes).not.toContain(0); + expect(counts).toEqual({ + [DogColorStatus.SELECTABLE]: 7, + [DogColorStatus.HIDDEN]: 21, + [DogColorStatus.LEGACY_UNKNOWN]: 26, + }); + expect( + DOG_COLOR_DEFINITIONS.filter( + (definition) => definition.status === DogColorStatus.SELECTABLE, + ).map((definition) => definition.code), + ).toEqual([886, 207, 121, 123, 125, 252, 539]); + expect( + DOG_COLOR_DEFINITIONS.every( + (definition) => + definition.nameFi.length > 0 && definition.nameSv.length > 0, + ), + ).toBe(true); + }); +}); diff --git a/packages/db/dogs/colors/__tests__/seed-dog-colors.test.ts b/packages/db/dogs/colors/__tests__/seed-dog-colors.test.ts new file mode 100644 index 00000000..a0cd6161 --- /dev/null +++ b/packages/db/dogs/colors/__tests__/seed-dog-colors.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { seedDogColorsDb } from "../seed-dog-colors"; + +const { dogColorUpsertMock } = vi.hoisted(() => ({ + dogColorUpsertMock: vi.fn(), +})); + +vi.mock("@db/core/prisma", () => ({ + prisma: { + dogColor: { upsert: dogColorUpsertMock }, + }, +})); + +describe("seedDogColorsDb", () => { + beforeEach(() => { + dogColorUpsertMock.mockReset(); + dogColorUpsertMock.mockResolvedValue(undefined); + }); + + it("upserts every canonical definition and returns its code set", async () => { + const result = await seedDogColorsDb(); + + expect(dogColorUpsertMock).toHaveBeenCalledTimes(54); + expect(result.codes).toHaveLength(54); + expect(dogColorUpsertMock).toHaveBeenCalledWith( + expect.objectContaining({ + where: { code: 121 }, + update: expect.objectContaining({ status: "SELECTABLE" }), + }), + ); + }); +}); diff --git a/packages/db/dogs/colors/definitions.ts b/packages/db/dogs/colors/definitions.ts new file mode 100644 index 00000000..8caf9940 --- /dev/null +++ b/packages/db/dogs/colors/definitions.ts @@ -0,0 +1,109 @@ +import { DogColorStatus } from "@prisma/client"; + +export type DogColorDefinition = { + code: number; + nameFi: string; + nameSv: string; + nameEn: string | null; + status: DogColorStatus; +}; + +const official = ( + code: number, + nameFi: string, + nameSv: string, + status: DogColorStatus, + nameEn: string | null = null, +): DogColorDefinition => ({ code, nameFi, nameSv, nameEn, status }); + +const unknown = (code: number): DogColorDefinition => ({ + code, + nameFi: "Tuntematon väri", + nameSv: "Okänd färg", + nameEn: "Unknown color", + status: DogColorStatus.LEGACY_UNKNOWN, +}); + +// Canonical registration colors plus explicit placeholders for legacy source codes. +export const DOG_COLOR_DEFINITIONS = [ + official( + 886, + "Hare pied", + "Hare pied", + DogColorStatus.SELECTABLE, + "Hare pied", + ), + official(112, "Harmaa", "Grå", DogColorStatus.HIDDEN), + official(118, "Harmaapäistärikkö", "Gråskimmel", DogColorStatus.HIDDEN), + official(207, "Kaksivärinen", "Tvåfärgad", DogColorStatus.SELECTABLE), + official(122, "Keltavalkoinen", "Gul-vit", DogColorStatus.HIDDEN), + official(121, "Kolmivärinen", "Trefärgad", DogColorStatus.SELECTABLE), + official( + 312, + "Lemon roan", + "Lemon roan", + DogColorStatus.HIDDEN, + "Lemon roan", + ), + official( + 147, + "Lemon white", + "Lemon white", + DogColorStatus.HIDDEN, + "Lemon white", + ), + official(150, "Maksa-valko-tan", "Lever-vit-tan", DogColorStatus.HIDDEN), + official(100, "Musta", "Svart", DogColorStatus.HIDDEN), + official(471, "Mustaruskea", "Svartbrun", DogColorStatus.HIDDEN), + official(211, "Musta-ruskea", "Svart-brun", DogColorStatus.HIDDEN), + official( + 706, + "Musta-ruskea-valkoinen", + "Svart-brun-vit", + DogColorStatus.HIDDEN, + ), + official(123, "Mustavalkoinen", "Svartvit", DogColorStatus.SELECTABLE), + official(741, "Musta-valkoinen", "Svart-vit", DogColorStatus.HIDDEN), + official( + 308, + "Mustavalkoinen täplikäs", + "Svartvit fläckig", + DogColorStatus.HIDDEN, + ), + official( + 229, + "Musta-valkoinen-ruskea", + "Svart-vit-brun", + DogColorStatus.HIDDEN, + ), + official(119, "Punavalkoinen", "Rödvit", DogColorStatus.HIDDEN), + official(181, "Rotumääritelmän mukainen", "Standard", DogColorStatus.HIDDEN), + official(125, "Ruskea-valkoinen", "Brun-vit", DogColorStatus.SELECTABLE), + official(252, "Sininen", "Blå", DogColorStatus.SELECTABLE), + official(493, "Tricolour", "Tricolour", DogColorStatus.HIDDEN, "Tricolour"), + official(750, "Valkoinen", "Vit", DogColorStatus.HIDDEN), + official( + 539, + "Valkoinen ruskein laikuin", + "Vit med bruna fläckar", + DogColorStatus.SELECTABLE, + ), + official(348, "Valkoinen-musta", "Vit-svart", DogColorStatus.HIDDEN), + official( + 809, + "Valkoinen-musta-ruskea", + "Vit-svart-brun", + DogColorStatus.HIDDEN, + ), + official(374, "Valkoinen-ruskea", "Vit-brun", DogColorStatus.HIDDEN), + official( + 962, + "Musta-ruskea-valkoinen", + "Svart-brun-vit", + DogColorStatus.HIDDEN, + ), + ...[ + 50, 106, 107, 108, 130, 153, 175, 240, 251, 301, 379, 391, 398, 416, 419, + 422, 511, 512, 617, 624, 647, 751, 767, 784, 858, 891, + ].map(unknown), +] satisfies DogColorDefinition[]; diff --git a/packages/db/dogs/colors/index.ts b/packages/db/dogs/colors/index.ts new file mode 100644 index 00000000..ed92b31c --- /dev/null +++ b/packages/db/dogs/colors/index.ts @@ -0,0 +1 @@ +export { seedDogColorsDb } from "./seed-dog-colors"; diff --git a/packages/db/dogs/colors/seed-dog-colors.ts b/packages/db/dogs/colors/seed-dog-colors.ts new file mode 100644 index 00000000..5c7d5543 --- /dev/null +++ b/packages/db/dogs/colors/seed-dog-colors.ts @@ -0,0 +1,19 @@ +import { prisma } from "@db/core/prisma"; +import { DOG_COLOR_DEFINITIONS } from "./definitions"; + +export async function seedDogColorsDb(): Promise<{ codes: number[] }> { + for (const definition of DOG_COLOR_DEFINITIONS) { + await prisma.dogColor.upsert({ + where: { code: definition.code }, + create: definition, + update: { + nameFi: definition.nameFi, + nameSv: definition.nameSv, + nameEn: definition.nameEn, + status: definition.status, + }, + }); + } + + return { codes: DOG_COLOR_DEFINITIONS.map((definition) => definition.code) }; +} diff --git a/packages/db/dogs/index.ts b/packages/db/dogs/index.ts index dd8528b6..00a33ed7 100644 --- a/packages/db/dogs/index.ts +++ b/packages/db/dogs/index.ts @@ -37,6 +37,7 @@ export { type DogEpiDiseaseFactDb, } from "./core/epi-disease-facts"; export { getNewestBeagleDogsDb } from "./newest"; +export { seedDogColorsDb } from "./colors"; export { searchBeagleDogsDb, type BeagleSearchModeDb, diff --git a/packages/db/dogs/profile/get-beagle-dog-profile.ts b/packages/db/dogs/profile/get-beagle-dog-profile.ts index 6c419acf..9dcd54cb 100644 --- a/packages/db/dogs/profile/get-beagle-dog-profile.ts +++ b/packages/db/dogs/profile/get-beagle-dog-profile.ts @@ -71,7 +71,7 @@ export async function getBeagleDogProfileDb( ), birthDate: dog.birthDate, sex, - color: null, + color: dog.color, ekNo: dog.ekNo, sire: mapParent(dog.sire), dam: mapParent(dog.dam), diff --git a/packages/db/dogs/profile/internal/offspring-litters.ts b/packages/db/dogs/profile/internal/offspring-litters.ts index b83d6ba9..5c9975fe 100644 --- a/packages/db/dogs/profile/internal/offspring-litters.ts +++ b/packages/db/dogs/profile/internal/offspring-litters.ts @@ -173,6 +173,7 @@ function mapOffspringRow( trialCount: puppy._count.trialEntries, showCount: puppy._count.showEntries, litterCount: countOffspringLitters(puppy), + color: puppy.color, }; } diff --git a/packages/db/dogs/profile/internal/profile-base-query.ts b/packages/db/dogs/profile/internal/profile-base-query.ts index 9ef633f0..bb7af8c5 100644 --- a/packages/db/dogs/profile/internal/profile-base-query.ts +++ b/packages/db/dogs/profile/internal/profile-base-query.ts @@ -47,6 +47,7 @@ export async function getDogProfileBaseRow(dogId: string) { }, whelpedPuppies: { include: { + color: true, registrations: true, sire: { include: { registrations: true } }, dam: { include: { registrations: true } }, @@ -96,6 +97,7 @@ export async function getDogProfileBaseRow(dogId: string) { }, siredPuppies: { include: { + color: true, registrations: true, sire: { include: { registrations: true } }, dam: { include: { registrations: true } }, @@ -154,6 +156,15 @@ export async function getDogProfileBaseRow(dogId: string) { }, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }], }, + color: { + select: { + code: true, + nameFi: true, + nameSv: true, + nameEn: true, + status: true, + }, + }, }, }); } diff --git a/packages/db/dogs/profile/internal/profile-siblings-query.ts b/packages/db/dogs/profile/internal/profile-siblings-query.ts index 52258e28..57e6a484 100644 --- a/packages/db/dogs/profile/internal/profile-siblings-query.ts +++ b/packages/db/dogs/profile/internal/profile-siblings-query.ts @@ -7,6 +7,7 @@ import { } from "./profile-siblings"; const siblingCandidateInclude = { + color: true, registrations: true, sire: { include: { registrations: true } }, dam: { include: { registrations: true } }, diff --git a/packages/db/dogs/profile/internal/profile-siblings.ts b/packages/db/dogs/profile/internal/profile-siblings.ts index d5ffa841..e684c838 100644 --- a/packages/db/dogs/profile/internal/profile-siblings.ts +++ b/packages/db/dogs/profile/internal/profile-siblings.ts @@ -190,6 +190,7 @@ function mapSiblingRow(dog: OffspringDogNode): BeagleDogProfileSiblingRowDb { trialCount: dog._count.trialEntries, showCount: dog._count.showEntries, litterCount: countOffspringLitters(dog), + color: dog.color, }; } diff --git a/packages/db/dogs/profile/internal/profile-types.ts b/packages/db/dogs/profile/internal/profile-types.ts index 92778390..99a396de 100644 --- a/packages/db/dogs/profile/internal/profile-types.ts +++ b/packages/db/dogs/profile/internal/profile-types.ts @@ -1,6 +1,14 @@ // Internal dog profile DB types for Prisma row shaping and profile mapping. // Public DB DTO types are re-exported through the profile entrypoint. -import { DogSex } from "@prisma/client"; +import { DogColorStatus, DogSex } from "@prisma/client"; + +export type DogColorDb = { + code: number; + nameFi: string; + nameSv: string | null; + nameEn: string | null; + status: DogColorStatus; +}; export type BeagleDogProfileSexDb = "U" | "N" | "-"; @@ -37,6 +45,7 @@ export type BeagleDogProfileOffspringRowDb = { trialCount: number; showCount: number; litterCount: number; + color: DogColorDb | null; }; export type BeagleDogProfileLitterDb = { @@ -61,6 +70,7 @@ export type BeagleDogProfileSiblingRowDb = { trialCount: number; showCount: number; litterCount: number; + color: DogColorDb | null; }; export type BeagleDogProfileTitleDb = { @@ -77,7 +87,7 @@ export type BeagleDogProfileDb = { registrationNos: string[]; birthDate: Date | null; sex: BeagleDogProfileSexDb; - color: string | null; + color: DogColorDb | null; ekNo: number | null; sire: BeagleDogProfileParentDb | null; dam: BeagleDogProfileParentDb | null; @@ -112,6 +122,7 @@ export type OffspringDogNode = { sex: DogSex; birthDate: Date | null; ekNo?: number | null; + color: DogColorDb | null; registrations: RegistrationNode[]; sire: ParentDogNode | null; dam: ParentDogNode | null; diff --git a/packages/db/imports/phase1/__tests__/source.test.ts b/packages/db/imports/phase1/__tests__/source.test.ts index 30d48233..67c129ff 100644 --- a/packages/db/imports/phase1/__tests__/source.test.ts +++ b/packages/db/imports/phase1/__tests__/source.test.ts @@ -17,10 +17,16 @@ describe("fetchLegacyPhase1Rows", () => { it("logs raw bea_apu rows and the subset with EKNO", async () => { const log = vi.fn(); const connection = { - query: vi.fn().mockResolvedValue([ - { registrationNo: "FI-1/20", ekNo: 123 }, - { registrationNo: "FI-2/20", ekNo: null }, - ]), + query: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { registrationNo: "FI-1/20", ekNo: 123 }, + { registrationNo: "FI-2/20", ekNo: null }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]), end: vi.fn().mockResolvedValue(undefined), }; connectLegacyDatabaseMock.mockResolvedValue(connection); diff --git a/packages/db/imports/phase1/source.ts b/packages/db/imports/phase1/source.ts index cdd09a8c..fd53bf91 100644 --- a/packages/db/imports/phase1/source.ts +++ b/packages/db/imports/phase1/source.ts @@ -27,7 +27,8 @@ export async function fetchLegacyPhase1Rows(options?: { SYNTY as birthDateRaw, ISREK as sireRegistrationNo, EMREK as damRegistrationNo, - KASVA as breederName + KASVA as breederName, + COLCODE as colorCode FROM bearek_id`, )) as LegacyDogRow[]; log( diff --git a/packages/db/imports/types.ts b/packages/db/imports/types.ts index 7b10e99a..afa1bf0c 100644 --- a/packages/db/imports/types.ts +++ b/packages/db/imports/types.ts @@ -6,6 +6,7 @@ export type LegacyDogRow = { sireRegistrationNo: string | null; damRegistrationNo: string | null; breederName: string | null; + colorCode: number | string | null; }; export type LegacyBreederRow = { diff --git a/packages/db/index.ts b/packages/db/index.ts index d2c32557..5fc2b88f 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -1,5 +1,6 @@ export { DogSex, + DogColorStatus, ImportKind, ImportStatus, Role, @@ -72,6 +73,7 @@ export { getHomeStatisticsSnapshot, type HomeStatisticsSnapshot } from "./home"; export { getBeagleDogProfileDb, + seedDogColorsDb, getNewestBeagleDogsDb, loadDogPedigreeAncestryDb, loadDogPedigreeAncestryForParentsDb, @@ -214,6 +216,8 @@ export { runAdminDogWriteTransactionDb, linkUnlinkedShowTrialEntriesByRegistrationDb, listAdminBreederOptionsDb, + listAdminDogColorOptionsDb, + findAdminDogColorOptionDb, listAdminOwnerOptionsDb, listAdminDogParentOptionsDb, runAdminUserWriteTransactionDb, @@ -248,6 +252,8 @@ export { type AdminDogParentLookupRequestDb, type AdminDogParentLookupOptionDb, type AdminDogParentLookupResponseDb, + type AdminDogColorLookupOptionDb, + type AdminDogColorLookupResponseDb, type CreateAdminDogDbInput, type CreatedAdminDogRowDb, type LinkUnlinkedShowTrialEntriesDbInput, diff --git a/packages/db/package.json b/packages/db/package.json index 3fc7a27e..4dfa8009 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -16,6 +16,7 @@ "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "seed:initial-test-data": "node --import tsx/esm scripts/seed-initial-test-data.ts", + "seed:dog-colors": "node --import tsx/esm scripts/seed-dog-colors.ts", "seed:show-result-definitions": "node --import tsx/esm scripts/seed-show-result-definitions.ts", "seed:show-workbook-import-schema": "node --import tsx/esm scripts/seed-show-workbook-import-schema.ts", "audit:prune": "node --import tsx/esm scripts/prune-audit-events.ts" diff --git a/packages/db/prisma/migrations/20260506120000_add_dog_colors/migration.sql b/packages/db/prisma/migrations/20260506120000_add_dog_colors/migration.sql new file mode 100644 index 00000000..465c3696 --- /dev/null +++ b/packages/db/prisma/migrations/20260506120000_add_dog_colors/migration.sql @@ -0,0 +1,33 @@ +CREATE TABLE "DogColor" ( + "code" INTEGER NOT NULL, + "nameFi" TEXT NOT NULL, + "nameSv" TEXT, + "nameEn" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "DogColor_pkey" PRIMARY KEY ("code") +); + +ALTER TABLE "Dog" ADD COLUMN "colorCode" INTEGER; + +CREATE INDEX "Dog_colorCode_idx" ON "Dog"("colorCode"); + +ALTER TABLE "Dog" +ADD CONSTRAINT "Dog_colorCode_fkey" +FOREIGN KEY ("colorCode") REFERENCES "DogColor"("code") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE TRIGGER trg_audit_dog_color +AFTER INSERT OR UPDATE OR DELETE ON "DogColor" +FOR EACH ROW EXECUTE FUNCTION audit_capture_row_change(); + +-- AlterTable +ALTER TABLE "DogColor" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "DogTitle" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "ShowWorkbookColumnRule" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "ShowWorkbookColumnValueMap" ALTER COLUMN "updatedAt" DROP DEFAULT; diff --git a/packages/db/prisma/migrations/20260623120000_add_dog_color_status/migration.sql b/packages/db/prisma/migrations/20260623120000_add_dog_color_status/migration.sql new file mode 100644 index 00000000..328a0861 --- /dev/null +++ b/packages/db/prisma/migrations/20260623120000_add_dog_color_status/migration.sql @@ -0,0 +1,4 @@ +CREATE TYPE "DogColorStatus" AS ENUM ('SELECTABLE', 'HIDDEN', 'LEGACY_UNKNOWN'); + +ALTER TABLE "DogColor" +ADD COLUMN "status" "DogColorStatus" NOT NULL DEFAULT 'HIDDEN'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 53611ad4..a5211759 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -17,6 +17,12 @@ enum DogSex { UNKNOWN } +enum DogColorStatus { + SELECTABLE + HIDDEN + LEGACY_UNKNOWN +} + enum SairausRyhma { EPILEPSIA LAFORA @@ -197,30 +203,32 @@ model BetterAuthVerification { } model Dog { - id String @id @default(cuid()) - name String - sex DogSex @default(UNKNOWN) - birthDate DateTime? - breederNameText String? - sireId String? - damId String? - breederId String? - ekNo Int? @unique - note String? - sire Dog? @relation("SireRelation", fields: [sireId], references: [id]) - siredPuppies Dog[] @relation("SireRelation") - dam Dog? @relation("DamRelation", fields: [damId], references: [id]) - whelpedPuppies Dog[] @relation("DamRelation") - breeder Breeder? @relation(fields: [breederId], references: [id]) - registrations DogRegistration[] - ownerships DogOwnership[] - titles DogTitle[] - trialResults TrialResult[] - trialEntries TrialEntry[] - showEntries ShowEntry[] - sairaudet KoiranSairaus[] @relation("KoiranSairausDog") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + sex DogSex @default(UNKNOWN) + birthDate DateTime? + breederNameText String? + sireId String? + damId String? + breederId String? + ekNo Int? @unique + colorCode Int? + note String? + sire Dog? @relation("SireRelation", fields: [sireId], references: [id]) + siredPuppies Dog[] @relation("SireRelation") + dam Dog? @relation("DamRelation", fields: [damId], references: [id]) + whelpedPuppies Dog[] @relation("DamRelation") + breeder Breeder? @relation(fields: [breederId], references: [id]) + color DogColor? @relation(fields: [colorCode], references: [code]) + registrations DogRegistration[] + ownerships DogOwnership[] + titles DogTitle[] + trialResults TrialResult[] + trialEntries TrialEntry[] + showEntries ShowEntry[] + sairaudet KoiranSairaus[] @relation("KoiranSairausDog") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([name]) @@index([sex]) @@ -228,6 +236,18 @@ model Dog { @@index([sireId]) @@index([damId]) @@index([breederId]) + @@index([colorCode]) +} + +model DogColor { + code Int @id + nameFi String + nameSv String? + nameEn String? + status DogColorStatus @default(HIDDEN) + dogs Dog[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model DogTitle { diff --git a/packages/db/scripts/seed-dog-colors.ts b/packages/db/scripts/seed-dog-colors.ts new file mode 100644 index 00000000..20b9b25b --- /dev/null +++ b/packages/db/scripts/seed-dog-colors.ts @@ -0,0 +1,12 @@ +import { prisma } from "../core/prisma"; +import { seedDogColorsDb } from "../dogs/colors"; + +try { + const result = await seedDogColorsDb(); + console.log(`[seed] dog colors upserted=${result.codes.length}`); +} catch (error) { + console.error("[seed] failed to seed dog colors", error); + process.exitCode = 1; +} finally { + await prisma.$disconnect(); +} diff --git a/packages/server/admin/dogs/index.ts b/packages/server/admin/dogs/index.ts index 03ea6e3b..2af1d112 100644 --- a/packages/server/admin/dogs/index.ts +++ b/packages/server/admin/dogs/index.ts @@ -4,6 +4,7 @@ export { createAdminDog } from "./manage/create-dog"; export { updateAdminDog } from "./manage/update-dog"; export { deleteAdminDog } from "./manage/delete-dog"; export { listAdminBreederOptions } from "./lookups/list-breeder-options"; +export { listAdminDogColorOptions } from "./lookups/list-color-options"; export { listAdminOwnerOptions } from "./lookups/list-owner-options"; export { listAdminDogParentOptions } from "./lookups/list-parent-options"; export { getAdminDogProfile } from "./profile"; diff --git a/packages/server/admin/dogs/lookups/__tests__/list-color-options.test.ts b/packages/server/admin/dogs/lookups/__tests__/list-color-options.test.ts new file mode 100644 index 00000000..671da786 --- /dev/null +++ b/packages/server/admin/dogs/lookups/__tests__/list-color-options.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listAdminDogColorOptions } from "../list-color-options"; + +const { listAdminDogColorOptionsDbMock } = vi.hoisted(() => ({ + listAdminDogColorOptionsDbMock: vi.fn(), +})); + +vi.mock("@beagle/db", () => ({ + listAdminDogColorOptionsDb: listAdminDogColorOptionsDbMock, +})); + +describe("listAdminDogColorOptions", () => { + beforeEach(() => { + listAdminDogColorOptionsDbMock.mockReset(); + }); + + it("returns unauthenticated when current user is missing", async () => { + await expect(listAdminDogColorOptions(null)).resolves.toEqual({ + status: 401, + body: { + ok: false, + error: "Not authenticated.", + code: "UNAUTHENTICATED", + }, + }); + }); + + it("returns forbidden when current user is not admin", async () => { + await expect( + listAdminDogColorOptions({ + id: "u_1", + email: "user@example.com", + username: null, + role: "USER", + }), + ).resolves.toEqual({ + status: 403, + body: { + ok: false, + error: "Admin role required.", + code: "FORBIDDEN", + }, + }); + }); + + it("returns color options on success", async () => { + listAdminDogColorOptionsDbMock.mockResolvedValue({ + items: [ + { + code: 1, + nameFi: "Musta", + nameSv: "Svart", + nameEn: "Black", + status: "SELECTABLE", + }, + { + code: 2, + nameFi: "Valkoinen", + nameSv: null, + nameEn: "White", + status: "HIDDEN", + }, + ], + }); + + await expect( + listAdminDogColorOptions( + { + id: "u_1", + email: "admin@example.com", + username: "Admin", + role: "ADMIN", + }, + { + requestId: "req-1", + actorUserId: "u_1", + }, + ), + ).resolves.toEqual({ + status: 200, + body: { + ok: true, + data: { + items: [ + { + code: 1, + nameFi: "Musta", + nameSv: "Svart", + nameEn: "Black", + status: "SELECTABLE", + }, + { + code: 2, + nameFi: "Valkoinen", + nameSv: null, + nameEn: "White", + status: "HIDDEN", + }, + ], + }, + }, + }); + + expect(listAdminDogColorOptionsDbMock).toHaveBeenCalledWith(); + }); + + it("returns internal error when db fails", async () => { + listAdminDogColorOptionsDbMock.mockRejectedValue(new Error("boom")); + + await expect( + listAdminDogColorOptions({ + id: "u_1", + email: "admin@example.com", + username: "Admin", + role: "ADMIN", + }), + ).resolves.toEqual({ + status: 500, + body: { + ok: false, + error: "Failed to load dog color options.", + code: "INTERNAL_ERROR", + }, + }); + }); +}); diff --git a/packages/server/admin/dogs/lookups/list-color-options.ts b/packages/server/admin/dogs/lookups/list-color-options.ts new file mode 100644 index 00000000..8f77c296 --- /dev/null +++ b/packages/server/admin/dogs/lookups/list-color-options.ts @@ -0,0 +1,81 @@ +import { listAdminDogColorOptionsDb } from "@beagle/db"; +import type { + AdminDogColorLookupResponse, + CurrentUserDto, +} from "@beagle/contracts"; +import { requireAdmin } from "@server/admin/core/service"; +import { toErrorLog, withLogContext } from "@server/core/logger"; +import type { ServiceResult } from "@server/core/result"; + +type ServiceLogContext = { + requestId?: string; + actorUserId?: string; +}; + +export async function listAdminDogColorOptions( + currentUser: CurrentUserDto | null, + context?: ServiceLogContext, +): Promise> { + const startedAt = Date.now(); + const log = withLogContext({ + layer: "service", + useCase: "admin-dogs.listAdminDogColorOptions", + ...(context?.requestId ? { requestId: context.requestId } : {}), + ...(context?.actorUserId ? { actorUserId: context.actorUserId } : {}), + }); + + log.info({ event: "start" }, "admin dog color options lookup started"); + + const authResult = requireAdmin(currentUser); + if (!authResult.body.ok) { + log.warn( + { + event: "forbidden", + status: authResult.status, + durationMs: Date.now() - startedAt, + }, + "admin dog color options lookup rejected by authorization", + ); + + return { status: authResult.status, body: authResult.body }; + } + + try { + const result = await listAdminDogColorOptionsDb(); + + log.info( + { + event: "success", + itemCount: result.items.length, + durationMs: Date.now() - startedAt, + }, + "admin dog color options lookup succeeded", + ); + + return { + status: 200, + body: { + ok: true, + data: result, + }, + }; + } catch (error) { + log.error( + { + event: "exception", + durationMs: Date.now() - startedAt, + ...toErrorLog(error), + }, + "admin dog color options lookup failed", + ); + + return { + status: 500, + body: { + ok: false, + error: "Failed to load dog color options.", + code: "INTERNAL_ERROR", + }, + }; + } +} diff --git a/packages/server/admin/dogs/manage/__tests__/create-dog.test.ts b/packages/server/admin/dogs/manage/__tests__/create-dog.test.ts index 16eac120..88ec3313 100644 --- a/packages/server/admin/dogs/manage/__tests__/create-dog.test.ts +++ b/packages/server/admin/dogs/manage/__tests__/create-dog.test.ts @@ -7,12 +7,14 @@ const { findDogByRegistrationNoDbMock, loadDogPedigreeAncestryForParentsDbMock, linkHistoricalEntriesOnDogCreateMock, + findAdminDogColorOptionDbMock, } = vi.hoisted(() => ({ createAdminDogWriteDbMock: vi.fn(), runAdminDogWriteTransactionDbMock: vi.fn(), findDogByRegistrationNoDbMock: vi.fn(), loadDogPedigreeAncestryForParentsDbMock: vi.fn(), linkHistoricalEntriesOnDogCreateMock: vi.fn(), + findAdminDogColorOptionDbMock: vi.fn(), })); vi.mock("@beagle/db", () => ({ @@ -20,6 +22,7 @@ vi.mock("@beagle/db", () => ({ runAdminDogWriteTransactionDb: runAdminDogWriteTransactionDbMock, findDogByRegistrationNoDb: findDogByRegistrationNoDbMock, loadDogPedigreeAncestryForParentsDb: loadDogPedigreeAncestryForParentsDbMock, + findAdminDogColorOptionDb: findAdminDogColorOptionDbMock, })); vi.mock("../link-historical-entries-on-dog-create", () => ({ @@ -64,6 +67,7 @@ describe("createAdminDog", () => { findDogByRegistrationNoDbMock.mockReset(); loadDogPedigreeAncestryForParentsDbMock.mockReset(); linkHistoricalEntriesOnDogCreateMock.mockReset(); + findAdminDogColorOptionDbMock.mockReset(); linkHistoricalEntriesOnDogCreateMock.mockResolvedValue({ showLinkedCount: 0, trialLinkedCount: 0, @@ -241,6 +245,7 @@ describe("createAdminDog", () => { damId: "dam_1", ownerNames: ["Tiina Virtanen"], ekNo: 5588, + colorCode: null, note: "Important", registrationNo: "FI12345/21", secondaryRegistrationNos: [], @@ -264,6 +269,87 @@ describe("createAdminDog", () => { ); }); + it("rejects a missing color code for a new dog", async () => { + mockRequiredParentResolution(); + findAdminDogColorOptionDbMock.mockResolvedValue(null); + + await expect( + createAdminDog({ + name: "Metsapolun Kide", + sex: "FEMALE", + registrationNo: "FI12345/21", + colorCode: 999, + sireRegistrationNo: "FI11111/11", + damRegistrationNo: "FI22222/22", + }), + ).resolves.toEqual({ + status: 400, + body: { + ok: false, + error: "Color code was not found.", + code: "COLOR_CODE_NOT_FOUND", + }, + }); + + expect(createAdminDogWriteDbMock).not.toHaveBeenCalled(); + }); + + it("rejects a hidden color for a new dog", async () => { + mockRequiredParentResolution(); + findAdminDogColorOptionDbMock.mockResolvedValue({ + code: 112, + status: "HIDDEN", + }); + + await expect( + createAdminDog({ + name: "Metsapolun Kide", + sex: "FEMALE", + registrationNo: "FI12345/21", + colorCode: 112, + sireRegistrationNo: "FI11111/11", + damRegistrationNo: "FI22222/22", + }), + ).resolves.toMatchObject({ + status: 400, + body: { + ok: false, + error: "Color code is hidden and cannot be selected.", + code: "COLOR_CODE_HIDDEN", + }, + }); + + expect(createAdminDogWriteDbMock).not.toHaveBeenCalled(); + }); + + it("rejects a legacy unknown color for a new dog", async () => { + mockRequiredParentResolution(); + findAdminDogColorOptionDbMock.mockResolvedValue({ + code: 493, + status: "LEGACY_UNKNOWN", + }); + + await expect( + createAdminDog({ + name: "Metsapolun Kide", + sex: "FEMALE", + registrationNo: "FI12345/21", + colorCode: 493, + sireRegistrationNo: "FI11111/11", + damRegistrationNo: "FI22222/22", + }), + ).resolves.toMatchObject({ + status: 400, + body: { + ok: false, + error: "Color code is a legacy unknown value and cannot be selected.", + code: "COLOR_CODE_LEGACY_UNKNOWN", + }, + }); + + expect(createAdminDogWriteDbMock).not.toHaveBeenCalled(); + }); + it("returns an internal error if historical linking fails after dog creation", async () => { mockRequiredParentResolution(); createAdminDogWriteDbMock.mockResolvedValue({ diff --git a/packages/server/admin/dogs/manage/__tests__/manage-responses.test.ts b/packages/server/admin/dogs/manage/__tests__/manage-responses.test.ts new file mode 100644 index 00000000..a76d1160 --- /dev/null +++ b/packages/server/admin/dogs/manage/__tests__/manage-responses.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + colorCodeNotFoundResponse, + hiddenColorCodeResponse, + invalidColorCodeResponse, + legacyUnknownColorCodeResponse, +} from "../internal/manage-responses"; + +describe("manage color responses", () => { + it("returns the invalid color code error message", () => { + expect(invalidColorCodeResponse()).toEqual({ + status: 400, + body: { + ok: false, + error: "Invalid color code.", + code: "INVALID_COLOR_CODE", + }, + }); + }); + + it("returns the not found color code error message", () => { + expect(colorCodeNotFoundResponse()).toEqual({ + status: 400, + body: { + ok: false, + error: "Color code was not found.", + code: "COLOR_CODE_NOT_FOUND", + }, + }); + }); + + it("returns the hidden color code error message", () => { + expect(hiddenColorCodeResponse()).toEqual({ + status: 400, + body: { + ok: false, + error: "Color code is hidden and cannot be selected.", + code: "COLOR_CODE_HIDDEN", + }, + }); + }); + + it("returns the legacy unknown color code error message", () => { + expect(legacyUnknownColorCodeResponse()).toEqual({ + status: 400, + body: { + ok: false, + error: "Color code is a legacy unknown value and cannot be selected.", + code: "COLOR_CODE_LEGACY_UNKNOWN", + }, + }); + }); +}); diff --git a/packages/server/admin/dogs/manage/__tests__/update-dog.test.ts b/packages/server/admin/dogs/manage/__tests__/update-dog.test.ts index 9f4f3303..e534bae7 100644 --- a/packages/server/admin/dogs/manage/__tests__/update-dog.test.ts +++ b/packages/server/admin/dogs/manage/__tests__/update-dog.test.ts @@ -7,12 +7,14 @@ const { findDogByIdDbMock, findDogByRegistrationNoDbMock, loadDogPedigreeAncestryForParentsDbMock, + findAdminDogColorOptionDbMock, } = vi.hoisted(() => ({ updateAdminDogWriteDbMock: vi.fn(), runAdminDogWriteTransactionDbMock: vi.fn(), findDogByIdDbMock: vi.fn(), findDogByRegistrationNoDbMock: vi.fn(), loadDogPedigreeAncestryForParentsDbMock: vi.fn(), + findAdminDogColorOptionDbMock: vi.fn(), })); vi.mock("@beagle/db", () => ({ @@ -21,6 +23,7 @@ vi.mock("@beagle/db", () => ({ findDogByIdDb: findDogByIdDbMock, findDogByRegistrationNoDb: findDogByRegistrationNoDbMock, loadDogPedigreeAncestryForParentsDb: loadDogPedigreeAncestryForParentsDbMock, + findAdminDogColorOptionDb: findAdminDogColorOptionDbMock, })); describe("updateAdminDog", () => { @@ -30,11 +33,13 @@ describe("updateAdminDog", () => { findDogByIdDbMock.mockReset(); findDogByRegistrationNoDbMock.mockReset(); loadDogPedigreeAncestryForParentsDbMock.mockReset(); + findAdminDogColorOptionDbMock.mockReset(); runAdminDogWriteTransactionDbMock.mockImplementation(async (callback) => callback({}), ); findDogByIdDbMock.mockResolvedValue({ id: "dog_1", + colorCode: null, sire: { id: "sire_1", sex: "MALE" }, dam: { id: "dam_1", sex: "FEMALE" }, }); @@ -55,6 +60,151 @@ describe("updateAdminDog", () => { }); }); + it("allows an existing hidden color to be preserved", async () => { + findDogByIdDbMock.mockResolvedValue({ + id: "dog_1", + colorCode: 112, + sire: null, + dam: null, + }); + findAdminDogColorOptionDbMock.mockResolvedValue({ + code: 112, + status: "HIDDEN", + }); + updateAdminDogWriteDbMock.mockResolvedValue({ + id: "dog_1", + name: "Metsapolun Kide", + sex: "FEMALE", + registrationNo: "FI12345/21", + }); + + await expect( + updateAdminDog({ + id: "dog_1", + name: "Metsapolun Kide", + sex: "FEMALE", + registrationNo: "FI12345/21", + colorCode: 112, + }), + ).resolves.toMatchObject({ status: 200 }); + }); + + it("allows an existing legacy unknown color to be preserved", async () => { + findDogByIdDbMock.mockResolvedValue({ + id: "dog_1", + colorCode: 493, + sire: null, + dam: null, + }); + findAdminDogColorOptionDbMock.mockResolvedValue({ + code: 493, + status: "LEGACY_UNKNOWN", + }); + updateAdminDogWriteDbMock.mockResolvedValue({ + id: "dog_1", + name: "Metsapolun Kide", + sex: "FEMALE", + registrationNo: "FI12345/21", + }); + + await expect( + updateAdminDog({ + id: "dog_1", + name: "Metsapolun Kide", + sex: "FEMALE", + registrationNo: "FI12345/21", + colorCode: 493, + }), + ).resolves.toMatchObject({ status: 200 }); + }); + + it("rejects assigning a missing color code", async () => { + findAdminDogColorOptionDbMock.mockResolvedValue(null); + + await expect( + updateAdminDog({ + id: "dog_1", + name: "Metsapolun Kide", + sex: "FEMALE", + registrationNo: "FI12345/21", + colorCode: 999, + }), + ).resolves.toEqual({ + status: 400, + body: { + ok: false, + error: "Color code was not found.", + code: "COLOR_CODE_NOT_FOUND", + }, + }); + + expect(updateAdminDogWriteDbMock).not.toHaveBeenCalled(); + }); + + it("rejects assigning a different hidden color", async () => { + findDogByIdDbMock.mockResolvedValue({ + id: "dog_1", + colorCode: 121, + sire: null, + dam: null, + }); + findAdminDogColorOptionDbMock.mockResolvedValue({ + code: 112, + status: "HIDDEN", + }); + + await expect( + updateAdminDog({ + id: "dog_1", + name: "Metsapolun Kide", + sex: "FEMALE", + registrationNo: "FI12345/21", + colorCode: 112, + }), + ).resolves.toMatchObject({ + status: 400, + body: { + ok: false, + error: "Color code is hidden and cannot be selected.", + code: "COLOR_CODE_HIDDEN", + }, + }); + + expect(updateAdminDogWriteDbMock).not.toHaveBeenCalled(); + }); + + it("rejects assigning a different legacy unknown color", async () => { + findDogByIdDbMock.mockResolvedValue({ + id: "dog_1", + colorCode: 121, + sire: null, + dam: null, + }); + findAdminDogColorOptionDbMock.mockResolvedValue({ + code: 493, + status: "LEGACY_UNKNOWN", + }); + + await expect( + updateAdminDog({ + id: "dog_1", + name: "Metsapolun Kide", + sex: "FEMALE", + registrationNo: "FI12345/21", + colorCode: 493, + }), + ).resolves.toEqual({ + status: 400, + body: { + ok: false, + error: "Color code is a legacy unknown value and cannot be selected.", + code: "COLOR_CODE_LEGACY_UNKNOWN", + }, + }); + + expect(updateAdminDogWriteDbMock).not.toHaveBeenCalled(); + }); + it("returns 400 for invalid id", async () => { await expect( updateAdminDog({ diff --git a/packages/server/admin/dogs/manage/create-dog.ts b/packages/server/admin/dogs/manage/create-dog.ts index ffbe7152..008772a3 100644 --- a/packages/server/admin/dogs/manage/create-dog.ts +++ b/packages/server/admin/dogs/manage/create-dog.ts @@ -1,5 +1,6 @@ import { createAdminDogWriteDb, + findAdminDogColorOptionDb, runAdminDogWriteTransactionDb, type AuditContextDb, } from "@beagle/db"; @@ -23,6 +24,7 @@ import { validateCreateInTry, validateCreatePreflight, } from "./internal/create-input-validation"; +import { validateAdminDogColorSelection } from "./internal/color-validation"; import { resolveAndValidateCreateParents } from "./internal/create-parent-validation"; import { linkHistoricalEntriesOnDogCreate } from "./link-historical-entries-on-dog-create"; @@ -87,6 +89,27 @@ export async function createAdminDog( if (!parentValidation.ok) { return parentValidation.response; } + if (preflight.data.colorCode != null) { + const colorOption = await findAdminDogColorOptionDb( + preflight.data.colorCode, + ); + const colorValidation = + validateAdminDogColorSelection( + preflight.data.colorCode, + colorOption, + ); + if (!colorValidation.ok) { + log.warn( + { + ...colorValidation.logContext, + colorCode: preflight.data.colorCode, + durationMs: Date.now() - startedAt, + }, + colorValidation.logMessage, + ); + return colorValidation.response; + } + } const { createdDog, linkResult } = await runAdminDogWriteTransactionDb( async (tx) => { @@ -100,6 +123,7 @@ export async function createAdminDog( damId: parentValidation.data.dam?.id ?? null, ownerNames: inTryValidation.data.ownerNames, ekNo: preflight.data.ekNo, + colorCode: preflight.data.colorCode, note: inTryValidation.data.note, registrationNo: preflight.data.primaryRegistrationNo, secondaryRegistrationNos: preflight.data.secondaryRegistrationNos, diff --git a/packages/server/admin/dogs/manage/internal/__tests__/color-validation.test.ts b/packages/server/admin/dogs/manage/internal/__tests__/color-validation.test.ts new file mode 100644 index 00000000..f46e6311 --- /dev/null +++ b/packages/server/admin/dogs/manage/internal/__tests__/color-validation.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { validateAdminDogColorSelection } from "../color-validation"; + +describe("validateAdminDogColorSelection", () => { + it("returns a not found failure when the catalog entry is missing", () => { + expect(validateAdminDogColorSelection(999, null)).toEqual({ + ok: false, + logContext: { + event: "color_code_not_found", + colorCode: 999, + }, + logMessage: + "admin dog color validation rejected because color code was not found", + response: { + status: 400, + body: { + ok: false, + error: "Color code was not found.", + code: "COLOR_CODE_NOT_FOUND", + }, + }, + }); + }); + + it("returns a hidden failure when the catalog entry is hidden", () => { + expect(validateAdminDogColorSelection(112, { status: "HIDDEN" })).toEqual({ + ok: false, + logContext: { + event: "color_code_hidden", + colorCode: 112, + }, + logMessage: + "admin dog color validation rejected because color code is hidden", + response: { + status: 400, + body: { + ok: false, + error: "Color code is hidden and cannot be selected.", + code: "COLOR_CODE_HIDDEN", + }, + }, + }); + }); + + it("returns a legacy unknown failure when the catalog entry is legacy unknown", () => { + expect( + validateAdminDogColorSelection(493, { status: "LEGACY_UNKNOWN" }), + ).toEqual({ + ok: false, + logContext: { + event: "color_code_legacy_unknown", + colorCode: 493, + }, + logMessage: + "admin dog color validation rejected because color code is a legacy unknown value", + response: { + status: 400, + body: { + ok: false, + error: "Color code is a legacy unknown value and cannot be selected.", + code: "COLOR_CODE_LEGACY_UNKNOWN", + }, + }, + }); + }); + + it("allows an existing hidden color to be retained", () => { + expect( + validateAdminDogColorSelection(112, { status: "HIDDEN" }, 112), + ).toEqual({ + ok: true, + }); + }); + + it("allows an existing legacy unknown color to be retained", () => { + expect( + validateAdminDogColorSelection(493, { status: "LEGACY_UNKNOWN" }, 493), + ).toEqual({ + ok: true, + }); + }); +}); diff --git a/packages/server/admin/dogs/manage/internal/color-validation.ts b/packages/server/admin/dogs/manage/internal/color-validation.ts new file mode 100644 index 00000000..cef8ec98 --- /dev/null +++ b/packages/server/admin/dogs/manage/internal/color-validation.ts @@ -0,0 +1,91 @@ +// Validates admin dog color selections before persistence. +// Keeps create/update behavior aligned while preserving an existing hidden or legacy color on update. + +import type { ServiceResult } from "@server/core/result"; +import type { + CreateAdminDogResponse, + UpdateAdminDogResponse, +} from "@beagle/contracts"; +import { + colorCodeNotFoundResponse, + hiddenColorCodeResponse, + legacyUnknownColorCodeResponse, +} from "./manage-responses"; + +type AdminDogColorOption = { + status: "SELECTABLE" | "HIDDEN" | "LEGACY_UNKNOWN"; +} | null; + +type ColorValidationFailure< + T extends CreateAdminDogResponse | UpdateAdminDogResponse, +> = { + ok: false; + logContext: Record; + logMessage: string; + response: ServiceResult; +}; + +type ColorValidationSuccess = { + ok: true; +}; + +export type ColorValidationResult< + T extends CreateAdminDogResponse | UpdateAdminDogResponse, +> = ColorValidationFailure | ColorValidationSuccess; + +export function validateAdminDogColorSelection< + T extends CreateAdminDogResponse | UpdateAdminDogResponse, +>( + colorCode: number, + colorOption: AdminDogColorOption, + existingColorCode?: number | null, +): ColorValidationResult { + if (colorOption === null) { + return { + ok: false, + logContext: { + event: "color_code_not_found", + colorCode, + }, + logMessage: + "admin dog color validation rejected because color code was not found", + response: colorCodeNotFoundResponse(), + }; + } + + if (colorOption.status === "HIDDEN") { + if (existingColorCode === colorCode) { + return { ok: true }; + } + + return { + ok: false, + logContext: { + event: "color_code_hidden", + colorCode, + }, + logMessage: + "admin dog color validation rejected because color code is hidden", + response: hiddenColorCodeResponse(), + }; + } + + if (colorOption.status === "LEGACY_UNKNOWN") { + if (existingColorCode === colorCode) { + return { ok: true }; + } + + return { + ok: false, + logContext: { + event: "color_code_legacy_unknown", + colorCode, + }, + logMessage: + "admin dog color validation rejected because color code is a legacy unknown value", + response: legacyUnknownColorCodeResponse(), + }; + } + + return { ok: true }; +} diff --git a/packages/server/admin/dogs/manage/internal/create-input-validation.ts b/packages/server/admin/dogs/manage/internal/create-input-validation.ts index dadaf99d..5931fe7a 100644 --- a/packages/server/admin/dogs/manage/internal/create-input-validation.ts +++ b/packages/server/admin/dogs/manage/internal/create-input-validation.ts @@ -14,6 +14,7 @@ import { import { duplicateRegistrationNoResponse, invalidBirthDateResponse, + invalidColorCodeResponse, invalidEkNoResponse, invalidNameResponse, invalidRegistrationNoFormatResponse, @@ -47,6 +48,7 @@ export type CreatePreflightValidationResult = sex: "MALE" | "FEMALE" | "UNKNOWN"; birthDate: Date | null; ekNo: number | null; + colorCode: number | null; primaryRegistrationNo: string; secondaryRegistrationNos: string[]; allRegistrationNos: string[]; @@ -116,6 +118,16 @@ export function validateCreatePreflight( }; } + const colorCode = parsePositiveInteger(input.colorCode); + if (colorCode === "INVALID") { + return { + ok: false, + logContext: { event: "invalid_color_code", colorCode: input.colorCode }, + logMessage: "admin dog create rejected because color code is invalid", + response: invalidColorCodeResponse(), + }; + } + const registration = validateRegistrationInput( input.registrationNo, input.secondaryRegistrationNos, @@ -177,6 +189,7 @@ export function validateCreatePreflight( sex, birthDate, ekNo, + colorCode, primaryRegistrationNo: registration.primaryRegistrationNo, secondaryRegistrationNos: registration.secondaryRegistrationNos, allRegistrationNos: registration.allRegistrationNos, diff --git a/packages/server/admin/dogs/manage/internal/manage-responses.ts b/packages/server/admin/dogs/manage/internal/manage-responses.ts index a3a53543..9a8e933d 100644 --- a/packages/server/admin/dogs/manage/internal/manage-responses.ts +++ b/packages/server/admin/dogs/manage/internal/manage-responses.ts @@ -102,6 +102,58 @@ export function invalidEkNoResponse< } as ServiceResult; } +export function invalidColorCodeResponse< + T extends ManageErrorTarget, +>(): ServiceResult { + return { + status: 400, + body: { + ok: false, + error: "Invalid color code.", + code: "INVALID_COLOR_CODE", + }, + } as ServiceResult; +} + +export function colorCodeNotFoundResponse< + T extends ManageErrorTarget, +>(): ServiceResult { + return { + status: 400, + body: { + ok: false, + error: "Color code was not found.", + code: "COLOR_CODE_NOT_FOUND", + }, + } as ServiceResult; +} + +export function hiddenColorCodeResponse< + T extends ManageErrorTarget, +>(): ServiceResult { + return { + status: 400, + body: { + ok: false, + error: "Color code is hidden and cannot be selected.", + code: "COLOR_CODE_HIDDEN", + }, + } as ServiceResult; +} + +export function legacyUnknownColorCodeResponse< + T extends ManageErrorTarget, +>(): ServiceResult { + return { + status: 400, + body: { + ok: false, + error: "Color code is a legacy unknown value and cannot be selected.", + code: "COLOR_CODE_LEGACY_UNKNOWN", + }, + } as ServiceResult; +} + export function invalidRegistrationNoResponse< T extends ManageErrorTarget, >(): ServiceResult { diff --git a/packages/server/admin/dogs/manage/internal/update-input-validation.ts b/packages/server/admin/dogs/manage/internal/update-input-validation.ts index 0f2d5ed4..a7a400b5 100644 --- a/packages/server/admin/dogs/manage/internal/update-input-validation.ts +++ b/packages/server/admin/dogs/manage/internal/update-input-validation.ts @@ -15,6 +15,7 @@ import { import { duplicateRegistrationNoResponse, invalidBirthDateResponse, + invalidColorCodeResponse, invalidDogIdResponse, invalidEkNoResponse, invalidNameResponse, @@ -50,6 +51,7 @@ export type UpdatePreflightValidationResult = sex: "MALE" | "FEMALE" | "UNKNOWN"; birthDate: Date | null | undefined; ekNo: number | null | undefined; + colorCode: number | null | undefined; primaryRegistrationNo: string; secondaryRegistrationNos: string[]; allRegistrationNos: string[]; @@ -194,6 +196,23 @@ export function validateUpdatePreflight( }; } + const colorCode = + input.colorCode === undefined + ? undefined + : parsePositiveInteger(input.colorCode); + if (colorCode === "INVALID") { + return { + ok: false, + logContext: { + event: "invalid_color_code", + dogId: id, + colorCode: input.colorCode, + }, + logMessage: "admin dog update rejected because color code is invalid", + response: invalidColorCodeResponse(), + }; + } + return { ok: true, data: { @@ -202,6 +221,7 @@ export function validateUpdatePreflight( sex, birthDate, ekNo, + colorCode, primaryRegistrationNo: registration.primaryRegistrationNo, secondaryRegistrationNos: registration.secondaryRegistrationNos, allRegistrationNos: registration.allRegistrationNos, diff --git a/packages/server/admin/dogs/manage/list-dogs.ts b/packages/server/admin/dogs/manage/list-dogs.ts index 3d486ec2..d2a5d397 100644 --- a/packages/server/admin/dogs/manage/list-dogs.ts +++ b/packages/server/admin/dogs/manage/list-dogs.ts @@ -190,6 +190,7 @@ export async function listAdminDogs( showCount: item.showCount, titlesText: item.titlesText, ekNo: item.ekNo, + colorCode: item.colorCode, note: item.note, titles: item.titles.map((title) => ({ id: title.id, diff --git a/packages/server/admin/dogs/manage/update-dog.ts b/packages/server/admin/dogs/manage/update-dog.ts index b4970d89..07f80437 100644 --- a/packages/server/admin/dogs/manage/update-dog.ts +++ b/packages/server/admin/dogs/manage/update-dog.ts @@ -1,5 +1,6 @@ import { findDogByIdDb, + findAdminDogColorOptionDb, runAdminDogWriteTransactionDb, updateAdminDogWriteDb, type AuditContextDb, @@ -18,6 +19,7 @@ import { updateInternalErrorResponse, updateSuccessResponse, } from "./internal/manage-responses"; +import { validateAdminDogColorSelection } from "./internal/color-validation"; import { validateUpdateInTry, validateUpdatePreflight, @@ -126,6 +128,29 @@ export async function updateAdminDog( if (parentGuardResult && !parentGuardResult.ok) { return parentGuardResult.response; } + if (preflight.data.colorCode != null) { + const colorOption = await findAdminDogColorOptionDb( + preflight.data.colorCode, + ); + const colorValidation = + validateAdminDogColorSelection( + preflight.data.colorCode, + colorOption, + existingDog.colorCode, + ); + if (!colorValidation.ok) { + log.warn( + { + ...colorValidation.logContext, + dogId: preflight.data.id, + colorCode: preflight.data.colorCode, + durationMs: Date.now() - startedAt, + }, + colorValidation.logMessage, + ); + return colorValidation.response; + } + } const updatedDog = await runAdminDogWriteTransactionDb( async (tx) => @@ -146,6 +171,7 @@ export async function updateAdminDog( : (resolvedParents.data.dam?.id ?? null), ownerNames: inTryValidation.data.ownerNames, ekNo: preflight.data.ekNo, + colorCode: preflight.data.colorCode, note: inTryValidation.data.note, registrationNo: preflight.data.primaryRegistrationNo, secondaryRegistrationNos: diff --git a/packages/server/admin/index.ts b/packages/server/admin/index.ts index 4cffd4fb..da26d1bd 100644 --- a/packages/server/admin/index.ts +++ b/packages/server/admin/index.ts @@ -11,6 +11,7 @@ export { createAdminDog } from "./dogs"; export { updateAdminDog } from "./dogs"; export { deleteAdminDog } from "./dogs"; export { listAdminBreederOptions } from "./dogs"; +export { listAdminDogColorOptions } from "./dogs"; export { listAdminDogParentOptions } from "./dogs"; export { listAdminDogs } from "./dogs"; export { listAdminOwnerOptions } from "./dogs"; diff --git a/packages/server/imports/phase1/__tests__/run-legacy-phase1.test.ts b/packages/server/imports/phase1/__tests__/run-legacy-phase1.test.ts index 2208a918..866f8c3e 100644 --- a/packages/server/imports/phase1/__tests__/run-legacy-phase1.test.ts +++ b/packages/server/imports/phase1/__tests__/run-legacy-phase1.test.ts @@ -8,6 +8,7 @@ const { createImportRunIssueMock, createImportRunIssuesBulkMock, fetchLegacyPhase1RowsMock, + seedDogColorsDbMock, breederFindManyMock, dogRegistrationFindUniqueMock, dogRegistrationFindManyMock, @@ -25,6 +26,7 @@ const { createImportRunIssueMock: vi.fn(), createImportRunIssuesBulkMock: vi.fn(), fetchLegacyPhase1RowsMock: vi.fn(), + seedDogColorsDbMock: vi.fn(), breederFindManyMock: vi.fn(), dogRegistrationFindUniqueMock: vi.fn(), dogRegistrationFindManyMock: vi.fn(), @@ -52,6 +54,7 @@ vi.mock("@beagle/db", () => ({ createImportRunIssue: createImportRunIssueMock, createImportRunIssuesBulk: createImportRunIssuesBulkMock, fetchLegacyPhase1Rows: fetchLegacyPhase1RowsMock, + seedDogColorsDb: seedDogColorsDbMock, prisma: { breeder: { findMany: breederFindManyMock }, dogRegistration: { @@ -85,6 +88,7 @@ describe("runLegacyPhase1", () => { createImportRunIssueMock.mockReset(); createImportRunIssuesBulkMock.mockReset(); fetchLegacyPhase1RowsMock.mockReset(); + seedDogColorsDbMock.mockReset(); breederFindManyMock.mockReset(); dogRegistrationFindUniqueMock.mockReset(); dogRegistrationFindManyMock.mockReset(); @@ -137,6 +141,7 @@ describe("runLegacyPhase1", () => { owners: [], samakoira: [], }); + seedDogColorsDbMock.mockResolvedValue({ codes: [121, 539, 886] }); breederFindManyMock.mockResolvedValue([]); dogRegistrationFindUniqueMock.mockImplementation(({ select }) => { @@ -191,6 +196,36 @@ describe("runLegacyPhase1", () => { ); }); + it("links a dog to a seeded legacy placeholder color", async () => { + seedDogColorsDbMock.mockResolvedValue({ codes: [391] }); + fetchLegacyPhase1RowsMock.mockResolvedValue({ + dogs: [ + { + registrationNo: "FI12345/21", + name: "Aino", + sex: "N", + birthDateRaw: "20240101", + sireRegistrationNo: null, + damRegistrationNo: null, + breederName: null, + colorCode: 391, + }, + ], + breeders: [], + eks: [], + owners: [], + samakoira: [], + }); + + await runLegacyPhase1("user-1"); + + expect(dogCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ colorCode: 391 }), + }), + ); + }); + it("explains that missing KNIMI prevented the dog from being imported and linked", async () => { fetchLegacyPhase1RowsMock.mockResolvedValue({ dogs: [ diff --git a/packages/server/imports/phase1/run-legacy-phase1.ts b/packages/server/imports/phase1/run-legacy-phase1.ts index 7ccc5328..5c88d36d 100644 --- a/packages/server/imports/phase1/run-legacy-phase1.ts +++ b/packages/server/imports/phase1/run-legacy-phase1.ts @@ -10,6 +10,7 @@ import { markImportRunFinished, markImportRunRunning, prisma, + seedDogColorsDb, } from "@beagle/db"; import type { ImportRunResponse } from "@beagle/contracts"; import type { ServiceResult } from "../../core/result"; @@ -72,6 +73,18 @@ function parseRegistrationNo(value: string | null | undefined): { }; } +function parseLegacyColorCode( + value: string | number | null | undefined, +): number | null | "INVALID" { + if (value == null) return null; + const normalized = String(value).trim(); + if (!normalized) return null; + if (!/^\d+$/u.test(normalized)) return "INVALID"; + const parsed = Number(normalized); + if (!Number.isSafeInteger(parsed)) return "INVALID"; + return parsed > 0 ? parsed : null; +} + function formatPlaceholderRelationMessage(role: "Sire" | "Dam"): string { return `${role} registration is a placeholder and was treated as unknown, so the placeholder reference was not written to the new database.`; } @@ -294,6 +307,11 @@ export async function runLegacyPhase1( ); finishStage("load"); + startStage("dogColors"); + const seededDogColors = await seedDogColorsDb(); + const importedDogColorCodes = new Set(seededDogColors.codes); + finishStage("dogColors", `upserted=${seededDogColors.codes.length}`); + startStage("breeders"); let breederRowsProcessed = 0; let breederRowsUpserted = 0; @@ -469,6 +487,40 @@ export async function runLegacyPhase1( } const breederNameText = normalizeNullable(row.breederName); + const colorCode = parseLegacyColorCode(row.colorCode); + if (colorCode === "INVALID") { + await recordIssue({ + stage: "dogs", + code: "DOG_COLOR_INVALID_CODE", + message: + "Dog row has invalid color code; color was treated as unknown.", + registrationNo, + sourceTable: "bearek_id", + payloadJson: JSON.stringify({ + registrationNo: row.registrationNo, + colorCode: row.colorCode, + }), + }); + } else if (colorCode != null && !importedDogColorCodes.has(colorCode)) { + await recordIssue({ + stage: "dogs", + code: "DOG_COLOR_LOOKUP_NOT_FOUND", + message: + "Dog row references a color code missing from the canonical catalog; color was treated as unknown.", + registrationNo, + sourceTable: "bearek_id", + payloadJson: JSON.stringify({ + registrationNo: row.registrationNo, + colorCode, + }), + }); + } + const dogColorCode = + colorCode === "INVALID" || + !colorCode || + !importedDogColorCodes.has(colorCode) + ? null + : colorCode; if (breederNameText) { dogsWithBreederText += 1; } @@ -513,6 +565,7 @@ export async function runLegacyPhase1( birthDate: parseLegacyDate(row.birthDateRaw), breederNameText, breederId, + colorCode: dogColorCode, }, }); @@ -530,6 +583,7 @@ export async function runLegacyPhase1( birthDate: parseLegacyDate(row.birthDateRaw), breederNameText, breederId, + colorCode: dogColorCode, registrations: { create: { registrationNo, diff --git a/packages/server/index.ts b/packages/server/index.ts index c76eb284..f5da2d6a 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -10,6 +10,7 @@ export { deleteAdminUser, listAdminBreederOptions, listAdminDogDiseases, + listAdminDogColorOptions, listAdminDogParentOptions, listAdminDogs, listAdminUsers,