From 02ca3f5538014b605c0f3cbeb6def76b5d93bfd8 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Thu, 7 May 2026 18:17:20 +0300 Subject: [PATCH 1/5] feat(dogs): add color lookup support Add dog color storage, legacy import mapping, public profile display, and admin form color selection backed by lookup data. Files: - CHANGELOG.md - apps/web/app/actions/admin/dogs/lookups/get-admin-dog-color-options.ts - apps/web/components/admin/dogs/admin-dogs-page-client.tsx - apps/web/components/admin/dogs/dog-form-modal.tsx - apps/web/components/admin/dogs/internal/dog-form-metadata-section.tsx - apps/web/components/beagle-dog-profile/dog-profile-details-card.tsx - apps/web/lib/admin/dogs/manage/dog-form-mappers.ts - apps/web/lib/admin/dogs/manage/dog-mutation-flow.ts - apps/web/queries/admin/dogs/lookups/use-admin-dog-color-options-query.ts - docs/features/admin-dog-management.md - docs/features/beagle-dog-profile.md - docs/features/schema/schema.md - docs/legacy-import/phase1.md - packages/contracts/admin/dogs/lookups/lookup-options.ts - packages/contracts/admin/dogs/manage/admin-dogs-list.ts - packages/contracts/admin/dogs/manage/create-admin-dog.ts - packages/contracts/admin/dogs/manage/update-admin-dog.ts - packages/db/admin/dogs/lookups/list-color-options.ts - packages/db/prisma/migrations/20260506120000_add_dog_colors/migration.sql - packages/db/prisma/schema.prisma - packages/server/admin/dogs/lookups/list-color-options.ts - packages/server/admin/dogs/manage/create-dog.ts - packages/server/admin/dogs/manage/update-dog.ts - packages/server/imports/phase1/run-legacy-phase1.ts - related tests and index exports ref bej-107 --- CHANGELOG.md | 2 + .../lookups/get-admin-dog-color-options.ts | 52 +++++++++++ .../manage/__tests__/get-admin-dogs.test.ts | 2 + .../__tests__/admin-dogs-page-client.test.tsx | 16 ++++ .../dogs/__tests__/dog-form-modal.test.ts | 6 ++ .../admin/dogs/__tests__/dog-results.test.tsx | 1 + .../admin/dogs/admin-dogs-page-client.tsx | 27 ++++++ .../components/admin/dogs/dog-form-modal.tsx | 3 + .../internal/dog-form-metadata-section.tsx | 12 +++ apps/web/components/admin/dogs/types.ts | 2 + .../dog-profile-details-card.tsx | 5 +- .../__tests__/use-admin-dog-form-flow.test.ts | 3 + .../dog-form-section-updates.test.ts | 1 + .../manage/__tests__/dog-manage-lib.test.ts | 3 + .../lib/admin/dogs/manage/dog-form-mappers.ts | 3 + .../admin/dogs/manage/dog-mutation-flow.ts | 14 +++ .../lib/i18n/messages/admin/dogs/common.ts | 3 + apps/web/lib/i18n/messages/admin/dogs/form.ts | 2 + apps/web/queries/admin/dogs/index.ts | 1 + .../use-admin-dog-color-options-query.ts | 23 +++++ .../__tests__/use-admin-dogs-query.test.ts | 2 + .../queries/admin/dogs/manage/query-keys.ts | 8 ++ docs/features/admin-dog-management.md | 4 +- docs/features/beagle-dog-profile.md | 2 + docs/features/schema/schema.md | 5 +- docs/legacy-import/phase1.md | 8 ++ .../contracts/admin/dogs/lookups/index.ts | 2 + .../admin/dogs/lookups/lookup-options.ts | 11 +++ .../admin/dogs/manage/admin-dogs-list.ts | 1 + .../admin/dogs/manage/create-admin-dog.ts | 1 + .../admin/dogs/manage/update-admin-dog.ts | 1 + packages/contracts/index.ts | 2 + packages/db/admin/dogs/lookups/index.ts | 5 + .../admin/dogs/lookups/list-color-options.ts | 26 ++++++ .../dogs/manage/__tests__/create-dog.test.ts | 2 + packages/db/admin/dogs/manage/create-dog.ts | 2 + packages/db/admin/dogs/manage/list-dogs.ts | 3 + packages/db/admin/dogs/manage/update-dog.ts | 2 + .../db/dogs/profile/get-beagle-dog-profile.ts | 2 +- .../profile/internal/profile-base-query.ts | 7 ++ .../imports/phase1/__tests__/source.test.ts | 15 ++- packages/db/imports/phase1/source.ts | 14 ++- packages/db/imports/types.ts | 7 ++ packages/db/index.ts | 3 + .../migration.sql | 33 +++++++ packages/db/prisma/schema.prisma | 61 ++++++++----- packages/server/admin/dogs/index.ts | 1 + .../admin/dogs/lookups/list-color-options.ts | 81 +++++++++++++++++ .../dogs/manage/__tests__/create-dog.test.ts | 1 + .../server/admin/dogs/manage/create-dog.ts | 19 ++++ .../internal/create-input-validation.ts | 13 +++ .../dogs/manage/internal/manage-responses.ts | 13 +++ .../internal/update-input-validation.ts | 20 ++++ .../server/admin/dogs/manage/list-dogs.ts | 1 + .../server/admin/dogs/manage/update-dog.ts | 19 ++++ packages/server/admin/index.ts | 1 + .../__tests__/run-legacy-phase1.test.ts | 12 +++ .../imports/phase1/run-legacy-phase1.ts | 91 ++++++++++++++++++- packages/server/index.ts | 1 + 59 files changed, 645 insertions(+), 38 deletions(-) create mode 100644 apps/web/app/actions/admin/dogs/lookups/get-admin-dog-color-options.ts create mode 100644 apps/web/queries/admin/dogs/lookups/use-admin-dog-color-options-query.ts create mode 100644 packages/db/admin/dogs/lookups/list-color-options.ts create mode 100644 packages/db/prisma/migrations/20260506120000_add_dog_colors/migration.sql create mode 100644 packages/server/admin/dogs/lookups/list-color-options.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f2ea13..4d101690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ This project uses a user-facing changelog format. ### Added +- Koirien värit tuodaan nyt vanhasta järjestelmästä, näkyvät koiraprofiilissa ja ovat muokattavissa ylläpidon koiralomakkeella. + ### Changed ### Fixed diff --git a/apps/web/app/actions/admin/dogs/lookups/get-admin-dog-color-options.ts b/apps/web/app/actions/admin/dogs/lookups/get-admin-dog-color-options.ts new file mode 100644 index 00000000..b281ceb6 --- /dev/null +++ b/apps/web/app/actions/admin/dogs/lookups/get-admin-dog-color-options.ts @@ -0,0 +1,52 @@ +"use server"; + +import type { AdminDogColorLookupResponse } from "@beagle/contracts"; +import { listAdminDogColorOptions } from "@beagle/server"; +import { requireAdminLayoutAccess } from "@/lib/server/admin-guard"; +import { getSessionCurrentUser } from "@/lib/server/current-user"; + +export type AdminDogColorOptionsActionResult = { + data: AdminDogColorLookupResponse | null; + hasError: boolean; + errorCode?: string; +}; + +export async function getAdminDogColorOptionsAction(): Promise { + const adminAccess = await requireAdminLayoutAccess(); + if (!adminAccess.ok) { + return { + data: null, + hasError: true, + errorCode: adminAccess.status === 401 ? "UNAUTHENTICATED" : "FORBIDDEN", + }; + } + + const currentUser = await getSessionCurrentUser(); + if (!currentUser) { + return { + data: null, + hasError: true, + errorCode: "UNAUTHENTICATED", + }; + } + + const result = await listAdminDogColorOptions({ + id: currentUser.id, + email: currentUser.email, + username: currentUser.name, + role: currentUser.role, + }); + + if (!result.body.ok) { + return { + data: null, + hasError: true, + errorCode: result.body.code, + }; + } + + return { + data: result.body.data, + hasError: false, + }; +} 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/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..9b6ebc29 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,9 @@ describe("AdminDogsPageClient", () => { isLoading: false, isError: false, }); + useAdminDogColorOptionsQueryMock.mockReturnValue({ + data: [{ code: 121, nameFi: "Kolmivärinen", nameSv: null, nameEn: null }], + }); useAdminDogOwnerOptionsQueryMock.mockReturnValue({ data: [{ id: "o_1", name: "Tiina Virtanen" }], }); @@ -259,6 +268,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", "", ""], + }, + ]); 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..f219e898 100644 --- a/apps/web/components/admin/dogs/admin-dogs-page-client.tsx +++ b/apps/web/components/admin/dogs/admin-dogs-page-client.tsx @@ -11,6 +11,7 @@ import { toAdminDogParentOptions, } from "@/lib/admin/dogs/manage"; import { + useAdminDogColorOptionsQuery, useDeleteAdminDogMutation, useAdminDogOwnerOptionsQuery, useAdminDogParentOptionsQuery, @@ -65,6 +66,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 +98,28 @@ export function AdminDogsPageClient() { dogFormFlow.formValues.damPreviewName, ], ); + const colorOptions = useMemo( + () => + (colorOptionsQuery.data ?? []).map((option) => { + const localizedName = + option.nameFi || + option.nameSv || + option.nameEn || + String(option.code); + + return { + value: String(option.code), + label: `${option.code} - ${localizedName}`, + keywords: [ + String(option.code), + option.nameFi, + option.nameSv ?? "", + option.nameEn ?? "", + ], + }; + }), + [colorOptionsQuery.data], + ); const resultCount = dogs.length; @@ -142,6 +168,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/types.ts b/apps/web/components/admin/dogs/types.ts index 4c88a4b2..50958ca5 100644 --- a/apps/web/components/admin/dogs/types.ts +++ b/apps/web/components/admin/dogs/types.ts @@ -24,6 +24,7 @@ export type AdminDogRecord = { titlesText: string | null; ownershipPreview: string[]; ekNo: number | null; + colorCode: number | null; note: string | null; registrationNo: string | null; secondaryRegistrationNos: string[]; @@ -46,6 +47,7 @@ export type AdminDogFormValues = { ownershipNames: string[]; ekNo: string; inbreedingCoefficientPct: number | null; + colorCode: string; note: string; registrationNo: string; secondaryRegistrationNos: string[]; 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..e5eda417 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 @@ -184,10 +184,7 @@ export function DogProfileDetailsCard({ /> {profile.ekNo != null && ( { 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/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/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..e455ed32 --- /dev/null +++ b/apps/web/queries/admin/dogs/lookups/use-admin-dog-color-options-query.ts @@ -0,0 +1,23 @@ +"use client"; + +import type { AdminDogColorLookupOption } from "@beagle/contracts"; +import { useQuery } from "@tanstack/react-query"; +import { getAdminDogColorOptionsAction } from "@/app/actions/admin/dogs/lookups/get-admin-dog-color-options"; +import { adminDogColorOptionsQueryKey } from "@/queries/admin/dogs/manage/query-keys"; + +export function useAdminDogColorOptionsQuery(enabled = true) { + return useQuery({ + queryKey: adminDogColorOptionsQueryKey(), + queryFn: async () => { + const result = await getAdminDogColorOptionsAction(); + if (result.hasError || !result.data) { + 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..eaece138 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,7 @@ 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 edits it with lookup-backed options from `DogColor`. ## Tests diff --git a/docs/features/beagle-dog-profile.md b/docs/features/beagle-dog-profile.md index 197f1bed..076adc25 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,6 +53,7 @@ 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 color comes from the `DogColor` lookup linked by `Dog.colorCode`; missing/unknown color renders as the standard fallback - 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 diff --git a/docs/features/schema/schema.md b/docs/features/schema/schema.md index 85725e60..8e82b69f 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`: Kennelliitto-style dog color lookup referenced by `Dog.colorCode`. - `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..7a1cdccb 100644 --- a/docs/legacy-import/phase1.md +++ b/docs/legacy-import/phase1.md @@ -14,6 +14,7 @@ Phase 1 imports foundation entities and link structures. It does not import tria ## Primary source tables - `bearek_id` +- `beacolor` - `kennel` - `beaom` - `bea_apu` @@ -27,6 +28,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` +- `beacolor` -> `DogColor` lookup rows (`code`, Finnish name). - `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 +39,7 @@ Phase 1 imports foundation entities and link structures. It does not import tria ## Main writes - `Dog` +- `DogColor` - `DogRegistration` - `Breeder` - `Owner` @@ -60,6 +64,10 @@ 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: + - `beacolor` is imported before dogs and is the single color label source + - `bearek_id.COLCODE` values of `0`, empty, or null are treated as unknown + - invalid or missing lookup color codes are reported as issues and stored as unknown ## Idempotency and rerun behavior 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..e87d3355 100644 --- a/packages/contracts/admin/dogs/lookups/lookup-options.ts +++ b/packages/contracts/admin/dogs/lookups/lookup-options.ts @@ -22,6 +22,13 @@ export type AdminDogParentLookupOption = { registrationNo: string | null; }; +export type AdminDogColorLookupOption = { + code: number; + nameFi: string; + nameSv: string | null; + nameEn: string | null; +}; + export type AdminBreederLookupResponse = { items: AdminBreederLookupOption[]; }; @@ -33,3 +40,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/index.ts b/packages/contracts/index.ts index 18a7e00e..1e33e8be 100644 --- a/packages/contracts/index.ts +++ b/packages/contracts/index.ts @@ -212,6 +212,7 @@ export type { AdminBreederLookupOption, AdminOwnerLookupOption, AdminDogParentLookupOption, + AdminDogColorLookupOption, AdminBreederLookupResponse, AdminOwnerLookupResponse, AdminDogParentLookupResponse, @@ -229,6 +230,7 @@ export type { AdminVirtualPairingDiagnosticsDto, CalculateAdminVirtualPairingRequest, CalculateAdminVirtualPairingResponse, + AdminDogColorLookupResponse, AdminShowDetailsEvent, AdminShowDetailsRequest, AdminShowDetailsResponse, diff --git a/packages/db/admin/dogs/lookups/index.ts b/packages/db/admin/dogs/lookups/index.ts index 1a03cca2..fac77b88 100644 --- a/packages/db/admin/dogs/lookups/index.ts +++ b/packages/db/admin/dogs/lookups/index.ts @@ -16,3 +16,8 @@ export { type AdminDogParentLookupOptionDb, type AdminDogParentLookupResponseDb, } from "./list-parent-options"; +export { + listAdminDogColorOptionsDb, + type AdminDogColorLookupOptionDb, + type AdminDogColorLookupResponseDb, +} from "./list-color-options"; 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..0f947f30 --- /dev/null +++ b/packages/db/admin/dogs/lookups/list-color-options.ts @@ -0,0 +1,26 @@ +import { prisma } from "@db/core/prisma"; + +export type AdminDogColorLookupOptionDb = { + code: number; + nameFi: string; + nameSv: string | null; + nameEn: string | null; +}; + +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, + }, + 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/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/dogs/profile/get-beagle-dog-profile.ts b/packages/db/dogs/profile/get-beagle-dog-profile.ts index 6c419acf..fddeed8e 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?.nameFi ?? dog.color?.nameSv ?? dog.color?.nameEn ?? null, ekNo: dog.ekNo, sire: mapParent(dog.sire), dam: mapParent(dog.dam), diff --git a/packages/db/dogs/profile/internal/profile-base-query.ts b/packages/db/dogs/profile/internal/profile-base-query.ts index 9ef633f0..17611a3a 100644 --- a/packages/db/dogs/profile/internal/profile-base-query.ts +++ b/packages/db/dogs/profile/internal/profile-base-query.ts @@ -154,6 +154,13 @@ export async function getDogProfileBaseRow(dogId: string) { }, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }], }, + color: { + select: { + nameFi: true, + nameSv: true, + nameEn: true, + }, + }, }, }); } diff --git a/packages/db/imports/phase1/__tests__/source.test.ts b/packages/db/imports/phase1/__tests__/source.test.ts index 30d48233..6a868307 100644 --- a/packages/db/imports/phase1/__tests__/source.test.ts +++ b/packages/db/imports/phase1/__tests__/source.test.ts @@ -17,10 +17,17 @@ 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([]) + .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..4b4339d0 100644 --- a/packages/db/imports/phase1/source.ts +++ b/packages/db/imports/phase1/source.ts @@ -27,13 +27,24 @@ 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( `Fetched dogs rows: count=${dogs.length}, elapsed=${Math.round((Date.now() - dogsStartedAt) / 1000)}s`, ); + const dogColorsStartedAt = Date.now(); + const dogColors = (await connection.query( + `SELECT COLCODE as code, + COLOR as name + FROM beacolor`, + )) as LegacyPhase1Rows["dogColors"]; + log( + `Fetched dog color rows: count=${dogColors.length}, elapsed=${Math.round((Date.now() - dogColorsStartedAt) / 1000)}s`, + ); + const breedersStartedAt = Date.now(); const breeders = (await connection.query( `SELECT KENNEL as name, @@ -91,6 +102,7 @@ export async function fetchLegacyPhase1Rows(options?: { return { dogs, + dogColors, breeders, eks, owners, diff --git a/packages/db/imports/types.ts b/packages/db/imports/types.ts index 7b10e99a..704318e1 100644 --- a/packages/db/imports/types.ts +++ b/packages/db/imports/types.ts @@ -6,6 +6,12 @@ export type LegacyDogRow = { sireRegistrationNo: string | null; damRegistrationNo: string | null; breederName: string | null; + colorCode: number | string | null; +}; + +export type LegacyDogColorRow = { + code: number | string | null; + name: string | null; }; export type LegacyBreederRow = { @@ -258,6 +264,7 @@ export type LegacySamakoiraRow = { export type LegacyPhase1Rows = { dogs: LegacyDogRow[]; + dogColors: LegacyDogColorRow[]; breeders: LegacyBreederRow[]; eks: LegacyEkRow[]; owners: LegacyOwnerRow[]; diff --git a/packages/db/index.ts b/packages/db/index.ts index d2c32557..3a4018ec 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -214,6 +214,7 @@ export { runAdminDogWriteTransactionDb, linkUnlinkedShowTrialEntriesByRegistrationDb, listAdminBreederOptionsDb, + listAdminDogColorOptionsDb, listAdminOwnerOptionsDb, listAdminDogParentOptionsDb, runAdminUserWriteTransactionDb, @@ -248,6 +249,8 @@ export { type AdminDogParentLookupRequestDb, type AdminDogParentLookupOptionDb, type AdminDogParentLookupResponseDb, + type AdminDogColorLookupOptionDb, + type AdminDogColorLookupResponseDb, type CreateAdminDogDbInput, type CreatedAdminDogRowDb, type LinkUnlinkedShowTrialEntriesDbInput, 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/schema.prisma b/packages/db/prisma/schema.prisma index 53611ad4..3ffeb54c 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -197,30 +197,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 +230,17 @@ model Dog { @@index([sireId]) @@index([damId]) @@index([breederId]) + @@index([colorCode]) +} + +model DogColor { + code Int @id + nameFi String + nameSv String? + nameEn String? + dogs Dog[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model DogTitle { 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/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..ac6c38dd 100644 --- a/packages/server/admin/dogs/manage/__tests__/create-dog.test.ts +++ b/packages/server/admin/dogs/manage/__tests__/create-dog.test.ts @@ -241,6 +241,7 @@ describe("createAdminDog", () => { damId: "dam_1", ownerNames: ["Tiina Virtanen"], ekNo: 5588, + colorCode: null, note: "Important", registrationNo: "FI12345/21", secondaryRegistrationNos: [], diff --git a/packages/server/admin/dogs/manage/create-dog.ts b/packages/server/admin/dogs/manage/create-dog.ts index ffbe7152..48b6b608 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, + listAdminDogColorOptionsDb, runAdminDogWriteTransactionDb, type AuditContextDb, } from "@beagle/db"; @@ -87,6 +88,23 @@ export async function createAdminDog( if (!parentValidation.ok) { return parentValidation.response; } + if (preflight.data.colorCode != null) { + const colorOptions = await listAdminDogColorOptionsDb(); + if ( + !colorOptions.items.some( + (item) => item.code === preflight.data.colorCode, + ) + ) { + return { + status: 400, + body: { + ok: false, + error: "Color code was not found.", + code: "INVALID_COLOR_CODE", + }, + }; + } + } const { createdDog, linkResult } = await runAdminDogWriteTransactionDb( async (tx) => { @@ -100,6 +118,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/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..d05b78e2 100644 --- a/packages/server/admin/dogs/manage/internal/manage-responses.ts +++ b/packages/server/admin/dogs/manage/internal/manage-responses.ts @@ -102,6 +102,19 @@ export function invalidEkNoResponse< } as ServiceResult; } +export function invalidColorCodeResponse< + T extends ManageErrorTarget, +>(): ServiceResult { + return { + status: 400, + body: { + ok: false, + error: "Color code was not found.", + code: "INVALID_COLOR_CODE", + }, + } 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..157b55a1 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, + listAdminDogColorOptionsDb, runAdminDogWriteTransactionDb, updateAdminDogWriteDb, type AuditContextDb, @@ -126,6 +127,23 @@ export async function updateAdminDog( if (parentGuardResult && !parentGuardResult.ok) { return parentGuardResult.response; } + if (preflight.data.colorCode != null) { + const colorOptions = await listAdminDogColorOptionsDb(); + if ( + !colorOptions.items.some( + (item) => item.code === preflight.data.colorCode, + ) + ) { + return { + status: 400, + body: { + ok: false, + error: "Color code was not found.", + code: "INVALID_COLOR_CODE", + }, + }; + } + } const updatedDog = await runAdminDogWriteTransactionDb( async (tx) => @@ -146,6 +164,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..43a17a17 100644 --- a/packages/server/imports/phase1/__tests__/run-legacy-phase1.test.ts +++ b/packages/server/imports/phase1/__tests__/run-legacy-phase1.test.ts @@ -15,6 +15,7 @@ const { dogRegistrationUpdateMock, dogCreateMock, dogUpdateMock, + dogColorUpsertMock, ownerFindFirstMock, ownerCreateMock, dogOwnershipCreateManyMock, @@ -32,6 +33,7 @@ const { dogRegistrationUpdateMock: vi.fn(), dogCreateMock: vi.fn(), dogUpdateMock: vi.fn(), + dogColorUpsertMock: vi.fn(), ownerFindFirstMock: vi.fn(), ownerCreateMock: vi.fn(), dogOwnershipCreateManyMock: vi.fn(), @@ -66,6 +68,9 @@ vi.mock("@beagle/db", () => ({ update: dogUpdateMock, findUnique: vi.fn(), }, + dogColor: { + upsert: dogColorUpsertMock, + }, owner: { findFirst: ownerFindFirstMock, create: ownerCreateMock, @@ -92,6 +97,7 @@ describe("runLegacyPhase1", () => { dogRegistrationUpdateMock.mockReset(); dogCreateMock.mockReset(); dogUpdateMock.mockReset(); + dogColorUpsertMock.mockReset(); ownerFindFirstMock.mockReset(); ownerCreateMock.mockReset(); dogOwnershipCreateManyMock.mockReset(); @@ -118,6 +124,7 @@ describe("runLegacyPhase1", () => { }); fetchLegacyPhase1RowsMock.mockResolvedValue({ + dogColors: [], dogs: [ { registrationNo: "FI12345/21", @@ -193,6 +200,7 @@ describe("runLegacyPhase1", () => { it("explains that missing KNIMI prevented the dog from being imported and linked", async () => { fetchLegacyPhase1RowsMock.mockResolvedValue({ + dogColors: [], dogs: [ { registrationNo: "S87477", @@ -242,6 +250,7 @@ describe("runLegacyPhase1", () => { it("describes placeholder parent registrations as unknown and excluded from the new database", async () => { fetchLegacyPhase1RowsMock.mockResolvedValue({ + dogColors: [], dogs: [ { registrationNo: "FI12345/21", @@ -294,6 +303,7 @@ describe("runLegacyPhase1", () => { it("skips a null EK row without writing an EK value", async () => { fetchLegacyPhase1RowsMock.mockResolvedValue({ + dogColors: [], dogs: [ { registrationNo: "FI12345/21", @@ -332,6 +342,7 @@ describe("runLegacyPhase1", () => { it("does not record an issue for an empty REK_3 alias slot", async () => { fetchLegacyPhase1RowsMock.mockResolvedValue({ + dogColors: [], dogs: [ { registrationNo: "FI12345/21", @@ -389,6 +400,7 @@ describe("runLegacyPhase1", () => { it("records an empty REK_2 alias slot as a warning", async () => { fetchLegacyPhase1RowsMock.mockResolvedValue({ + dogColors: [], dogs: [ { registrationNo: "FI12345/21", diff --git a/packages/server/imports/phase1/run-legacy-phase1.ts b/packages/server/imports/phase1/run-legacy-phase1.ts index 7ccc5328..150b10d2 100644 --- a/packages/server/imports/phase1/run-legacy-phase1.ts +++ b/packages/server/imports/phase1/run-legacy-phase1.ts @@ -72,6 +72,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.`; } @@ -290,10 +302,51 @@ export async function runLegacyPhase1( (row) => row.ekNo != null, ).length; log( - `Loaded legacy source rows: dogs=${legacy.dogs.length}, breeders=${legacy.breeders.length}, bea_apuRows=${beaApuRows}, bea_apuRowsWithEkNo=${beaApuRowsWithEkNo}, owners=${legacy.owners.length}, samakoira=${legacy.samakoira.length}`, + `Loaded legacy source rows: dogs=${legacy.dogs.length}, dogColors=${legacy.dogColors.length}, breeders=${legacy.breeders.length}, bea_apuRows=${beaApuRows}, bea_apuRowsWithEkNo=${beaApuRowsWithEkNo}, owners=${legacy.owners.length}, samakoira=${legacy.samakoira.length}`, ); finishStage("load"); + startStage("dogColors"); + let dogColorsProcessed = 0; + let dogColorsUpserted = 0; + let dogColorsSkipped = 0; + const totalDogColors = legacy.dogColors.length; + const importedDogColorCodes = new Set(); + for (const row of legacy.dogColors) { + dogColorsProcessed += 1; + const code = parseLegacyColorCode(row.code); + const name = normalizeNullable(row.name); + + if (code === "INVALID" || code == null || !name) { + dogColorsSkipped += 1; + await recordIssue({ + stage: "dogColors", + code: "DOG_COLOR_INVALID_LOOKUP_ROW", + message: "Dog color lookup row has invalid code or missing name.", + sourceTable: "beacolor", + payloadJson: JSON.stringify(row), + }); + continue; + } + + await prisma.dogColor.upsert({ + where: { code }, + create: { + code, + nameFi: name, + }, + update: { + nameFi: name, + }, + }); + importedDogColorCodes.add(code); + dogColorsUpserted += 1; + } + finishStage( + "dogColors", + `upserted=${dogColorsUpserted}, skipped=${dogColorsSkipped}`, + ); + startStage("breeders"); let breederRowsProcessed = 0; let breederRowsUpserted = 0; @@ -469,6 +522,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 beacolor; 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 +600,7 @@ export async function runLegacyPhase1( birthDate: parseLegacyDate(row.birthDateRaw), breederNameText, breederId, + colorCode: dogColorCode, }, }); @@ -530,6 +618,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, From b29634a3de3f647b81b95d61dc8cc93f77ca341b Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:32:05 +0300 Subject: [PATCH 2/5] test: add dog color lookup coverage - cover the admin dog color lookup service, action wrapper, and query hook - verify auth, success, and failure branches so the coverage gate passes ref bej-107 --- .../get-admin-dog-color-options.test.ts | 107 ++++++++++++++++++ .../use-admin-dog-color-options-query.test.ts | 78 +++++++++++++ .../__tests__/list-color-options.test.ts | 102 +++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 apps/web/app/actions/admin/dogs/lookups/__tests__/get-admin-dog-color-options.test.ts create mode 100644 apps/web/queries/admin/dogs/lookups/__tests__/use-admin-dog-color-options-query.test.ts create mode 100644 packages/server/admin/dogs/lookups/__tests__/list-color-options.test.ts diff --git a/apps/web/app/actions/admin/dogs/lookups/__tests__/get-admin-dog-color-options.test.ts b/apps/web/app/actions/admin/dogs/lookups/__tests__/get-admin-dog-color-options.test.ts new file mode 100644 index 00000000..cd0e838d --- /dev/null +++ b/apps/web/app/actions/admin/dogs/lookups/__tests__/get-admin-dog-color-options.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getAdminDogColorOptionsAction } from "../get-admin-dog-color-options"; + +const { + requireAdminLayoutAccessMock, + getSessionCurrentUserMock, + listAdminDogColorOptionsMock, +} = vi.hoisted(() => ({ + requireAdminLayoutAccessMock: vi.fn(), + getSessionCurrentUserMock: vi.fn(), + listAdminDogColorOptionsMock: vi.fn(), +})); + +vi.mock("@/lib/server/admin-guard", () => ({ + requireAdminLayoutAccess: requireAdminLayoutAccessMock, +})); + +vi.mock("@/lib/server/current-user", () => ({ + getSessionCurrentUser: getSessionCurrentUserMock, +})); + +vi.mock("@beagle/server", () => ({ + listAdminDogColorOptions: listAdminDogColorOptionsMock, +})); + +describe("getAdminDogColorOptionsAction", () => { + beforeEach(() => { + requireAdminLayoutAccessMock.mockReset(); + getSessionCurrentUserMock.mockReset(); + listAdminDogColorOptionsMock.mockReset(); + }); + + it("returns unauthenticated when admin access is denied with 401", async () => { + requireAdminLayoutAccessMock.mockResolvedValue({ ok: false, status: 401 }); + + await expect(getAdminDogColorOptionsAction()).resolves.toEqual({ + data: null, + hasError: true, + errorCode: "UNAUTHENTICATED", + }); + }); + + it("returns unauthenticated when there is no current user", async () => { + requireAdminLayoutAccessMock.mockResolvedValue({ ok: true }); + getSessionCurrentUserMock.mockResolvedValue(null); + + await expect(getAdminDogColorOptionsAction()).resolves.toEqual({ + data: null, + hasError: true, + errorCode: "UNAUTHENTICATED", + }); + }); + + it("returns color options when service succeeds", async () => { + requireAdminLayoutAccessMock.mockResolvedValue({ ok: true }); + getSessionCurrentUserMock.mockResolvedValue({ + id: "u_1", + email: "admin@example.com", + name: "Admin", + role: "ADMIN", + createdAt: null, + sessionId: "s_1", + }); + listAdminDogColorOptionsMock.mockResolvedValue({ + status: 200, + body: { + ok: true, + data: { + items: [{ id: "dc_1", name: "Musta" }], + }, + }, + }); + + await expect(getAdminDogColorOptionsAction()).resolves.toEqual({ + data: { + items: [{ id: "dc_1", name: "Musta" }], + }, + hasError: false, + }); + }); + + it("returns service error code when lookup fails", async () => { + requireAdminLayoutAccessMock.mockResolvedValue({ ok: true }); + getSessionCurrentUserMock.mockResolvedValue({ + id: "u_1", + email: "admin@example.com", + name: "Admin", + role: "ADMIN", + createdAt: null, + sessionId: "s_1", + }); + listAdminDogColorOptionsMock.mockResolvedValue({ + status: 500, + body: { + ok: false, + error: "Failed to load color options.", + code: "INTERNAL_ERROR", + }, + }); + + await expect(getAdminDogColorOptionsAction()).resolves.toEqual({ + data: null, + hasError: true, + errorCode: "INTERNAL_ERROR", + }); + }); +}); 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..15adaabd --- /dev/null +++ b/apps/web/queries/admin/dogs/lookups/__tests__/use-admin-dog-color-options-query.test.ts @@ -0,0 +1,78 @@ +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, getAdminDogColorOptionsActionMock } = vi.hoisted(() => ({ + useQueryMock: vi.fn(), + getAdminDogColorOptionsActionMock: vi.fn(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: useQueryMock, +})); + +vi.mock("@/app/actions/admin/dogs/lookups/get-admin-dog-color-options", () => ({ + getAdminDogColorOptionsAction: getAdminDogColorOptionsActionMock, +})); + +describe("useAdminDogColorOptionsQuery", () => { + beforeEach(() => { + useQueryMock.mockReset(); + getAdminDogColorOptionsActionMock.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); + getAdminDogColorOptionsActionMock.mockResolvedValue({ + hasError: false, + 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); + getAdminDogColorOptionsActionMock.mockResolvedValue({ + hasError: true, + data: null, + errorCode: "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/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..ac0275df --- /dev/null +++ b/packages/server/admin/dogs/lookups/__tests__/list-color-options.test.ts @@ -0,0 +1,102 @@ +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" }, + { code: 2, nameFi: "Valkoinen", nameSv: null, nameEn: "White" }, + ], + }); + + 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" }, + { code: 2, nameFi: "Valkoinen", nameSv: null, nameEn: "White" }, + ], + }, + }, + }); + + 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", + }, + }); + }); +}); From 3c6a8d274480e989fb3aa7c57fce72aefb2eb91b Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:54:14 +0300 Subject: [PATCH 3/5] feat(dogs): add canonical color catalog and legacy statuses Seed and expose a canonical dog color catalog with status metadata, use it for admin selection and profile display, and preserve legacy hidden colors during updates and phase 1 import. ref bej-107 --- CHANGELOG.md | 4 +- .../__tests__/admin-dogs-page-client.test.tsx | 12 +- .../admin/dogs/admin-dogs-page-client.tsx | 48 +++++--- .../__tests__/admin-dog-profile-page.test.tsx | 2 +- .../dogs/profile/admin-dog-profile-page.tsx | 8 +- .../__tests__/beagle-dog-profile-page.test.ts | 3 + .../dog-profile-details-card.tsx | 3 +- .../dog-profile-litters-card.tsx | 21 ++-- .../dog-profile-siblings-card.tsx | 35 ++++-- apps/web/lib/dogs/__tests__/color.test.ts | 32 +++++ apps/web/lib/dogs/color.ts | 21 ++++ docs/features/admin-dog-management.md | 3 +- docs/features/beagle-dog-profile.md | 5 +- docs/features/schema/schema.md | 2 +- docs/legacy-import/phase1.md | 8 +- docs/ops-env-safety.md | 10 ++ docs/planning/dog-color-catalog-import.md | 20 ++++ package.json | 1 + .../admin/dogs/lookups/lookup-options.ts | 8 +- .../admin/dogs/profile/admin-dog-profile.ts | 4 +- packages/contracts/dogs/colors/dog-color.ts | 9 ++ packages/contracts/dogs/colors/index.ts | 1 + packages/contracts/dogs/index.ts | 1 + .../contracts/dogs/profile/beagle-profile.ts | 5 +- packages/contracts/index.ts | 2 + .../admin/dogs/lookups/find-color-option.ts | 8 ++ packages/db/admin/dogs/lookups/index.ts | 1 + .../admin/dogs/lookups/list-color-options.ts | 2 + .../db/admin/dogs/manage/find-dog-by-id.ts | 3 + .../profile/internal/profile-db-mappers.ts | 2 +- .../dogs/profile/internal/profile-select.ts | 3 + packages/db/admin/dogs/profile/types.ts | 3 +- .../dogs/colors/__tests__/definitions.test.ts | 36 ++++++ .../colors/__tests__/seed-dog-colors.test.ts | 32 +++++ packages/db/dogs/colors/definitions.ts | 109 ++++++++++++++++++ packages/db/dogs/colors/index.ts | 1 + packages/db/dogs/colors/seed-dog-colors.ts | 19 +++ packages/db/dogs/index.ts | 1 + .../db/dogs/profile/get-beagle-dog-profile.ts | 2 +- .../profile/internal/offspring-litters.ts | 1 + .../profile/internal/profile-base-query.ts | 4 + .../internal/profile-siblings-query.ts | 1 + .../dogs/profile/internal/profile-siblings.ts | 1 + .../db/dogs/profile/internal/profile-types.ts | 15 ++- .../imports/phase1/__tests__/source.test.ts | 1 - packages/db/imports/phase1/source.ts | 11 -- packages/db/imports/types.ts | 6 - packages/db/index.ts | 3 + packages/db/package.json | 1 + .../migration.sql | 4 + packages/db/prisma/schema.prisma | 13 ++- packages/db/scripts/seed-dog-colors.ts | 12 ++ .../__tests__/list-color-options.test.ts | 32 ++++- .../dogs/manage/__tests__/create-dog.test.ts | 28 +++++ .../dogs/manage/__tests__/update-dog.test.ts | 62 ++++++++++ .../server/admin/dogs/manage/create-dog.ts | 22 ++-- .../server/admin/dogs/manage/update-dog.ts | 25 ++-- .../__tests__/run-legacy-phase1.test.ts | 41 ++++++- .../imports/phase1/run-legacy-phase1.ts | 47 +------- 59 files changed, 669 insertions(+), 151 deletions(-) create mode 100644 apps/web/lib/dogs/__tests__/color.test.ts create mode 100644 apps/web/lib/dogs/color.ts create mode 100644 docs/planning/dog-color-catalog-import.md create mode 100644 packages/contracts/dogs/colors/dog-color.ts create mode 100644 packages/contracts/dogs/colors/index.ts create mode 100644 packages/db/admin/dogs/lookups/find-color-option.ts create mode 100644 packages/db/dogs/colors/__tests__/definitions.test.ts create mode 100644 packages/db/dogs/colors/__tests__/seed-dog-colors.test.ts create mode 100644 packages/db/dogs/colors/definitions.ts create mode 100644 packages/db/dogs/colors/index.ts create mode 100644 packages/db/dogs/colors/seed-dog-colors.ts create mode 100644 packages/db/prisma/migrations/20260623120000_add_dog_color_status/migration.sql create mode 100644 packages/db/scripts/seed-dog-colors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d101690..71b841ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,14 @@ This project uses a user-facing changelog format. ### Added -- Koirien värit tuodaan nyt vanhasta järjestelmästä, näkyvät koiraprofiilissa ja ovat muokattavissa ylläpidon koiralomakkeella. +- 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/components/admin/dogs/__tests__/admin-dogs-page-client.test.tsx b/apps/web/components/admin/dogs/__tests__/admin-dogs-page-client.test.tsx index 9b6ebc29..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 @@ -186,7 +186,15 @@ describe("AdminDogsPageClient", () => { isError: false, }); useAdminDogColorOptionsQueryMock.mockReturnValue({ - data: [{ code: 121, nameFi: "Kolmivärinen", nameSv: null, nameEn: null }], + data: [ + { + code: 121, + nameFi: "Kolmivärinen", + nameSv: "Trefärgad", + nameEn: null, + status: "SELECTABLE", + }, + ], }); useAdminDogOwnerOptionsQueryMock.mockReturnValue({ data: [{ id: "o_1", name: "Tiina Virtanen" }], @@ -272,7 +280,7 @@ describe("AdminDogsPageClient", () => { { value: "121", label: "121 - Kolmivärinen", - keywords: ["121", "Kolmivärinen", "", ""], + keywords: ["121", "Kolmivärinen", "Trefärgad", ""], }, ]); expect(dogResultsProps.dogs[0]?.titlesText).toBeNull(); 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 f219e898..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,6 +10,7 @@ import { toAdminDogOwnerOptions, toAdminDogParentOptions, } from "@/lib/admin/dogs/manage"; +import { formatDogColor } from "@/lib/dogs/color"; import { useAdminDogColorOptionsQuery, useDeleteAdminDogMutation, @@ -27,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"); @@ -100,25 +101,34 @@ export function AdminDogsPageClient() { ); const colorOptions = useMemo( () => - (colorOptionsQuery.data ?? []).map((option) => { - const localizedName = - option.nameFi || - option.nameSv || - option.nameEn || - String(option.code); + (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}`, - keywords: [ - String(option.code), - option.nameFi, - option.nameSv ?? "", - option.nameEn ?? "", - ], - }; - }), - [colorOptionsQuery.data], + 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; 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 e5eda417..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,7 +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/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/docs/features/admin-dog-management.md b/docs/features/admin-dog-management.md index eaece138..9f50ba13 100644 --- a/docs/features/admin-dog-management.md +++ b/docs/features/admin-dog-management.md @@ -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. -- Admin create/update payloads include optional `colorCode`; the form edits it with lookup-backed options from `DogColor`. +- 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 076adc25..9f1de7f1 100644 --- a/docs/features/beagle-dog-profile.md +++ b/docs/features/beagle-dog-profile.md @@ -53,10 +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 color comes from the `DogColor` lookup linked by `Dog.colorCode`; missing/unknown color renders as the standard fallback +- 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 8e82b69f..5d83dbe5 100644 --- a/docs/features/schema/schema.md +++ b/docs/features/schema/schema.md @@ -66,7 +66,7 @@ erDiagram ### Core dog data - `Dog`: central dog entity (pedigree links, breeder link, color code, timestamps). -- `DogColor`: Kennelliitto-style dog color lookup referenced by `Dog.colorCode`. +- `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). diff --git a/docs/legacy-import/phase1.md b/docs/legacy-import/phase1.md index 7a1cdccb..07411d23 100644 --- a/docs/legacy-import/phase1.md +++ b/docs/legacy-import/phase1.md @@ -14,7 +14,6 @@ Phase 1 imports foundation entities and link structures. It does not import tria ## Primary source tables - `bearek_id` -- `beacolor` - `kennel` - `beaom` - `bea_apu` @@ -29,7 +28,7 @@ Phase 1 imports foundation entities and link structures. It does not import tria - `SYNTY` -> `Dog.birthDate` - `KASVA` -> breeder text + breeder link candidate - `COLCODE` -> `Dog.colorCode` -- `beacolor` -> `DogColor` lookup rows (`code`, Finnish name). +- 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. @@ -65,9 +64,10 @@ Phase 1 imports foundation entities and link structures. It does not import tria - 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: - - `beacolor` is imported before dogs and is the single color label source + - 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 missing lookup color codes are reported as issues and stored 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/contracts/admin/dogs/lookups/lookup-options.ts b/packages/contracts/admin/dogs/lookups/lookup-options.ts index e87d3355..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,12 +23,7 @@ export type AdminDogParentLookupOption = { registrationNo: string | null; }; -export type AdminDogColorLookupOption = { - code: number; - nameFi: string; - nameSv: string | null; - nameEn: string | null; -}; +export type AdminDogColorLookupOption = DogColorDto; export type AdminBreederLookupResponse = { items: AdminBreederLookupOption[]; 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 1e33e8be..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, 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 fac77b88..62b75373 100644 --- a/packages/db/admin/dogs/lookups/index.ts +++ b/packages/db/admin/dogs/lookups/index.ts @@ -21,3 +21,4 @@ export { 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 index 0f947f30..7567f1a0 100644 --- a/packages/db/admin/dogs/lookups/list-color-options.ts +++ b/packages/db/admin/dogs/lookups/list-color-options.ts @@ -5,6 +5,7 @@ export type AdminDogColorLookupOptionDb = { nameFi: string; nameSv: string | null; nameEn: string | null; + status: "SELECTABLE" | "HIDDEN" | "LEGACY_UNKNOWN"; }; export type AdminDogColorLookupResponseDb = { @@ -18,6 +19,7 @@ export async function listAdminDogColorOptionsDb(): Promise()({ 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 fddeed8e..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: dog.color?.nameFi ?? dog.color?.nameSv ?? dog.color?.nameEn ?? 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 17611a3a..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 } }, @@ -156,9 +158,11 @@ export async function getDogProfileBaseRow(dogId: string) { }, 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 6a868307..67c129ff 100644 --- a/packages/db/imports/phase1/__tests__/source.test.ts +++ b/packages/db/imports/phase1/__tests__/source.test.ts @@ -21,7 +21,6 @@ describe("fetchLegacyPhase1Rows", () => { .fn() .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { registrationNo: "FI-1/20", ekNo: 123 }, { registrationNo: "FI-2/20", ekNo: null }, diff --git a/packages/db/imports/phase1/source.ts b/packages/db/imports/phase1/source.ts index 4b4339d0..fd53bf91 100644 --- a/packages/db/imports/phase1/source.ts +++ b/packages/db/imports/phase1/source.ts @@ -35,16 +35,6 @@ export async function fetchLegacyPhase1Rows(options?: { `Fetched dogs rows: count=${dogs.length}, elapsed=${Math.round((Date.now() - dogsStartedAt) / 1000)}s`, ); - const dogColorsStartedAt = Date.now(); - const dogColors = (await connection.query( - `SELECT COLCODE as code, - COLOR as name - FROM beacolor`, - )) as LegacyPhase1Rows["dogColors"]; - log( - `Fetched dog color rows: count=${dogColors.length}, elapsed=${Math.round((Date.now() - dogColorsStartedAt) / 1000)}s`, - ); - const breedersStartedAt = Date.now(); const breeders = (await connection.query( `SELECT KENNEL as name, @@ -102,7 +92,6 @@ export async function fetchLegacyPhase1Rows(options?: { return { dogs, - dogColors, breeders, eks, owners, diff --git a/packages/db/imports/types.ts b/packages/db/imports/types.ts index 704318e1..afa1bf0c 100644 --- a/packages/db/imports/types.ts +++ b/packages/db/imports/types.ts @@ -9,11 +9,6 @@ export type LegacyDogRow = { colorCode: number | string | null; }; -export type LegacyDogColorRow = { - code: number | string | null; - name: string | null; -}; - export type LegacyBreederRow = { name: string | null; shortCode: string | null; @@ -264,7 +259,6 @@ export type LegacySamakoiraRow = { export type LegacyPhase1Rows = { dogs: LegacyDogRow[]; - dogColors: LegacyDogColorRow[]; breeders: LegacyBreederRow[]; eks: LegacyEkRow[]; owners: LegacyOwnerRow[]; diff --git a/packages/db/index.ts b/packages/db/index.ts index 3a4018ec..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, @@ -215,6 +217,7 @@ export { linkUnlinkedShowTrialEntriesByRegistrationDb, listAdminBreederOptionsDb, listAdminDogColorOptionsDb, + findAdminDogColorOptionDb, listAdminOwnerOptionsDb, listAdminDogParentOptionsDb, runAdminUserWriteTransactionDb, 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/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 3ffeb54c..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 @@ -234,13 +240,14 @@ model Dog { } model DogColor { - code Int @id + code Int @id nameFi String nameSv String? nameEn String? + status DogColorStatus @default(HIDDEN) dogs Dog[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + 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/lookups/__tests__/list-color-options.test.ts b/packages/server/admin/dogs/lookups/__tests__/list-color-options.test.ts index ac0275df..671da786 100644 --- a/packages/server/admin/dogs/lookups/__tests__/list-color-options.test.ts +++ b/packages/server/admin/dogs/lookups/__tests__/list-color-options.test.ts @@ -46,8 +46,20 @@ describe("listAdminDogColorOptions", () => { it("returns color options on success", async () => { listAdminDogColorOptionsDbMock.mockResolvedValue({ items: [ - { code: 1, nameFi: "Musta", nameSv: "Svart", nameEn: "Black" }, - { code: 2, nameFi: "Valkoinen", nameSv: null, nameEn: "White" }, + { + code: 1, + nameFi: "Musta", + nameSv: "Svart", + nameEn: "Black", + status: "SELECTABLE", + }, + { + code: 2, + nameFi: "Valkoinen", + nameSv: null, + nameEn: "White", + status: "HIDDEN", + }, ], }); @@ -70,8 +82,20 @@ describe("listAdminDogColorOptions", () => { ok: true, data: { items: [ - { code: 1, nameFi: "Musta", nameSv: "Svart", nameEn: "Black" }, - { code: 2, nameFi: "Valkoinen", nameSv: null, nameEn: "White" }, + { + code: 1, + nameFi: "Musta", + nameSv: "Svart", + nameEn: "Black", + status: "SELECTABLE", + }, + { + code: 2, + nameFi: "Valkoinen", + nameSv: null, + nameEn: "White", + status: "HIDDEN", + }, ], }, }, 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 ac6c38dd..ae99433f 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, @@ -265,6 +269,30 @@ describe("createAdminDog", () => { ); }); + 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, code: "INVALID_COLOR_CODE" }, + }); + + 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__/update-dog.test.ts b/packages/server/admin/dogs/manage/__tests__/update-dog.test.ts index 9f4f3303..2c8651df 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,63 @@ 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("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, code: "INVALID_COLOR_CODE" }, + }); + + 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 48b6b608..cba4b0bd 100644 --- a/packages/server/admin/dogs/manage/create-dog.ts +++ b/packages/server/admin/dogs/manage/create-dog.ts @@ -1,6 +1,6 @@ import { createAdminDogWriteDb, - listAdminDogColorOptionsDb, + findAdminDogColorOptionDb, runAdminDogWriteTransactionDb, type AuditContextDb, } from "@beagle/db"; @@ -89,17 +89,23 @@ export async function createAdminDog( return parentValidation.response; } if (preflight.data.colorCode != null) { - const colorOptions = await listAdminDogColorOptionsDb(); - if ( - !colorOptions.items.some( - (item) => item.code === preflight.data.colorCode, - ) - ) { + const colorOption = await findAdminDogColorOptionDb( + preflight.data.colorCode, + ); + if (colorOption?.status !== "SELECTABLE") { + log.warn( + { + event: "color_not_selectable", + colorCode: preflight.data.colorCode, + durationMs: Date.now() - startedAt, + }, + "admin dog create rejected because color is not selectable", + ); return { status: 400, body: { ok: false, - error: "Color code was not found.", + error: "Color code is not selectable.", code: "INVALID_COLOR_CODE", }, }; diff --git a/packages/server/admin/dogs/manage/update-dog.ts b/packages/server/admin/dogs/manage/update-dog.ts index 157b55a1..1b00bd8f 100644 --- a/packages/server/admin/dogs/manage/update-dog.ts +++ b/packages/server/admin/dogs/manage/update-dog.ts @@ -1,6 +1,6 @@ import { findDogByIdDb, - listAdminDogColorOptionsDb, + findAdminDogColorOptionDb, runAdminDogWriteTransactionDb, updateAdminDogWriteDb, type AuditContextDb, @@ -128,17 +128,26 @@ export async function updateAdminDog( return parentGuardResult.response; } if (preflight.data.colorCode != null) { - const colorOptions = await listAdminDogColorOptionsDb(); - if ( - !colorOptions.items.some( - (item) => item.code === preflight.data.colorCode, - ) - ) { + const colorOption = await findAdminDogColorOptionDb( + preflight.data.colorCode, + ); + const preservesExistingColor = + existingDog.colorCode === preflight.data.colorCode; + if (colorOption?.status !== "SELECTABLE" && !preservesExistingColor) { + log.warn( + { + event: "color_not_selectable", + dogId: preflight.data.id, + colorCode: preflight.data.colorCode, + durationMs: Date.now() - startedAt, + }, + "admin dog update rejected because color is not selectable", + ); return { status: 400, body: { ok: false, - error: "Color code was not found.", + error: "Color code is not selectable.", code: "INVALID_COLOR_CODE", }, }; 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 43a17a17..e2879495 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, @@ -15,7 +16,6 @@ const { dogRegistrationUpdateMock, dogCreateMock, dogUpdateMock, - dogColorUpsertMock, ownerFindFirstMock, ownerCreateMock, dogOwnershipCreateManyMock, @@ -26,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(), @@ -33,7 +34,6 @@ const { dogRegistrationUpdateMock: vi.fn(), dogCreateMock: vi.fn(), dogUpdateMock: vi.fn(), - dogColorUpsertMock: vi.fn(), ownerFindFirstMock: vi.fn(), ownerCreateMock: vi.fn(), dogOwnershipCreateManyMock: vi.fn(), @@ -54,6 +54,7 @@ vi.mock("@beagle/db", () => ({ createImportRunIssue: createImportRunIssueMock, createImportRunIssuesBulk: createImportRunIssuesBulkMock, fetchLegacyPhase1Rows: fetchLegacyPhase1RowsMock, + seedDogColorsDb: seedDogColorsDbMock, prisma: { breeder: { findMany: breederFindManyMock }, dogRegistration: { @@ -68,9 +69,6 @@ vi.mock("@beagle/db", () => ({ update: dogUpdateMock, findUnique: vi.fn(), }, - dogColor: { - upsert: dogColorUpsertMock, - }, owner: { findFirst: ownerFindFirstMock, create: ownerCreateMock, @@ -90,6 +88,7 @@ describe("runLegacyPhase1", () => { createImportRunIssueMock.mockReset(); createImportRunIssuesBulkMock.mockReset(); fetchLegacyPhase1RowsMock.mockReset(); + seedDogColorsDbMock.mockReset(); breederFindManyMock.mockReset(); dogRegistrationFindUniqueMock.mockReset(); dogRegistrationFindManyMock.mockReset(); @@ -97,7 +96,6 @@ describe("runLegacyPhase1", () => { dogRegistrationUpdateMock.mockReset(); dogCreateMock.mockReset(); dogUpdateMock.mockReset(); - dogColorUpsertMock.mockReset(); ownerFindFirstMock.mockReset(); ownerCreateMock.mockReset(); dogOwnershipCreateManyMock.mockReset(); @@ -144,6 +142,7 @@ describe("runLegacyPhase1", () => { owners: [], samakoira: [], }); + seedDogColorsDbMock.mockResolvedValue({ codes: [121, 539, 886] }); breederFindManyMock.mockResolvedValue([]); dogRegistrationFindUniqueMock.mockImplementation(({ select }) => { @@ -198,6 +197,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({ dogColors: [], diff --git a/packages/server/imports/phase1/run-legacy-phase1.ts b/packages/server/imports/phase1/run-legacy-phase1.ts index 150b10d2..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"; @@ -302,50 +303,14 @@ export async function runLegacyPhase1( (row) => row.ekNo != null, ).length; log( - `Loaded legacy source rows: dogs=${legacy.dogs.length}, dogColors=${legacy.dogColors.length}, breeders=${legacy.breeders.length}, bea_apuRows=${beaApuRows}, bea_apuRowsWithEkNo=${beaApuRowsWithEkNo}, owners=${legacy.owners.length}, samakoira=${legacy.samakoira.length}`, + `Loaded legacy source rows: dogs=${legacy.dogs.length}, breeders=${legacy.breeders.length}, bea_apuRows=${beaApuRows}, bea_apuRowsWithEkNo=${beaApuRowsWithEkNo}, owners=${legacy.owners.length}, samakoira=${legacy.samakoira.length}`, ); finishStage("load"); startStage("dogColors"); - let dogColorsProcessed = 0; - let dogColorsUpserted = 0; - let dogColorsSkipped = 0; - const totalDogColors = legacy.dogColors.length; - const importedDogColorCodes = new Set(); - for (const row of legacy.dogColors) { - dogColorsProcessed += 1; - const code = parseLegacyColorCode(row.code); - const name = normalizeNullable(row.name); - - if (code === "INVALID" || code == null || !name) { - dogColorsSkipped += 1; - await recordIssue({ - stage: "dogColors", - code: "DOG_COLOR_INVALID_LOOKUP_ROW", - message: "Dog color lookup row has invalid code or missing name.", - sourceTable: "beacolor", - payloadJson: JSON.stringify(row), - }); - continue; - } - - await prisma.dogColor.upsert({ - where: { code }, - create: { - code, - nameFi: name, - }, - update: { - nameFi: name, - }, - }); - importedDogColorCodes.add(code); - dogColorsUpserted += 1; - } - finishStage( - "dogColors", - `upserted=${dogColorsUpserted}, skipped=${dogColorsSkipped}`, - ); + const seededDogColors = await seedDogColorsDb(); + const importedDogColorCodes = new Set(seededDogColors.codes); + finishStage("dogColors", `upserted=${seededDogColors.codes.length}`); startStage("breeders"); let breederRowsProcessed = 0; @@ -541,7 +506,7 @@ export async function runLegacyPhase1( stage: "dogs", code: "DOG_COLOR_LOOKUP_NOT_FOUND", message: - "Dog row references a color code missing from beacolor; color was treated as unknown.", + "Dog row references a color code missing from the canonical catalog; color was treated as unknown.", registrationNo, sourceTable: "bearek_id", payloadJson: JSON.stringify({ From ddc1b7ff428215939446d1cc9e292d66dc954379 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:18:48 +0300 Subject: [PATCH 4/5] fix(admin-dogs): move color options read to api route Replace the React Query Server Action path with a canonical admin API route, keep the server use case as the source of business logic, and remove the now- unused color lookup action. Files: - apps/web/app/actions/admin/dogs/lookups/__tests__/get-admin-dog-color-options.test.ts - apps/web/app/actions/admin/dogs/lookups/get-admin-dog-color-options.ts - apps/web/app/api/admin/dogs/lookups/colors/__tests__/route.test.ts - apps/web/app/api/admin/dogs/lookups/colors/route.ts - apps/web/queries/admin/dogs/lookups/__tests__/use-admin-dog-color-options-query.test.ts - apps/web/queries/admin/dogs/lookups/use-admin-dog-color-options-query.ts - packages/api-client/admin/dogs/__tests__/admin-dogs.test.ts - packages/api-client/admin/dogs/create-admin-dogs-api-client.ts - packages/api-client/admin/dogs/index.ts - packages/api-client/admin/dogs/list-admin-dog-color-options.ts ref bej-107 --- .../get-admin-dog-color-options.test.ts | 107 ------------- .../lookups/get-admin-dog-color-options.ts | 52 ------- .../lookups/colors/__tests__/route.test.ts | 146 ++++++++++++++++++ .../api/admin/dogs/lookups/colors/route.ts | 39 +++++ .../use-admin-dog-color-options-query.test.ts | 29 ++-- .../use-admin-dog-color-options-query.ts | 8 +- .../admin/dogs/__tests__/admin-dogs.test.ts | 37 +++++ .../dogs/create-admin-dogs-api-client.ts | 5 + packages/api-client/admin/dogs/index.ts | 1 + .../dogs/list-admin-dog-color-options.ts | 11 ++ 10 files changed, 262 insertions(+), 173 deletions(-) delete mode 100644 apps/web/app/actions/admin/dogs/lookups/__tests__/get-admin-dog-color-options.test.ts delete mode 100644 apps/web/app/actions/admin/dogs/lookups/get-admin-dog-color-options.ts create mode 100644 apps/web/app/api/admin/dogs/lookups/colors/__tests__/route.test.ts create mode 100644 apps/web/app/api/admin/dogs/lookups/colors/route.ts create mode 100644 packages/api-client/admin/dogs/__tests__/admin-dogs.test.ts create mode 100644 packages/api-client/admin/dogs/list-admin-dog-color-options.ts diff --git a/apps/web/app/actions/admin/dogs/lookups/__tests__/get-admin-dog-color-options.test.ts b/apps/web/app/actions/admin/dogs/lookups/__tests__/get-admin-dog-color-options.test.ts deleted file mode 100644 index cd0e838d..00000000 --- a/apps/web/app/actions/admin/dogs/lookups/__tests__/get-admin-dog-color-options.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getAdminDogColorOptionsAction } from "../get-admin-dog-color-options"; - -const { - requireAdminLayoutAccessMock, - getSessionCurrentUserMock, - listAdminDogColorOptionsMock, -} = vi.hoisted(() => ({ - requireAdminLayoutAccessMock: vi.fn(), - getSessionCurrentUserMock: vi.fn(), - listAdminDogColorOptionsMock: vi.fn(), -})); - -vi.mock("@/lib/server/admin-guard", () => ({ - requireAdminLayoutAccess: requireAdminLayoutAccessMock, -})); - -vi.mock("@/lib/server/current-user", () => ({ - getSessionCurrentUser: getSessionCurrentUserMock, -})); - -vi.mock("@beagle/server", () => ({ - listAdminDogColorOptions: listAdminDogColorOptionsMock, -})); - -describe("getAdminDogColorOptionsAction", () => { - beforeEach(() => { - requireAdminLayoutAccessMock.mockReset(); - getSessionCurrentUserMock.mockReset(); - listAdminDogColorOptionsMock.mockReset(); - }); - - it("returns unauthenticated when admin access is denied with 401", async () => { - requireAdminLayoutAccessMock.mockResolvedValue({ ok: false, status: 401 }); - - await expect(getAdminDogColorOptionsAction()).resolves.toEqual({ - data: null, - hasError: true, - errorCode: "UNAUTHENTICATED", - }); - }); - - it("returns unauthenticated when there is no current user", async () => { - requireAdminLayoutAccessMock.mockResolvedValue({ ok: true }); - getSessionCurrentUserMock.mockResolvedValue(null); - - await expect(getAdminDogColorOptionsAction()).resolves.toEqual({ - data: null, - hasError: true, - errorCode: "UNAUTHENTICATED", - }); - }); - - it("returns color options when service succeeds", async () => { - requireAdminLayoutAccessMock.mockResolvedValue({ ok: true }); - getSessionCurrentUserMock.mockResolvedValue({ - id: "u_1", - email: "admin@example.com", - name: "Admin", - role: "ADMIN", - createdAt: null, - sessionId: "s_1", - }); - listAdminDogColorOptionsMock.mockResolvedValue({ - status: 200, - body: { - ok: true, - data: { - items: [{ id: "dc_1", name: "Musta" }], - }, - }, - }); - - await expect(getAdminDogColorOptionsAction()).resolves.toEqual({ - data: { - items: [{ id: "dc_1", name: "Musta" }], - }, - hasError: false, - }); - }); - - it("returns service error code when lookup fails", async () => { - requireAdminLayoutAccessMock.mockResolvedValue({ ok: true }); - getSessionCurrentUserMock.mockResolvedValue({ - id: "u_1", - email: "admin@example.com", - name: "Admin", - role: "ADMIN", - createdAt: null, - sessionId: "s_1", - }); - listAdminDogColorOptionsMock.mockResolvedValue({ - status: 500, - body: { - ok: false, - error: "Failed to load color options.", - code: "INTERNAL_ERROR", - }, - }); - - await expect(getAdminDogColorOptionsAction()).resolves.toEqual({ - data: null, - hasError: true, - errorCode: "INTERNAL_ERROR", - }); - }); -}); diff --git a/apps/web/app/actions/admin/dogs/lookups/get-admin-dog-color-options.ts b/apps/web/app/actions/admin/dogs/lookups/get-admin-dog-color-options.ts deleted file mode 100644 index b281ceb6..00000000 --- a/apps/web/app/actions/admin/dogs/lookups/get-admin-dog-color-options.ts +++ /dev/null @@ -1,52 +0,0 @@ -"use server"; - -import type { AdminDogColorLookupResponse } from "@beagle/contracts"; -import { listAdminDogColorOptions } from "@beagle/server"; -import { requireAdminLayoutAccess } from "@/lib/server/admin-guard"; -import { getSessionCurrentUser } from "@/lib/server/current-user"; - -export type AdminDogColorOptionsActionResult = { - data: AdminDogColorLookupResponse | null; - hasError: boolean; - errorCode?: string; -}; - -export async function getAdminDogColorOptionsAction(): Promise { - const adminAccess = await requireAdminLayoutAccess(); - if (!adminAccess.ok) { - return { - data: null, - hasError: true, - errorCode: adminAccess.status === 401 ? "UNAUTHENTICATED" : "FORBIDDEN", - }; - } - - const currentUser = await getSessionCurrentUser(); - if (!currentUser) { - return { - data: null, - hasError: true, - errorCode: "UNAUTHENTICATED", - }; - } - - const result = await listAdminDogColorOptions({ - id: currentUser.id, - email: currentUser.email, - username: currentUser.name, - role: currentUser.role, - }); - - if (!result.body.ok) { - return { - data: null, - hasError: true, - errorCode: result.body.code, - }; - } - - return { - data: result.body.data, - hasError: false, - }; -} 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/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 index 15adaabd..8c55e3b0 100644 --- 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 @@ -2,23 +2,30 @@ 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, getAdminDogColorOptionsActionMock } = vi.hoisted(() => ({ +const { + useQueryMock, + createAdminDogsApiClientMock, + listAdminDogColorOptionsMock, +} = vi.hoisted(() => ({ useQueryMock: vi.fn(), - getAdminDogColorOptionsActionMock: vi.fn(), + createAdminDogsApiClientMock: vi.fn(() => ({ + listAdminDogColorOptions: listAdminDogColorOptionsMock, + })), + listAdminDogColorOptionsMock: vi.fn(), })); vi.mock("@tanstack/react-query", () => ({ useQuery: useQueryMock, })); -vi.mock("@/app/actions/admin/dogs/lookups/get-admin-dog-color-options", () => ({ - getAdminDogColorOptionsAction: getAdminDogColorOptionsActionMock, +vi.mock("@beagle/api-client", () => ({ + createAdminDogsApiClient: createAdminDogsApiClientMock, })); describe("useAdminDogColorOptionsQuery", () => { beforeEach(() => { useQueryMock.mockReset(); - getAdminDogColorOptionsActionMock.mockReset(); + listAdminDogColorOptionsMock.mockReset(); }); it("uses expected query key and options", () => { @@ -41,8 +48,8 @@ describe("useAdminDogColorOptionsQuery", () => { it("returns options when action succeeds", async () => { useQueryMock.mockImplementation((options) => options); - getAdminDogColorOptionsActionMock.mockResolvedValue({ - hasError: false, + listAdminDogColorOptionsMock.mockResolvedValue({ + ok: true, data: { items: [{ id: "dc_1", name: "Musta" }], }, @@ -60,10 +67,10 @@ describe("useAdminDogColorOptionsQuery", () => { it("throws when action returns error", async () => { useQueryMock.mockImplementation((options) => options); - getAdminDogColorOptionsActionMock.mockResolvedValue({ - hasError: true, - data: null, - errorCode: "INTERNAL_ERROR", + listAdminDogColorOptionsMock.mockResolvedValue({ + ok: false, + error: "Failed to load dog color options.", + code: "INTERNAL_ERROR", }); useAdminDogColorOptionsQuery(); 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 index e455ed32..29816a8a 100644 --- 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 @@ -1,16 +1,18 @@ "use client"; +import { createAdminDogsApiClient } from "@beagle/api-client"; import type { AdminDogColorLookupOption } from "@beagle/contracts"; import { useQuery } from "@tanstack/react-query"; -import { getAdminDogColorOptionsAction } from "@/app/actions/admin/dogs/lookups/get-admin-dog-color-options"; 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 getAdminDogColorOptionsAction(); - if (result.hasError || !result.data) { + const result = await adminDogsApiClient.listAdminDogColorOptions(); + if (!result.ok) { throw new Error("Failed to load dog color options."); } 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", + }, + ); +} From f15a72ea19a36bfa0dccd2e1838e599b8dfc5307 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:07:26 +0300 Subject: [PATCH 5/5] fix/dog color responses and cleanup tests ref bej-107 --- .../dogs/manage/__tests__/create-dog.test.ts | 59 +++++++++++- .../manage/__tests__/manage-responses.test.ts | 53 +++++++++++ .../dogs/manage/__tests__/update-dog.test.ts | 90 +++++++++++++++++- .../server/admin/dogs/manage/create-dog.ts | 21 ++--- .../__tests__/color-validation.test.ts | 82 +++++++++++++++++ .../dogs/manage/internal/color-validation.ts | 91 +++++++++++++++++++ .../dogs/manage/internal/manage-responses.ts | 41 ++++++++- .../server/admin/dogs/manage/update-dog.ts | 24 +++-- .../__tests__/run-legacy-phase1.test.ts | 6 -- 9 files changed, 434 insertions(+), 33 deletions(-) create mode 100644 packages/server/admin/dogs/manage/__tests__/manage-responses.test.ts create mode 100644 packages/server/admin/dogs/manage/internal/__tests__/color-validation.test.ts create mode 100644 packages/server/admin/dogs/manage/internal/color-validation.ts 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 ae99433f..88ec3313 100644 --- a/packages/server/admin/dogs/manage/__tests__/create-dog.test.ts +++ b/packages/server/admin/dogs/manage/__tests__/create-dog.test.ts @@ -269,6 +269,31 @@ 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({ @@ -287,7 +312,39 @@ describe("createAdminDog", () => { }), ).resolves.toMatchObject({ status: 400, - body: { ok: false, code: "INVALID_COLOR_CODE" }, + 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(); 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 2c8651df..e534bae7 100644 --- a/packages/server/admin/dogs/manage/__tests__/update-dog.test.ts +++ b/packages/server/admin/dogs/manage/__tests__/update-dog.test.ts @@ -89,6 +89,58 @@ describe("updateAdminDog", () => { ).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", @@ -111,7 +163,43 @@ describe("updateAdminDog", () => { }), ).resolves.toMatchObject({ status: 400, - body: { ok: false, code: "INVALID_COLOR_CODE" }, + 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(); diff --git a/packages/server/admin/dogs/manage/create-dog.ts b/packages/server/admin/dogs/manage/create-dog.ts index cba4b0bd..008772a3 100644 --- a/packages/server/admin/dogs/manage/create-dog.ts +++ b/packages/server/admin/dogs/manage/create-dog.ts @@ -24,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"; @@ -92,23 +93,21 @@ export async function createAdminDog( const colorOption = await findAdminDogColorOptionDb( preflight.data.colorCode, ); - if (colorOption?.status !== "SELECTABLE") { + const colorValidation = + validateAdminDogColorSelection( + preflight.data.colorCode, + colorOption, + ); + if (!colorValidation.ok) { log.warn( { - event: "color_not_selectable", + ...colorValidation.logContext, colorCode: preflight.data.colorCode, durationMs: Date.now() - startedAt, }, - "admin dog create rejected because color is not selectable", + colorValidation.logMessage, ); - return { - status: 400, - body: { - ok: false, - error: "Color code is not selectable.", - code: "INVALID_COLOR_CODE", - }, - }; + return colorValidation.response; } } 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/manage-responses.ts b/packages/server/admin/dogs/manage/internal/manage-responses.ts index d05b78e2..9a8e933d 100644 --- a/packages/server/admin/dogs/manage/internal/manage-responses.ts +++ b/packages/server/admin/dogs/manage/internal/manage-responses.ts @@ -109,12 +109,51 @@ export function invalidColorCodeResponse< status: 400, body: { ok: false, - error: "Color code was not found.", + 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/update-dog.ts b/packages/server/admin/dogs/manage/update-dog.ts index 1b00bd8f..07f80437 100644 --- a/packages/server/admin/dogs/manage/update-dog.ts +++ b/packages/server/admin/dogs/manage/update-dog.ts @@ -19,6 +19,7 @@ import { updateInternalErrorResponse, updateSuccessResponse, } from "./internal/manage-responses"; +import { validateAdminDogColorSelection } from "./internal/color-validation"; import { validateUpdateInTry, validateUpdatePreflight, @@ -131,26 +132,23 @@ export async function updateAdminDog( const colorOption = await findAdminDogColorOptionDb( preflight.data.colorCode, ); - const preservesExistingColor = - existingDog.colorCode === preflight.data.colorCode; - if (colorOption?.status !== "SELECTABLE" && !preservesExistingColor) { + const colorValidation = + validateAdminDogColorSelection( + preflight.data.colorCode, + colorOption, + existingDog.colorCode, + ); + if (!colorValidation.ok) { log.warn( { - event: "color_not_selectable", + ...colorValidation.logContext, dogId: preflight.data.id, colorCode: preflight.data.colorCode, durationMs: Date.now() - startedAt, }, - "admin dog update rejected because color is not selectable", + colorValidation.logMessage, ); - return { - status: 400, - body: { - ok: false, - error: "Color code is not selectable.", - code: "INVALID_COLOR_CODE", - }, - }; + return colorValidation.response; } } 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 e2879495..866f8c3e 100644 --- a/packages/server/imports/phase1/__tests__/run-legacy-phase1.test.ts +++ b/packages/server/imports/phase1/__tests__/run-legacy-phase1.test.ts @@ -122,7 +122,6 @@ describe("runLegacyPhase1", () => { }); fetchLegacyPhase1RowsMock.mockResolvedValue({ - dogColors: [], dogs: [ { registrationNo: "FI12345/21", @@ -229,7 +228,6 @@ describe("runLegacyPhase1", () => { it("explains that missing KNIMI prevented the dog from being imported and linked", async () => { fetchLegacyPhase1RowsMock.mockResolvedValue({ - dogColors: [], dogs: [ { registrationNo: "S87477", @@ -279,7 +277,6 @@ describe("runLegacyPhase1", () => { it("describes placeholder parent registrations as unknown and excluded from the new database", async () => { fetchLegacyPhase1RowsMock.mockResolvedValue({ - dogColors: [], dogs: [ { registrationNo: "FI12345/21", @@ -332,7 +329,6 @@ describe("runLegacyPhase1", () => { it("skips a null EK row without writing an EK value", async () => { fetchLegacyPhase1RowsMock.mockResolvedValue({ - dogColors: [], dogs: [ { registrationNo: "FI12345/21", @@ -371,7 +367,6 @@ describe("runLegacyPhase1", () => { it("does not record an issue for an empty REK_3 alias slot", async () => { fetchLegacyPhase1RowsMock.mockResolvedValue({ - dogColors: [], dogs: [ { registrationNo: "FI12345/21", @@ -429,7 +424,6 @@ describe("runLegacyPhase1", () => { it("records an empty REK_2 alias slot as a warning", async () => { fetchLegacyPhase1RowsMock.mockResolvedValue({ - dogColors: [], dogs: [ { registrationNo: "FI12345/21",