void handleDelete()}
+ onCancel={() => {
+ if (!deleteMutation.isPending) setIsOpen(false);
+ }}
+ />
+ >
+ );
+}
diff --git a/apps/web/components/admin/trials/admin-trial-event-edit-dialog-helpers.ts b/apps/web/components/admin/trials/admin-trial-event-edit-dialog-helpers.ts
index 718b1e21..d4284e87 100644
--- a/apps/web/components/admin/trials/admin-trial-event-edit-dialog-helpers.ts
+++ b/apps/web/components/admin/trials/admin-trial-event-edit-dialog-helpers.ts
@@ -1,4 +1,5 @@
import type { AdminTrialEventDetails } from "@beagle/contracts";
+export { parseSklKoeIdDraft } from "@/lib/admin/trials/submit-admin-trial-event-creation";
export type TrialEventDraft = {
eventDate: string;
@@ -12,6 +13,20 @@ export type TrialEventDraft = {
sklKoeId: string;
};
+export function createEmptyTrialEventDraft(): TrialEventDraft {
+ return {
+ eventDate: "",
+ eventPlace: "",
+ jarjestaja: "",
+ ylituomari: "",
+ ylituomariNumero: "",
+ ytKertomus: "",
+ kennelpiiri: "",
+ kennelpiirinro: "",
+ sklKoeId: "",
+ };
+}
+
export function toTrialEventDraft(
event: AdminTrialEventDetails,
): TrialEventDraft {
@@ -44,19 +59,3 @@ export function areTrialEventDraftsEqual(
(left.sklKoeId === null ? "" : String(left.sklKoeId)) === right.sklKoeId
);
}
-
-const VALID_SKL_KOE_ID_PATTERN = /^[1-9]\d*$/;
-
-export function parseSklKoeIdDraft(value: string): number | null {
- const trimmed = value.trim();
- if (!trimmed) {
- return null;
- }
-
- if (!VALID_SKL_KOE_ID_PATTERN.test(trimmed)) {
- return null;
- }
-
- const parsed = Number(trimmed);
- return Number.isSafeInteger(parsed) ? parsed : null;
-}
diff --git a/apps/web/components/admin/trials/admin-trial-event-edit-dialog.tsx b/apps/web/components/admin/trials/admin-trial-event-edit-dialog.tsx
index cb1e0f20..2d63bead 100644
--- a/apps/web/components/admin/trials/admin-trial-event-edit-dialog.tsx
+++ b/apps/web/components/admin/trials/admin-trial-event-edit-dialog.tsx
@@ -3,7 +3,6 @@
import React, { useMemo, useState } from "react";
import { AdminFormModalShell } from "@/components/admin";
import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
import { useI18n } from "@/hooks/i18n";
import type { AdminTrialEventDetails } from "@beagle/contracts";
import {
@@ -11,6 +10,7 @@ import {
parseSklKoeIdDraft,
toTrialEventDraft,
} from "./admin-trial-event-edit-dialog-helpers";
+import { AdminTrialEventFormFields } from "./admin-trial-event-form-fields";
export type UpdateAdminTrialEventPayload = {
trialEventId: string;
@@ -197,135 +197,12 @@ function AdminTrialEventEditDialogContent({
{errorText}
) : null}
-
-
-
-
-
-
-
-
-
-
-
+
);
}
diff --git a/apps/web/components/admin/trials/admin-trial-event-form-fields.tsx b/apps/web/components/admin/trials/admin-trial-event-form-fields.tsx
new file mode 100644
index 00000000..b20cbdaa
--- /dev/null
+++ b/apps/web/components/admin/trials/admin-trial-event-form-fields.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { Input } from "@/components/ui/input";
+import { useI18n } from "@/hooks/i18n";
+import type { TrialEventDraft } from "./admin-trial-event-edit-dialog-helpers";
+
+type AdminTrialEventFormFieldsProps = {
+ draft: TrialEventDraft;
+ disabled: boolean;
+ onChange: (draft: TrialEventDraft) => void;
+ showJudgeUpdateHelp?: boolean;
+ requireSklKoeId?: boolean;
+};
+
+export function AdminTrialEventFormFields({
+ draft,
+ disabled,
+ onChange,
+ showJudgeUpdateHelp = false,
+ requireSklKoeId = false,
+}: AdminTrialEventFormFieldsProps) {
+ const { t } = useI18n();
+
+ function setField(
+ field: Field,
+ value: TrialEventDraft[Field],
+ ) {
+ onChange({ ...draft, [field]: value });
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx b/apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx
index 793fdaff..e049c019 100644
--- a/apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx
+++ b/apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx
@@ -60,6 +60,8 @@ export function AdminTrialEventWorkspacePageClient({
isError={false}
errorText={t("admin.trials.manage.workspace.error")}
onDeletedTrialEvent={() => router.replace(getAdminTrialsHref())}
+ onTrialEventDeleteConflict={() => void eventQuery.refetch()}
+ allowEmptyEventDeletion
/>
)}
diff --git a/apps/web/components/admin/trials/admin-trial-selected-event-panel.tsx b/apps/web/components/admin/trials/admin-trial-selected-event-panel.tsx
index 5c6f3b8c..11dd63c0 100644
--- a/apps/web/components/admin/trials/admin-trial-selected-event-panel.tsx
+++ b/apps/web/components/admin/trials/admin-trial-selected-event-panel.tsx
@@ -27,6 +27,7 @@ import {
type UpdateAdminTrialEventPayload,
} from "./admin-trial-event-edit-dialog";
import { AdminTrialEntryEditDialog } from "./admin-trial-entry-edit-dialog";
+import { AdminTrialEventDeleteAction } from "./admin-trial-event-delete-action";
type AdminTrialSelectedEventPanelProps = {
selectedEvent: AdminTrialEventDetails | null;
@@ -34,6 +35,8 @@ type AdminTrialSelectedEventPanelProps = {
isError: boolean;
errorText: string;
onDeletedTrialEvent: (deletedTrialEventId: string) => void;
+ onTrialEventDeleteConflict?: () => void;
+ allowEmptyEventDeletion?: boolean;
workspaceHref?: string;
};
@@ -45,6 +48,8 @@ export function AdminTrialSelectedEventPanel({
isError,
errorText,
onDeletedTrialEvent,
+ onTrialEventDeleteConflict,
+ allowEmptyEventDeletion = false,
workspaceHref,
}: AdminTrialSelectedEventPanelProps) {
const { t } = useI18n();
@@ -186,6 +191,13 @@ export function AdminTrialSelectedEventPanel({
>
{t("admin.trials.manage.selected.actions.editEvent")}
+ {allowEmptyEventDeletion && selectedEntries.length === 0 ? (
+ onTrialEventDeleteConflict?.()}
+ />
+ ) : null}
diff --git a/apps/web/components/admin/trials/admin-trials-page-client.tsx b/apps/web/components/admin/trials/admin-trials-page-client.tsx
index 3bbf235d..1c6c8384 100644
--- a/apps/web/components/admin/trials/admin-trials-page-client.tsx
+++ b/apps/web/components/admin/trials/admin-trials-page-client.tsx
@@ -1,9 +1,14 @@
"use client";
import React, { useState } from "react";
+import Link from "next/link";
import type { AdminTrialEventSearchRequest } from "@beagle/contracts";
import { useI18n } from "@/hooks/i18n";
-import { getAdminTrialEventHref } from "@/lib/admin/trials";
+import { Button } from "@/components/ui/button";
+import {
+ getAdminTrialEventCreateHref,
+ getAdminTrialEventHref,
+} from "@/lib/admin/trials";
import { AdminTrialEventsFilters } from "./admin-trial-events-filters";
import { AdminTrialEventsResults } from "./admin-trial-events-results";
import { AdminTrialSelectedEventPanel } from "./admin-trial-selected-event-panel";
@@ -143,13 +148,20 @@ export function AdminTrialsPageClient() {
return (
-
-
- {t("admin.trials.title")}
-
-
- {t("admin.trials.description")}
-
+
+
+
+ {t("admin.trials.title")}
+
+
+ {t("admin.trials.description")}
+
+
+
{
it("returns the admin trials root", () => {
@@ -10,6 +14,10 @@ describe("admin trial route helpers", () => {
expect(getAdminTrialEventHref("event-1")).toBe("/admin/trials/event-1");
});
+ it("builds the event creation href", () => {
+ expect(getAdminTrialEventCreateHref()).toBe("/admin/trials/new");
+ });
+
it("encodes the event id as one path segment", () => {
expect(getAdminTrialEventHref("event/with spaces")).toBe(
"/admin/trials/event%2Fwith%20spaces",
diff --git a/apps/web/lib/admin/trials/index.ts b/apps/web/lib/admin/trials/index.ts
index 2f3dfb12..6eb021d1 100644
--- a/apps/web/lib/admin/trials/index.ts
+++ b/apps/web/lib/admin/trials/index.ts
@@ -1 +1,2 @@
export * from "./trial-route";
+export * from "./submit-admin-trial-event-creation";
diff --git a/apps/web/lib/admin/trials/submit-admin-trial-event-creation.ts b/apps/web/lib/admin/trials/submit-admin-trial-event-creation.ts
new file mode 100644
index 00000000..6e99cc7f
--- /dev/null
+++ b/apps/web/lib/admin/trials/submit-admin-trial-event-creation.ts
@@ -0,0 +1,106 @@
+import type {
+ CreateAdminTrialEventRequest,
+ CreateAdminTrialEventResponse,
+} from "@beagle/contracts";
+import { AdminMutationError } from "@/queries/admin/mutation-error";
+import { getAdminTrialEventHref } from "./trial-route";
+
+export type AdminTrialEventCreationDraft = {
+ eventDate: string;
+ eventPlace: string;
+ jarjestaja: string;
+ ylituomari: string;
+ ylituomariNumero: string;
+ ytKertomus: string;
+ kennelpiiri: string;
+ kennelpiirinro: string;
+ sklKoeId: string;
+};
+
+type CreationMessageKey =
+ | "admin.trials.manage.eventModal.validation.invalidDate"
+ | "admin.trials.manage.eventModal.validation.requiredPlace"
+ | "admin.trials.manage.eventModal.validation.invalidSklKoeId"
+ | "admin.trials.manage.create.conflict"
+ | "admin.trials.manage.create.error";
+
+type SubmitAdminTrialEventCreationOptions = {
+ draft: AdminTrialEventCreationDraft;
+ isPending: boolean;
+ mutateAsync: (
+ request: CreateAdminTrialEventRequest,
+ ) => Promise;
+ replace: (href: string) => void;
+ setErrorText: (errorText: string | null) => void;
+ t: (key: CreationMessageKey) => string;
+};
+
+const VALID_SKL_KOE_ID_PATTERN = /^[1-9]\d*$/;
+
+export function parseSklKoeIdDraft(value: string): number | null {
+ const normalized = value.trim();
+ if (!VALID_SKL_KOE_ID_PATTERN.test(normalized)) {
+ return null;
+ }
+
+ const parsed = Number(normalized);
+ return Number.isSafeInteger(parsed) ? parsed : null;
+}
+
+// Runs the create-form validation, mutation, error mapping, and continuation.
+export async function submitAdminTrialEventCreation({
+ draft,
+ isPending,
+ mutateAsync,
+ replace,
+ setErrorText,
+ t,
+}: SubmitAdminTrialEventCreationOptions): Promise {
+ if (isPending) {
+ return;
+ }
+
+ setErrorText(null);
+ const eventDate = draft.eventDate.trim();
+ if (!eventDate) {
+ setErrorText(t("admin.trials.manage.eventModal.validation.invalidDate"));
+ return;
+ }
+
+ const eventPlace = draft.eventPlace.trim();
+ if (!eventPlace) {
+ setErrorText(t("admin.trials.manage.eventModal.validation.requiredPlace"));
+ return;
+ }
+
+ const sklKoeId = parseSklKoeIdDraft(draft.sklKoeId);
+ if (sklKoeId === null) {
+ setErrorText(
+ t("admin.trials.manage.eventModal.validation.invalidSklKoeId"),
+ );
+ return;
+ }
+
+ try {
+ const result = await mutateAsync({
+ eventDate,
+ eventPlace,
+ jarjestaja: draft.jarjestaja.trim() || null,
+ ylituomari: draft.ylituomari.trim() || null,
+ ylituomariNumero: draft.ylituomariNumero.trim() || null,
+ ytKertomus: draft.ytKertomus.trim() || null,
+ kennelpiiri: draft.kennelpiiri.trim() || null,
+ kennelpiirinro: draft.kennelpiirinro.trim() || null,
+ sklKoeId,
+ });
+ replace(getAdminTrialEventHref(result.trialEventId));
+ } catch (error) {
+ setErrorText(
+ error instanceof AdminMutationError
+ ? error.errorCode === "SKL_KOE_ID_CONFLICT"
+ ? t("admin.trials.manage.create.conflict")
+ : error.message
+ : t("admin.trials.manage.create.error"),
+ );
+ }
+}
diff --git a/apps/web/lib/admin/trials/trial-route.ts b/apps/web/lib/admin/trials/trial-route.ts
index fc4f9324..03871766 100644
--- a/apps/web/lib/admin/trials/trial-route.ts
+++ b/apps/web/lib/admin/trials/trial-route.ts
@@ -4,6 +4,10 @@ export function getAdminTrialsHref(): string {
return ADMIN_TRIALS_ROUTE_ROOT;
}
+export function getAdminTrialEventCreateHref(): string {
+ return `${ADMIN_TRIALS_ROUTE_ROOT}/new`;
+}
+
export function getAdminTrialEventHref(trialEventId: string): string {
return `${ADMIN_TRIALS_ROUTE_ROOT}/${encodeURIComponent(trialEventId)}`;
}
diff --git a/apps/web/lib/i18n/messages/admin/trials/__tests__/index.test.ts b/apps/web/lib/i18n/messages/admin/trials/__tests__/index.test.ts
index 2861a72a..7b5d930f 100644
--- a/apps/web/lib/i18n/messages/admin/trials/__tests__/index.test.ts
+++ b/apps/web/lib/i18n/messages/admin/trials/__tests__/index.test.ts
@@ -23,6 +23,9 @@ describe("admin trial message index", () => {
"admin.trials.manage.selected.actions.openWorkspace"
],
).toBe("Avaa tapahtuman sivu");
+ expect(fiAdminTrialsMessages["admin.trials.manage.create.action"]).toBe(
+ "Luo tapahtuma",
+ );
expect(svAdminTrialsMessages["admin.trials.manage.events.title"]).toBe(
"Evenemang",
);
@@ -43,5 +46,8 @@ describe("admin trial message index", () => {
"admin.trials.manage.selected.actions.openWorkspace"
],
).toBe("Öppna evenemangssidan");
+ expect(svAdminTrialsMessages["admin.trials.manage.create.action"]).toBe(
+ "Skapa evenemang",
+ );
});
});
diff --git a/apps/web/lib/i18n/messages/admin/trials/manage.ts b/apps/web/lib/i18n/messages/admin/trials/manage.ts
index c07f2248..ba6f8a6d 100644
--- a/apps/web/lib/i18n/messages/admin/trials/manage.ts
+++ b/apps/web/lib/i18n/messages/admin/trials/manage.ts
@@ -45,6 +45,28 @@ export const fiAdminTrialsManageMessages = {
"admin.trials.manage.workspace.error":
"Ajokoetapahtuman lataaminen epäonnistui.",
"admin.trials.manage.workspace.retry": "Yritä uudelleen",
+ "admin.trials.manage.create.action": "Luo tapahtuma",
+ "admin.trials.manage.create.title": "Luo ajokoetapahtuma",
+ "admin.trials.manage.create.description":
+ "Tallenna tapahtuman perustiedot. Koirakohtaiset tulokset lisätään myöhemmin.",
+ "admin.trials.manage.create.save": "Luo tapahtuma",
+ "admin.trials.manage.create.saving": "Luodaan...",
+ "admin.trials.manage.create.cancel": "Peruuta",
+ "admin.trials.manage.create.error": "Tapahtuman luominen epäonnistui.",
+ "admin.trials.manage.create.conflict":
+ "Annettu SKL koe ID kuuluu jo toiselle tapahtumalle.",
+ "admin.trials.manage.deleteEvent.action": "Poista tapahtuma",
+ "admin.trials.manage.deleteEvent.confirmTitle": "Poistetaanko tapahtuma?",
+ "admin.trials.manage.deleteEvent.confirmBody":
+ "Tyhjä tapahtuma poistetaan pysyvästi. Tapahtumaa, jolla on tuloksia, ei voi poistaa.",
+ "admin.trials.manage.deleteEvent.confirm": "Poista tapahtuma",
+ "admin.trials.manage.deleteEvent.deleting": "Poistetaan...",
+ "admin.trials.manage.deleteEvent.cancel": "Peruuta",
+ "admin.trials.manage.deleteEvent.success": "Tapahtuma poistettiin.",
+ "admin.trials.manage.deleteEvent.error":
+ "Tapahtuman poistaminen epäonnistui.",
+ "admin.trials.manage.deleteEvent.notEmpty":
+ "Tapahtumaa ei voi poistaa, koska sillä on koetuloksia.",
"admin.trials.manage.selected.title": "Valittu tapahtuma",
"admin.trials.manage.selected.subtitle":
"Tapahtuman koirarivit ja rivikohtaiset toiminnot.",
@@ -79,7 +101,7 @@ export const fiAdminTrialsManageMessages = {
"admin.trials.manage.selected.actions.delete.confirmTitle":
"Poistetaanko tämä koetulos?",
"admin.trials.manage.selected.actions.delete.confirmBody":
- "Tämä poistaa vain tämän koirakohtaisen koetuloksen.\nJos tämä oli tapahtuman viimeinen tulos, myös tyhjä koetapahtuma poistetaan.",
+ "Tämä poistaa vain tämän koirakohtaisen koetuloksen. Jos tämä oli tapahtuman viimeinen tulos, tyhjä koetapahtuma säilyy.",
"admin.trials.manage.selected.actions.delete.confirmDog": "Koira",
"admin.trials.manage.selected.actions.delete.confirmRegistration":
"Rekisterinumero",
@@ -179,6 +201,28 @@ export const svAdminTrialsManageMessages = {
"admin.trials.manage.workspace.error":
"Det gick inte att ladda jaktprovsevenemanget.",
"admin.trials.manage.workspace.retry": "Försök igen",
+ "admin.trials.manage.create.action": "Skapa evenemang",
+ "admin.trials.manage.create.title": "Skapa jaktprovsevenemang",
+ "admin.trials.manage.create.description":
+ "Spara evenemangets grunduppgifter. Hundspecifika resultat läggs till senare.",
+ "admin.trials.manage.create.save": "Skapa evenemang",
+ "admin.trials.manage.create.saving": "Skapar...",
+ "admin.trials.manage.create.cancel": "Avbryt",
+ "admin.trials.manage.create.error": "Det gick inte att skapa evenemanget.",
+ "admin.trials.manage.create.conflict":
+ "Det angivna SKL prov-ID:t används redan av ett annat evenemang.",
+ "admin.trials.manage.deleteEvent.action": "Ta bort evenemang",
+ "admin.trials.manage.deleteEvent.confirmTitle": "Ta bort evenemanget?",
+ "admin.trials.manage.deleteEvent.confirmBody":
+ "Det tomma evenemanget tas bort permanent. Ett evenemang med resultat kan inte tas bort.",
+ "admin.trials.manage.deleteEvent.confirm": "Ta bort evenemang",
+ "admin.trials.manage.deleteEvent.deleting": "Tar bort...",
+ "admin.trials.manage.deleteEvent.cancel": "Avbryt",
+ "admin.trials.manage.deleteEvent.success": "Evenemanget togs bort.",
+ "admin.trials.manage.deleteEvent.error":
+ "Det gick inte att ta bort evenemanget.",
+ "admin.trials.manage.deleteEvent.notEmpty":
+ "Evenemanget kan inte tas bort eftersom det har provresultat.",
"admin.trials.manage.selected.title": "Valt evenemang",
"admin.trials.manage.selected.subtitle":
"Hundrader och radåtgärder för valt evenemang.",
@@ -212,7 +256,7 @@ export const svAdminTrialsManageMessages = {
"admin.trials.manage.selected.actions.delete.confirmTitle":
"Ta bort det här provresultatet?",
"admin.trials.manage.selected.actions.delete.confirmBody":
- "Det här tar bara bort det här hundspecifika provresultatet.\nOm detta var det sista resultatet i evenemanget tas även det tomma provevenemanget bort.",
+ "Det här tar bara bort det hundspecifika provresultatet. Om det var evenemangets sista resultat bevaras det tomma evenemanget.",
"admin.trials.manage.selected.actions.delete.confirmDog": "Hund",
"admin.trials.manage.selected.actions.delete.confirmRegistration":
"Registrering",
diff --git a/apps/web/queries/admin/trials/manage/__tests__/use-create-admin-trial-event-mutation.test.ts b/apps/web/queries/admin/trials/manage/__tests__/use-create-admin-trial-event-mutation.test.ts
new file mode 100644
index 00000000..2bcce2b1
--- /dev/null
+++ b/apps/web/queries/admin/trials/manage/__tests__/use-create-admin-trial-event-mutation.test.ts
@@ -0,0 +1,66 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ adminTrialEventQueryKeyRoot,
+ adminTrialEventsQueryKeyRoot,
+} from "../query-keys";
+import { useCreateAdminTrialEventMutation } from "../use-create-admin-trial-event-mutation";
+
+const { useMutationMock, useQueryClientMock, invalidateMock, actionMock } =
+ vi.hoisted(() => ({
+ useMutationMock: vi.fn(),
+ useQueryClientMock: vi.fn(),
+ invalidateMock: vi.fn(),
+ actionMock: vi.fn(),
+ }));
+
+vi.mock("@tanstack/react-query", () => ({
+ useMutation: useMutationMock,
+ useQueryClient: useQueryClientMock,
+}));
+vi.mock("@/app/actions/admin/trials/manage/create-admin-trial-event", () => ({
+ createAdminTrialEventAction: actionMock,
+}));
+
+describe("useCreateAdminTrialEventMutation", () => {
+ beforeEach(() => {
+ useMutationMock.mockReset();
+ useQueryClientMock.mockReturnValue({ invalidateQueries: invalidateMock });
+ invalidateMock.mockReset();
+ actionMock.mockReset();
+ useMutationMock.mockImplementation((options) => options);
+ });
+
+ it("returns action data and invalidates admin event queries", async () => {
+ actionMock.mockResolvedValue({
+ hasError: false,
+ data: { trialEventId: "event-1" },
+ });
+ useCreateAdminTrialEventMutation();
+ const options = useMutationMock.mock.calls[0]?.[0];
+
+ await expect(options.mutationFn({})).resolves.toEqual({
+ trialEventId: "event-1",
+ });
+ await options.onSuccess();
+ expect(invalidateMock).toHaveBeenCalledWith({
+ queryKey: adminTrialEventsQueryKeyRoot,
+ });
+ expect(invalidateMock).toHaveBeenCalledWith({
+ queryKey: adminTrialEventQueryKeyRoot,
+ });
+ });
+
+ it("rejects action errors", async () => {
+ actionMock.mockResolvedValue({
+ hasError: true,
+ data: null,
+ errorCode: "SKL_KOE_ID_CONFLICT",
+ message: "Conflict",
+ });
+ useCreateAdminTrialEventMutation();
+
+ await expect(
+ useMutationMock.mock.calls[0]?.[0].mutationFn({}),
+ ).rejects.toMatchObject({ errorCode: "SKL_KOE_ID_CONFLICT" });
+ });
+});
diff --git a/apps/web/queries/admin/trials/manage/__tests__/use-delete-admin-trial-event-mutation.test.ts b/apps/web/queries/admin/trials/manage/__tests__/use-delete-admin-trial-event-mutation.test.ts
new file mode 100644
index 00000000..0a7fd416
--- /dev/null
+++ b/apps/web/queries/admin/trials/manage/__tests__/use-delete-admin-trial-event-mutation.test.ts
@@ -0,0 +1,52 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ adminTrialEventQueryKeyRoot,
+ adminTrialEventsQueryKeyRoot,
+} from "../query-keys";
+import { useDeleteAdminTrialEventMutation } from "../use-delete-admin-trial-event-mutation";
+
+const { useMutationMock, useQueryClientMock, invalidateMock, actionMock } =
+ vi.hoisted(() => ({
+ useMutationMock: vi.fn(),
+ useQueryClientMock: vi.fn(),
+ invalidateMock: vi.fn(),
+ actionMock: vi.fn(),
+ }));
+
+vi.mock("@tanstack/react-query", () => ({
+ useMutation: useMutationMock,
+ useQueryClient: useQueryClientMock,
+}));
+vi.mock("@/app/actions/admin/trials/manage/delete-admin-trial-event", () => ({
+ deleteAdminTrialEventAction: actionMock,
+}));
+
+describe("useDeleteAdminTrialEventMutation", () => {
+ beforeEach(() => {
+ useMutationMock.mockReset();
+ useQueryClientMock.mockReturnValue({ invalidateQueries: invalidateMock });
+ invalidateMock.mockReset();
+ actionMock.mockReset();
+ useMutationMock.mockImplementation((options) => options);
+ });
+
+ it("returns action data and invalidates admin event queries", async () => {
+ actionMock.mockResolvedValue({
+ hasError: false,
+ data: { deletedTrialEventId: "event-1" },
+ });
+ useDeleteAdminTrialEventMutation();
+ const options = useMutationMock.mock.calls[0]?.[0];
+
+ await expect(
+ options.mutationFn({ trialEventId: "event-1" }),
+ ).resolves.toEqual({ deletedTrialEventId: "event-1" });
+ await options.onSuccess();
+ expect(invalidateMock).toHaveBeenCalledWith({
+ queryKey: adminTrialEventsQueryKeyRoot,
+ });
+ expect(invalidateMock).toHaveBeenCalledWith({
+ queryKey: adminTrialEventQueryKeyRoot,
+ });
+ });
+});
diff --git a/apps/web/queries/admin/trials/manage/index.ts b/apps/web/queries/admin/trials/manage/index.ts
index 5f677796..f798a697 100644
--- a/apps/web/queries/admin/trials/manage/index.ts
+++ b/apps/web/queries/admin/trials/manage/index.ts
@@ -1,4 +1,6 @@
export * from "./query-keys";
+export * from "./use-create-admin-trial-event-mutation";
+export * from "./use-delete-admin-trial-event-mutation";
export * from "./use-admin-trial-event-query";
export * from "./use-admin-trial-events-query";
export * from "./use-delete-admin-trial-entry-mutation";
diff --git a/apps/web/queries/admin/trials/manage/use-create-admin-trial-event-mutation.ts b/apps/web/queries/admin/trials/manage/use-create-admin-trial-event-mutation.ts
new file mode 100644
index 00000000..354befe1
--- /dev/null
+++ b/apps/web/queries/admin/trials/manage/use-create-admin-trial-event-mutation.ts
@@ -0,0 +1,42 @@
+"use client";
+
+import type {
+ CreateAdminTrialEventRequest,
+ CreateAdminTrialEventResponse,
+} from "@beagle/contracts";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { createAdminTrialEventAction } from "@/app/actions/admin/trials/manage/create-admin-trial-event";
+import { AdminMutationError } from "@/queries/admin/mutation-error";
+import {
+ adminTrialEventQueryKeyRoot,
+ adminTrialEventsQueryKeyRoot,
+} from "./query-keys";
+
+export function useCreateAdminTrialEventMutation() {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ CreateAdminTrialEventResponse,
+ AdminMutationError,
+ CreateAdminTrialEventRequest
+ >({
+ mutationFn: async (input) => {
+ const result = await createAdminTrialEventAction(input);
+ if (result.hasError || !result.data) {
+ throw new AdminMutationError(
+ result.message ?? "Failed to create trial event.",
+ result.errorCode,
+ );
+ }
+ return result.data;
+ },
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: adminTrialEventsQueryKeyRoot,
+ });
+ await queryClient.invalidateQueries({
+ queryKey: adminTrialEventQueryKeyRoot,
+ });
+ },
+ });
+}
diff --git a/apps/web/queries/admin/trials/manage/use-delete-admin-trial-event-mutation.ts b/apps/web/queries/admin/trials/manage/use-delete-admin-trial-event-mutation.ts
new file mode 100644
index 00000000..10c1e023
--- /dev/null
+++ b/apps/web/queries/admin/trials/manage/use-delete-admin-trial-event-mutation.ts
@@ -0,0 +1,42 @@
+"use client";
+
+import type {
+ DeleteAdminTrialEventRequest,
+ DeleteAdminTrialEventResponse,
+} from "@beagle/contracts";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { deleteAdminTrialEventAction } from "@/app/actions/admin/trials/manage/delete-admin-trial-event";
+import { AdminMutationError } from "@/queries/admin/mutation-error";
+import {
+ adminTrialEventQueryKeyRoot,
+ adminTrialEventsQueryKeyRoot,
+} from "./query-keys";
+
+export function useDeleteAdminTrialEventMutation() {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ DeleteAdminTrialEventResponse,
+ AdminMutationError,
+ DeleteAdminTrialEventRequest
+ >({
+ mutationFn: async (input) => {
+ const result = await deleteAdminTrialEventAction(input);
+ if (result.hasError || !result.data) {
+ throw new AdminMutationError(
+ result.message ?? "Failed to delete trial event.",
+ result.errorCode,
+ );
+ }
+ return result.data;
+ },
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: adminTrialEventsQueryKeyRoot,
+ });
+ await queryClient.invalidateQueries({
+ queryKey: adminTrialEventQueryKeyRoot,
+ });
+ },
+ });
+}
diff --git a/docs/features/admin-trial-management.md b/docs/features/admin-trial-management.md
index 17c90851..bb4f006d 100644
--- a/docs/features/admin-trial-management.md
+++ b/docs/features/admin-trial-management.md
@@ -7,16 +7,20 @@ and follow-up admin flow redesign).
- The admin list page is an event-first master-detail workflow for canonical
AJOK `TrialEvent` + `TrialEntry` rows.
-- An admin can search events, select one event, inspect and edit its metadata
- and dog rows, delete a result, or open the event at the stable
+- An admin can create and search events, select one event, inspect and edit its
+ metadata and dog rows, delete a result, or open the event at the stable
`/admin/trials/[trialEventId]` workspace URL.
+- Empty events remain available to administrators. They can be deleted
+ explicitly from their workspace, but only while they have no result rows.
- Per-dog inspection opens the generated trial PDF. Existing event and result
editing remains modal-based.
## Main files
- `apps/web/app/(admin)/admin/trials/page.tsx`: route entrypoint for event master-detail list
+- `apps/web/app/(admin)/admin/trials/new/page.tsx`: full-page event creation route
- `apps/web/app/(admin)/admin/trials/[trialEventId]/page.tsx`: stable event workspace route
+- `apps/web/components/admin/trials/admin-trial-event-create-page-client.tsx`: event creation form and continuation
- `apps/web/components/admin/trials/admin-trials-page-client.tsx`: event filters + event list + selected-event rows
- `apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx`: one-event workspace states and navigation
- `apps/web/components/admin/trials/admin-trial-selected-event-panel.tsx`: selected-event rows and row actions
@@ -37,8 +41,9 @@ and follow-up admin flow redesign).
2. Event selection fetches selected event rows through `useAdminTrialEventQuery`.
3. The selected-event panel links to the stable event workspace, which fetches
only the `trialEventId` from its route.
-4. Event and result changes use the existing admin mutations; the PDF action
+4. Event and result changes use admin Server Action mutations; the PDF action
opens `/api/trials/[trialEntryId]/pdf` in a new tab.
+5. Successful event creation opens the persisted empty event workspace.
## Contract rules
@@ -46,6 +51,10 @@ and follow-up admin flow redesign).
`page`, `filters`, `availableYears`, `items[]`).
- Event detail response: one event (`event`) with event header fields and
selected dog rows (`entries[]`).
+- Event creation requires a positive `sklKoeId`, an ISO date, and a non-empty
+ place. Duplicate SKL IDs return `SKL_KOE_ID_CONFLICT`.
+- Empty event deletion returns `TRIAL_EVENT_NOT_EMPTY` if result rows are
+ present and `TRIAL_EVENT_NOT_FOUND` if the event no longer exists.
- `TRIAL_EVENT_NOT_FOUND` is returned for missing event IDs.
- After BEJ-81 switch, event/list identifiers are:
- `sklKoeId` (preferred when available)
@@ -58,13 +67,17 @@ and follow-up admin flow redesign).
- Selected dog rows have PDF, edit, and result-delete actions.
- A missing workspace event is shown explicitly and never falls back to a
different event.
-- Until BEJ-103 Gate E2 changes the lifecycle, deleting the final result also
- deletes its event. The workspace then returns to `/admin/trials`; non-final
- and failed deletions remain on the workspace.
+- Deleting the final result leaves its event persisted and displays the valid
+ empty-event state in the workspace.
+- Explicit event deletion is offered only for an empty event and is enforced
+ by both the server and database write. Successful deletion returns to
+ `/admin/trials`; failed deletion remains on the workspace.
## Tests
+- `apps/web/app/(admin)/admin/trials/new/__tests__/page.test.tsx`
- `apps/web/app/(admin)/admin/trials/[trialEventId]/__tests__/page.test.tsx`
+- `apps/web/components/admin/trials/__tests__/admin-trial-event-create-page-client.test.tsx`
- `apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx`
- `apps/web/components/admin/trials/__tests__/admin-trials-page-client.test.tsx`
- `apps/web/components/admin/trials/__tests__/admin-trial-entry-actions.test.tsx`
diff --git a/packages/contracts/admin/trials/manage/create-admin-trial-event.ts b/packages/contracts/admin/trials/manage/create-admin-trial-event.ts
new file mode 100644
index 00000000..4d5d89a8
--- /dev/null
+++ b/packages/contracts/admin/trials/manage/create-admin-trial-event.ts
@@ -0,0 +1,15 @@
+export type CreateAdminTrialEventRequest = {
+ eventDate: string;
+ eventPlace: string;
+ jarjestaja: string | null;
+ ylituomari: string | null;
+ ylituomariNumero: string | null;
+ ytKertomus: string | null;
+ kennelpiiri: string | null;
+ kennelpiirinro: string | null;
+ sklKoeId: number;
+};
+
+export type CreateAdminTrialEventResponse = {
+ trialEventId: string;
+};
diff --git a/packages/contracts/admin/trials/manage/delete-admin-trial-event.ts b/packages/contracts/admin/trials/manage/delete-admin-trial-event.ts
new file mode 100644
index 00000000..58b5fe72
--- /dev/null
+++ b/packages/contracts/admin/trials/manage/delete-admin-trial-event.ts
@@ -0,0 +1,7 @@
+export type DeleteAdminTrialEventRequest = {
+ trialEventId: string;
+};
+
+export type DeleteAdminTrialEventResponse = {
+ deletedTrialEventId: string;
+};
diff --git a/packages/contracts/admin/trials/manage/index.ts b/packages/contracts/admin/trials/manage/index.ts
index a7125112..b335d5b6 100644
--- a/packages/contracts/admin/trials/manage/index.ts
+++ b/packages/contracts/admin/trials/manage/index.ts
@@ -12,6 +12,14 @@ export type {
AdminTrialEventDetailsResponse,
AdminTrialEventEntry,
} from "./admin-trial-event-details";
+export type {
+ CreateAdminTrialEventRequest,
+ CreateAdminTrialEventResponse,
+} from "./create-admin-trial-event";
+export type {
+ DeleteAdminTrialEventRequest,
+ DeleteAdminTrialEventResponse,
+} from "./delete-admin-trial-event";
export type {
DeleteAdminTrialEntryRequest,
DeleteAdminTrialEntryResponse,
diff --git a/packages/contracts/index.ts b/packages/contracts/index.ts
index 97382686..2c61a7ef 100644
--- a/packages/contracts/index.ts
+++ b/packages/contracts/index.ts
@@ -292,6 +292,10 @@ export type {
AdminTrialEventDetailsRequest,
AdminTrialEventDetailsResponse,
AdminTrialEventEntry,
+ CreateAdminTrialEventRequest,
+ CreateAdminTrialEventResponse,
+ DeleteAdminTrialEventRequest,
+ DeleteAdminTrialEventResponse,
DeleteAdminTrialEntryRequest,
DeleteAdminTrialEntryResponse,
UpdateAdminTrialEventRequest,
diff --git a/packages/db/admin/trials/manage/__tests__/create-trial-event.test.ts b/packages/db/admin/trials/manage/__tests__/create-trial-event.test.ts
new file mode 100644
index 00000000..909d075a
--- /dev/null
+++ b/packages/db/admin/trials/manage/__tests__/create-trial-event.test.ts
@@ -0,0 +1,68 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createAdminTrialEventWriteDb } from "../create-trial-event";
+
+const { trialEventCreateMock } = vi.hoisted(() => ({
+ trialEventCreateMock: vi.fn(),
+}));
+
+vi.mock("@db/core/prisma", () => ({
+ prisma: { trialEvent: { create: trialEventCreateMock } },
+}));
+
+describe("createAdminTrialEventWriteDb", () => {
+ beforeEach(() => {
+ trialEventCreateMock.mockReset();
+ });
+
+ it("creates one empty event with the resolved rule window", async () => {
+ trialEventCreateMock.mockResolvedValue({ id: "event-1" });
+ const eventDate = new Date("2026-07-21T00:00:00.000Z");
+
+ await expect(
+ createAdminTrialEventWriteDb({
+ eventDate,
+ eventPlace: "Helsinki",
+ jarjestaja: "Club",
+ ylituomari: null,
+ ylituomariNumero: null,
+ ytKertomus: null,
+ kennelpiiri: null,
+ kennelpiirinro: null,
+ sklKoeId: 123,
+ trialRuleWindowId: "window-1",
+ }),
+ ).resolves.toEqual({ trialEventId: "event-1" });
+
+ expect(trialEventCreateMock).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ koepaiva: eventDate,
+ koekunta: "Helsinki",
+ sklKoeId: 123,
+ trialRuleWindowId: "window-1",
+ }),
+ select: { id: true },
+ });
+ });
+
+ it("propagates duplicate SKL id errors for the service to map", async () => {
+ const duplicateError = Object.assign(new Error("duplicate SKL id"), {
+ code: "P2002",
+ });
+ trialEventCreateMock.mockRejectedValue(duplicateError);
+
+ await expect(
+ createAdminTrialEventWriteDb({
+ eventDate: new Date("2026-07-21T00:00:00.000Z"),
+ eventPlace: "Helsinki",
+ jarjestaja: null,
+ ylituomari: null,
+ ylituomariNumero: null,
+ ytKertomus: null,
+ kennelpiiri: null,
+ kennelpiirinro: null,
+ sklKoeId: 123,
+ trialRuleWindowId: null,
+ }),
+ ).rejects.toBe(duplicateError);
+ });
+});
diff --git a/packages/db/admin/trials/manage/__tests__/delete-trial-entry.test.ts b/packages/db/admin/trials/manage/__tests__/delete-trial-entry.test.ts
index 15faef64..39f9a969 100644
--- a/packages/db/admin/trials/manage/__tests__/delete-trial-entry.test.ts
+++ b/packages/db/admin/trials/manage/__tests__/delete-trial-entry.test.ts
@@ -2,31 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ADMIN_WRITE_TX_CONFIG } from "@db/core/interactive-write-transaction";
import { deleteAdminTrialEntryWriteDb } from "../delete-trial-entry";
-const {
- prismaTransactionMock,
- trialEntryDeleteManyMock,
- trialEntryCountMock,
- trialEventDeleteMock,
-} = vi.hoisted(() => {
+const { prismaTransactionMock, trialEntryDeleteManyMock } = vi.hoisted(() => {
const trialEntryDeleteMany = vi.fn();
- const trialEntryCount = vi.fn();
- const trialEventDelete = vi.fn();
const tx = {
trialEntry: {
deleteMany: trialEntryDeleteMany,
- count: trialEntryCount,
- },
- trialEvent: {
- delete: trialEventDelete,
},
};
return {
prismaTransactionMock: vi.fn(async (callback) => callback(tx)),
trialEntryDeleteManyMock: trialEntryDeleteMany,
- trialEntryCountMock: trialEntryCount,
- trialEventDeleteMock: trialEventDelete,
};
});
@@ -40,8 +27,6 @@ describe("deleteAdminTrialEntryWriteDb", () => {
beforeEach(() => {
prismaTransactionMock.mockClear();
trialEntryDeleteManyMock.mockReset();
- trialEntryCountMock.mockReset();
- trialEventDeleteMock.mockReset();
});
it("returns not_found when no matching row is deleted", async () => {
@@ -53,14 +38,10 @@ describe("deleteAdminTrialEntryWriteDb", () => {
trialEntryId: "entry-1",
}),
).resolves.toEqual({ status: "not_found" });
-
- expect(trialEntryCountMock).not.toHaveBeenCalled();
- expect(trialEventDeleteMock).not.toHaveBeenCalled();
});
- it("keeps parent trial event when sibling entries remain", async () => {
+ it("deletes the entry while preserving its parent event", async () => {
trialEntryDeleteManyMock.mockResolvedValue({ count: 1 });
- trialEntryCountMock.mockResolvedValue(2);
await expect(
deleteAdminTrialEntryWriteDb({
@@ -73,36 +54,10 @@ describe("deleteAdminTrialEntryWriteDb", () => {
trialEventId: "event-1",
deletedTrialEvent: false,
});
-
- expect(trialEventDeleteMock).not.toHaveBeenCalled();
- });
-
- it("deletes parent trial event when last entry is removed", async () => {
- trialEntryDeleteManyMock.mockResolvedValue({ count: 1 });
- trialEntryCountMock.mockResolvedValue(0);
-
- await expect(
- deleteAdminTrialEntryWriteDb({
- trialEventId: "event-1",
- trialEntryId: "entry-1",
- }),
- ).resolves.toEqual({
- status: "deleted",
- deletedTrialEntryId: "entry-1",
- trialEventId: "event-1",
- deletedTrialEvent: true,
- });
-
- expect(trialEventDeleteMock).toHaveBeenCalledWith({
- where: {
- id: "event-1",
- },
- });
});
it("passes explicit transaction timeout options", async () => {
trialEntryDeleteManyMock.mockResolvedValue({ count: 1 });
- trialEntryCountMock.mockResolvedValue(1);
await deleteAdminTrialEntryWriteDb({
trialEventId: "event-1",
diff --git a/packages/db/admin/trials/manage/__tests__/delete-trial-event.test.ts b/packages/db/admin/trials/manage/__tests__/delete-trial-event.test.ts
new file mode 100644
index 00000000..c790af46
--- /dev/null
+++ b/packages/db/admin/trials/manage/__tests__/delete-trial-event.test.ts
@@ -0,0 +1,65 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { ADMIN_WRITE_TX_CONFIG } from "@db/core/interactive-write-transaction";
+import { deleteAdminTrialEventWriteDb } from "../delete-trial-event";
+
+const { transactionMock, deleteManyMock, findUniqueMock } = vi.hoisted(() => {
+ const deleteManyMock = vi.fn();
+ const findUniqueMock = vi.fn();
+ const tx = {
+ trialEvent: { deleteMany: deleteManyMock, findUnique: findUniqueMock },
+ };
+ return {
+ transactionMock: vi.fn(async (callback) => callback(tx)),
+ deleteManyMock,
+ findUniqueMock,
+ };
+});
+
+vi.mock("@db/core/prisma", () => ({
+ prisma: { $transaction: transactionMock },
+}));
+
+describe("deleteAdminTrialEventWriteDb", () => {
+ beforeEach(() => {
+ transactionMock.mockClear();
+ deleteManyMock.mockReset();
+ findUniqueMock.mockReset();
+ });
+
+ it("deletes only an event with no entries", async () => {
+ deleteManyMock.mockResolvedValue({ count: 1 });
+
+ await expect(
+ deleteAdminTrialEventWriteDb({ trialEventId: "event-1" }),
+ ).resolves.toEqual({
+ status: "deleted",
+ deletedTrialEventId: "event-1",
+ });
+ expect(deleteManyMock).toHaveBeenCalledWith({
+ where: { id: "event-1", entries: { none: {} } },
+ });
+ expect(findUniqueMock).not.toHaveBeenCalled();
+ expect(transactionMock).toHaveBeenCalledWith(
+ expect.any(Function),
+ ADMIN_WRITE_TX_CONFIG,
+ );
+ });
+
+ it("rejects an existing non-empty event", async () => {
+ deleteManyMock.mockResolvedValue({ count: 0 });
+ findUniqueMock.mockResolvedValue({ id: "event-1" });
+
+ await expect(
+ deleteAdminTrialEventWriteDb({ trialEventId: "event-1" }),
+ ).resolves.toEqual({ status: "not_empty" });
+ });
+
+ it("reports a missing event", async () => {
+ deleteManyMock.mockResolvedValue({ count: 0 });
+ findUniqueMock.mockResolvedValue(null);
+
+ await expect(
+ deleteAdminTrialEventWriteDb({ trialEventId: "missing" }),
+ ).resolves.toEqual({ status: "not_found" });
+ });
+});
diff --git a/packages/db/admin/trials/manage/create-trial-event.ts b/packages/db/admin/trials/manage/create-trial-event.ts
new file mode 100644
index 00000000..8fbdd4d7
--- /dev/null
+++ b/packages/db/admin/trials/manage/create-trial-event.ts
@@ -0,0 +1,43 @@
+import { prisma } from "@db/core/prisma";
+
+export type CreateAdminTrialEventWriteRequestDb = {
+ eventDate: Date;
+ eventPlace: string;
+ jarjestaja: string | null;
+ ylituomari: string | null;
+ ylituomariNumero: string | null;
+ ytKertomus: string | null;
+ kennelpiiri: string | null;
+ kennelpiirinro: string | null;
+ sklKoeId: number;
+ trialRuleWindowId: string | null;
+};
+
+export type CreateAdminTrialEventWriteResultDb = {
+ trialEventId: string;
+};
+
+// Creates one canonical event without child result rows.
+export async function createAdminTrialEventWriteDb(
+ input: CreateAdminTrialEventWriteRequestDb,
+): Promise {
+ const created = await prisma.trialEvent.create({
+ data: {
+ koepaiva: input.eventDate,
+ koekunta: input.eventPlace,
+ jarjestaja: input.jarjestaja,
+ ylituomariNimi: input.ylituomari,
+ ylituomariNumero: input.ylituomariNumero,
+ ytKertomus: input.ytKertomus,
+ kennelpiiri: input.kennelpiiri,
+ kennelpiirinro: input.kennelpiirinro,
+ sklKoeId: input.sklKoeId,
+ trialRuleWindowId: input.trialRuleWindowId,
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ return { trialEventId: created.id };
+}
diff --git a/packages/db/admin/trials/manage/delete-trial-entry.ts b/packages/db/admin/trials/manage/delete-trial-entry.ts
index 57325543..de18733d 100644
--- a/packages/db/admin/trials/manage/delete-trial-entry.ts
+++ b/packages/db/admin/trials/manage/delete-trial-entry.ts
@@ -15,8 +15,8 @@ export type DeleteAdminTrialEntryWriteResultDb =
deletedTrialEvent: boolean;
};
-// modification: deleting TrialEntry cascades to TrialEra and TrialEraLisatieto.
-// If this was the last entry, remove the now-empty TrialEvent as cleanup.
+// Deleting TrialEntry cascades to TrialEra and TrialEraLisatieto.
+// The parent event intentionally remains persisted when the final entry is removed.
export async function deleteAdminTrialEntryWriteDb(
input: DeleteAdminTrialEntryWriteRequestDb,
): Promise {
@@ -32,27 +32,11 @@ export async function deleteAdminTrialEntryWriteDb(
return { status: "not_found" };
}
- const remainingEntries = await tx.trialEntry.count({
- where: {
- trialEventId: input.trialEventId,
- },
- });
-
- let deletedTrialEvent = false;
- if (remainingEntries === 0) {
- await tx.trialEvent.delete({
- where: {
- id: input.trialEventId,
- },
- });
- deletedTrialEvent = true;
- }
-
return {
status: "deleted",
deletedTrialEntryId: input.trialEntryId,
trialEventId: input.trialEventId,
- deletedTrialEvent,
+ deletedTrialEvent: false,
};
}, ADMIN_WRITE_TX_CONFIG);
}
diff --git a/packages/db/admin/trials/manage/delete-trial-event.ts b/packages/db/admin/trials/manage/delete-trial-event.ts
new file mode 100644
index 00000000..2598b5b2
--- /dev/null
+++ b/packages/db/admin/trials/manage/delete-trial-event.ts
@@ -0,0 +1,39 @@
+import { ADMIN_WRITE_TX_CONFIG } from "@db/core/interactive-write-transaction";
+import { prisma } from "@db/core/prisma";
+
+export type DeleteAdminTrialEventWriteRequestDb = {
+ trialEventId: string;
+};
+
+export type DeleteAdminTrialEventWriteResultDb =
+ | { status: "not_found" }
+ | { status: "not_empty" }
+ | { status: "deleted"; deletedTrialEventId: string };
+
+// Enforces the empty-only event deletion rule in the database write itself.
+export async function deleteAdminTrialEventWriteDb(
+ input: DeleteAdminTrialEventWriteRequestDb,
+): Promise {
+ return prisma.$transaction(async (tx) => {
+ const deleted = await tx.trialEvent.deleteMany({
+ where: {
+ id: input.trialEventId,
+ entries: { none: {} },
+ },
+ });
+
+ if (deleted.count > 0) {
+ return {
+ status: "deleted",
+ deletedTrialEventId: input.trialEventId,
+ };
+ }
+
+ const existing = await tx.trialEvent.findUnique({
+ where: { id: input.trialEventId },
+ select: { id: true },
+ });
+
+ return existing ? { status: "not_empty" } : { status: "not_found" };
+ }, ADMIN_WRITE_TX_CONFIG);
+}
diff --git a/packages/db/admin/trials/manage/index.ts b/packages/db/admin/trials/manage/index.ts
index 96012a3f..0cfddbea 100644
--- a/packages/db/admin/trials/manage/index.ts
+++ b/packages/db/admin/trials/manage/index.ts
@@ -1,5 +1,15 @@
export { getAdminTrialEventDetailsDb } from "./get-trial-event-details";
export { searchAdminTrialsDb } from "./search-trials";
+export {
+ createAdminTrialEventWriteDb,
+ type CreateAdminTrialEventWriteRequestDb,
+ type CreateAdminTrialEventWriteResultDb,
+} from "./create-trial-event";
+export {
+ deleteAdminTrialEventWriteDb,
+ type DeleteAdminTrialEventWriteRequestDb,
+ type DeleteAdminTrialEventWriteResultDb,
+} from "./delete-trial-event";
export {
deleteAdminTrialEntryWriteDb,
type DeleteAdminTrialEntryWriteRequestDb,
diff --git a/packages/db/index.ts b/packages/db/index.ts
index 37c0b180..779e44b0 100644
--- a/packages/db/index.ts
+++ b/packages/db/index.ts
@@ -153,6 +153,7 @@ export {
getBeagleTrialDetailsDb,
getBeagleTrialsForDogDb,
getBeagleTrialSummarySourceForDogDb,
+ listActiveTrialRuleWindowsDb,
searchBeagleTrialsDb,
upsertKoiratietokantaAjokResultDb,
type BeagleTrialDetailsRequestDb,
@@ -166,6 +167,7 @@ export {
type BeagleTrialSearchResponseDb,
type BeagleTrialSearchRowDb,
type BeagleTrialSearchSortDb,
+ type ActiveTrialRuleWindowDb,
type KoiratietokantaAjokEntryDbInput,
type KoiratietokantaAjokEventDbInput,
type KoiratietokantaAjokEraDbInput,
@@ -177,6 +179,8 @@ export {
export {
getAdminTrialEventDetailsDb,
searchAdminTrialsDb,
+ createAdminTrialEventWriteDb,
+ deleteAdminTrialEventWriteDb,
deleteAdminTrialEntryWriteDb,
updateAdminTrialEventWriteDb,
updateAdminTrialEntryWriteDb,
@@ -189,6 +193,10 @@ export {
type AdminTrialEventSearchResponseDb,
type AdminTrialEventSearchSortDb,
type AdminTrialEventSummaryDb,
+ type CreateAdminTrialEventWriteRequestDb,
+ type CreateAdminTrialEventWriteResultDb,
+ type DeleteAdminTrialEventWriteRequestDb,
+ type DeleteAdminTrialEventWriteResultDb,
type DeleteAdminTrialEntryWriteRequestDb,
type DeleteAdminTrialEntryWriteResultDb,
type UpdateAdminTrialEventWriteRequestDb,
diff --git a/packages/db/trials/__tests__/list-active-trial-rule-windows.test.ts b/packages/db/trials/__tests__/list-active-trial-rule-windows.test.ts
new file mode 100644
index 00000000..0d83109e
--- /dev/null
+++ b/packages/db/trials/__tests__/list-active-trial-rule-windows.test.ts
@@ -0,0 +1,32 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { listActiveTrialRuleWindowsDb } from "../list-active-trial-rule-windows";
+
+const { ruleWindowFindManyMock } = vi.hoisted(() => ({
+ ruleWindowFindManyMock: vi.fn(),
+}));
+
+vi.mock("@db/core/prisma", () => ({
+ prisma: { trialRuleWindow: { findMany: ruleWindowFindManyMock } },
+}));
+
+describe("listActiveTrialRuleWindowsDb", () => {
+ beforeEach(() => {
+ ruleWindowFindManyMock.mockReset();
+ });
+
+ it("loads the minimal active rule-window shape in resolution order", async () => {
+ const rows = [{ id: "window-1", fromYmd: 20200101, toYmd: null }];
+ ruleWindowFindManyMock.mockResolvedValue(rows);
+
+ await expect(listActiveTrialRuleWindowsDb()).resolves.toBe(rows);
+ expect(ruleWindowFindManyMock).toHaveBeenCalledWith({
+ where: { isActive: true },
+ orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
+ select: {
+ id: true,
+ fromYmd: true,
+ toYmd: true,
+ },
+ });
+ });
+});
diff --git a/packages/db/trials/index.ts b/packages/db/trials/index.ts
index c1d8a519..0348bb80 100644
--- a/packages/db/trials/index.ts
+++ b/packages/db/trials/index.ts
@@ -2,6 +2,10 @@ export { getBeagleTrialDetailsDb } from "./get-beagle-trial-details";
export { getBeagleTrialsForDogDb } from "./get-beagle-trials-for-dog";
export { getBeagleTrialSummarySourceForDogDb } from "./get-beagle-trials-summary-for-dog";
export { searchBeagleTrialsDb } from "./search-beagle-trials";
+export {
+ listActiveTrialRuleWindowsDb,
+ type ActiveTrialRuleWindowDb,
+} from "./list-active-trial-rule-windows";
export type {
BeagleTrialDetailsRequestDb,
BeagleTrialDetailsResponseDb,
diff --git a/packages/db/trials/list-active-trial-rule-windows.ts b/packages/db/trials/list-active-trial-rule-windows.ts
new file mode 100644
index 00000000..e01ecf0a
--- /dev/null
+++ b/packages/db/trials/list-active-trial-rule-windows.ts
@@ -0,0 +1,22 @@
+import { prisma } from "@db/core/prisma";
+
+export type ActiveTrialRuleWindowDb = {
+ id: string;
+ fromYmd: number | null;
+ toYmd: number | null;
+};
+
+// Loads active rule windows in deterministic rule-resolution order.
+export async function listActiveTrialRuleWindowsDb(): Promise<
+ ActiveTrialRuleWindowDb[]
+> {
+ return prisma.trialRuleWindow.findMany({
+ where: { isActive: true },
+ orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
+ select: {
+ id: true,
+ fromYmd: true,
+ toYmd: true,
+ },
+ });
+}
diff --git a/packages/server/admin/index.ts b/packages/server/admin/index.ts
index ec963078..92a08cf3 100644
--- a/packages/server/admin/index.ts
+++ b/packages/server/admin/index.ts
@@ -21,6 +21,8 @@ export { createAdminDogDisease } from "./dogs";
export { deleteAdminDogDisease } from "./dogs";
export { listAdminTrialEvents } from "./trials";
export { getAdminTrialEvent } from "./trials";
+export { createAdminTrialEvent } from "./trials";
+export { deleteAdminTrialEvent } from "./trials";
export { deleteAdminTrialEntry } from "./trials";
export { updateAdminTrialEvent } from "./trials";
export { updateAdminTrialEntry } from "./trials";
diff --git a/packages/server/admin/trials/manage/__tests__/create-trial-event.integration.test.ts b/packages/server/admin/trials/manage/__tests__/create-trial-event.integration.test.ts
new file mode 100644
index 00000000..a65d6e50
--- /dev/null
+++ b/packages/server/admin/trials/manage/__tests__/create-trial-event.integration.test.ts
@@ -0,0 +1,92 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createAdminTrialEvent } from "../create-trial-event";
+
+const { ruleWindowFindManyMock, trialEventCreateMock } = vi.hoisted(() => ({
+ ruleWindowFindManyMock: vi.fn(),
+ trialEventCreateMock: vi.fn(),
+}));
+
+vi.mock("@db/core/prisma", () => ({
+ prisma: {
+ trialRuleWindow: { findMany: ruleWindowFindManyMock },
+ trialEvent: { create: trialEventCreateMock },
+ },
+}));
+
+const admin = {
+ id: "u_1",
+ email: "admin@example.com",
+ username: null,
+ role: "ADMIN" as const,
+};
+
+const validInput = {
+ eventDate: "2026-07-21",
+ eventPlace: " Helsinki ",
+ jarjestaja: " Club ",
+ ylituomari: null,
+ ylituomariNumero: null,
+ ytKertomus: null,
+ kennelpiiri: null,
+ kennelpiirinro: null,
+ sklKoeId: 123,
+};
+
+describe("createAdminTrialEvent with the DB repository", () => {
+ beforeEach(() => {
+ ruleWindowFindManyMock.mockReset();
+ trialEventCreateMock.mockReset();
+ ruleWindowFindManyMock.mockResolvedValue([
+ { id: "window-old", fromYmd: 20200101, toYmd: 20251231 },
+ { id: "window-current", fromYmd: 20260101, toYmd: null },
+ ]);
+ trialEventCreateMock.mockResolvedValue({ id: "event-1" });
+ });
+
+ it("resolves the rule window and persists one empty event", async () => {
+ await expect(createAdminTrialEvent(validInput, admin)).resolves.toEqual({
+ status: 201,
+ body: { ok: true, data: { trialEventId: "event-1" } },
+ });
+
+ expect(ruleWindowFindManyMock).toHaveBeenCalledWith({
+ where: { isActive: true },
+ orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
+ select: {
+ id: true,
+ fromYmd: true,
+ toYmd: true,
+ },
+ });
+ expect(trialEventCreateMock).toHaveBeenCalledWith({
+ data: {
+ koepaiva: new Date("2026-07-21T00:00:00.000Z"),
+ koekunta: "Helsinki",
+ jarjestaja: "Club",
+ ylituomariNimi: null,
+ ylituomariNumero: null,
+ ytKertomus: null,
+ kennelpiiri: null,
+ kennelpiirinro: null,
+ sklKoeId: 123,
+ trialRuleWindowId: "window-current",
+ },
+ select: { id: true },
+ });
+ });
+
+ it("maps a repository duplicate error to the stable conflict", async () => {
+ trialEventCreateMock.mockRejectedValue(
+ Object.assign(new Error("duplicate SKL id"), { code: "P2002" }),
+ );
+
+ await expect(createAdminTrialEvent(validInput, admin)).resolves.toEqual({
+ status: 409,
+ body: {
+ ok: false,
+ error: "An event already uses this SKL koe id.",
+ code: "SKL_KOE_ID_CONFLICT",
+ },
+ });
+ });
+});
diff --git a/packages/server/admin/trials/manage/__tests__/create-trial-event.test.ts b/packages/server/admin/trials/manage/__tests__/create-trial-event.test.ts
new file mode 100644
index 00000000..b9337aff
--- /dev/null
+++ b/packages/server/admin/trials/manage/__tests__/create-trial-event.test.ts
@@ -0,0 +1,109 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createAdminTrialEvent } from "../create-trial-event";
+
+const { createWriteMock, listActiveRuleWindowsMock } = vi.hoisted(() => ({
+ createWriteMock: vi.fn(),
+ listActiveRuleWindowsMock: vi.fn(),
+}));
+
+vi.mock("@beagle/db", () => ({
+ createAdminTrialEventWriteDb: createWriteMock,
+ listActiveTrialRuleWindowsDb: listActiveRuleWindowsMock,
+}));
+
+const admin = {
+ id: "u_1",
+ email: "admin@example.com",
+ username: null,
+ role: "ADMIN" as const,
+};
+
+const validInput = {
+ eventDate: "2026-07-21",
+ eventPlace: " Helsinki ",
+ jarjestaja: " Club ",
+ ylituomari: null,
+ ylituomariNumero: null,
+ ytKertomus: null,
+ kennelpiiri: null,
+ kennelpiirinro: null,
+ sklKoeId: 123,
+};
+
+describe("createAdminTrialEvent", () => {
+ beforeEach(() => {
+ createWriteMock.mockReset();
+ listActiveRuleWindowsMock.mockReset();
+ listActiveRuleWindowsMock.mockResolvedValue([
+ { id: "window-1", fromYmd: 20200101, toYmd: null },
+ ]);
+ createWriteMock.mockResolvedValue({ trialEventId: "event-1" });
+ });
+
+ it("requires admin authorization", async () => {
+ const result = await createAdminTrialEvent(validInput, {
+ ...admin,
+ role: "USER",
+ });
+
+ expect(result.status).toBe(403);
+ expect(createWriteMock).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["event date", { eventDate: "2026-02-30" }, "INVALID_EVENT_DATE"],
+ ["event place", { eventPlace: " " }, "INVALID_EVENT_PLACE"],
+ ["SKL id", { sklKoeId: 0 }, "INVALID_SKL_KOE_ID"],
+ ])("rejects an invalid %s", async (_label, override, code) => {
+ const result = await createAdminTrialEvent(
+ { ...validInput, ...override },
+ admin,
+ );
+
+ expect(result.status).toBe(400);
+ expect(result.body).toMatchObject({ ok: false, code });
+ expect(createWriteMock).not.toHaveBeenCalled();
+ });
+
+ it("normalizes metadata and assigns the active rule window", async () => {
+ const result = await createAdminTrialEvent(validInput, admin);
+
+ expect(result).toEqual({
+ status: 201,
+ body: { ok: true, data: { trialEventId: "event-1" } },
+ });
+ expect(listActiveRuleWindowsMock).toHaveBeenCalledOnce();
+ expect(createWriteMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventDate: new Date("2026-07-21T00:00:00.000Z"),
+ eventPlace: "Helsinki",
+ jarjestaja: "Club",
+ sklKoeId: 123,
+ trialRuleWindowId: "window-1",
+ }),
+ );
+ });
+
+ it("allows no matching active rule window", async () => {
+ listActiveRuleWindowsMock.mockResolvedValue([]);
+
+ await createAdminTrialEvent(validInput, admin);
+
+ expect(createWriteMock).toHaveBeenCalledWith(
+ expect.objectContaining({ trialRuleWindowId: null }),
+ );
+ });
+
+ it("maps duplicate SKL ids to a stable conflict", async () => {
+ createWriteMock.mockRejectedValue({ code: "P2002" });
+
+ await expect(createAdminTrialEvent(validInput, admin)).resolves.toEqual({
+ status: 409,
+ body: {
+ ok: false,
+ error: "An event already uses this SKL koe id.",
+ code: "SKL_KOE_ID_CONFLICT",
+ },
+ });
+ });
+});
diff --git a/packages/server/admin/trials/manage/__tests__/delete-trial-entry.test.ts b/packages/server/admin/trials/manage/__tests__/delete-trial-entry.test.ts
index b77290d7..e8ab765d 100644
--- a/packages/server/admin/trials/manage/__tests__/delete-trial-entry.test.ts
+++ b/packages/server/admin/trials/manage/__tests__/delete-trial-entry.test.ts
@@ -117,7 +117,7 @@ describe("deleteAdminTrialEntry", () => {
status: "deleted",
deletedTrialEntryId: "entry-1",
trialEventId: "event-1",
- deletedTrialEvent: true,
+ deletedTrialEvent: false,
});
await expect(
@@ -140,7 +140,7 @@ describe("deleteAdminTrialEntry", () => {
data: {
deletedTrialEntryId: "entry-1",
trialEventId: "event-1",
- deletedTrialEvent: true,
+ deletedTrialEvent: false,
},
},
});
diff --git a/packages/server/admin/trials/manage/__tests__/delete-trial-event.test.ts b/packages/server/admin/trials/manage/__tests__/delete-trial-event.test.ts
new file mode 100644
index 00000000..53d97c77
--- /dev/null
+++ b/packages/server/admin/trials/manage/__tests__/delete-trial-event.test.ts
@@ -0,0 +1,64 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { deleteAdminTrialEvent } from "../delete-trial-event";
+
+const { deleteWriteMock } = vi.hoisted(() => ({ deleteWriteMock: vi.fn() }));
+
+vi.mock("@beagle/db", () => ({
+ deleteAdminTrialEventWriteDb: deleteWriteMock,
+}));
+
+const admin = {
+ id: "u_1",
+ email: "admin@example.com",
+ username: null,
+ role: "ADMIN" as const,
+};
+
+describe("deleteAdminTrialEvent", () => {
+ beforeEach(() => deleteWriteMock.mockReset());
+
+ it("validates and authorizes the request", async () => {
+ expect(
+ (await deleteAdminTrialEvent({ trialEventId: " " }, admin)).status,
+ ).toBe(400);
+ expect(
+ (
+ await deleteAdminTrialEvent(
+ { trialEventId: "event-1" },
+ { ...admin, role: "USER" },
+ )
+ ).status,
+ ).toBe(403);
+ expect(deleteWriteMock).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["not_found", 404, "TRIAL_EVENT_NOT_FOUND"],
+ ["not_empty", 409, "TRIAL_EVENT_NOT_EMPTY"],
+ ] as const)("maps %s", async (status, httpStatus, code) => {
+ deleteWriteMock.mockResolvedValue({ status });
+
+ const result = await deleteAdminTrialEvent(
+ { trialEventId: "event-1" },
+ admin,
+ );
+
+ expect(result.status).toBe(httpStatus);
+ expect(result.body).toMatchObject({ ok: false, code });
+ });
+
+ it("returns the deleted event id", async () => {
+ deleteWriteMock.mockResolvedValue({
+ status: "deleted",
+ deletedTrialEventId: "event-1",
+ });
+
+ await expect(
+ deleteAdminTrialEvent({ trialEventId: " event-1 " }, admin),
+ ).resolves.toEqual({
+ status: 200,
+ body: { ok: true, data: { deletedTrialEventId: "event-1" } },
+ });
+ expect(deleteWriteMock).toHaveBeenCalledWith({ trialEventId: "event-1" });
+ });
+});
diff --git a/packages/server/admin/trials/manage/create-trial-event.ts b/packages/server/admin/trials/manage/create-trial-event.ts
new file mode 100644
index 00000000..35f9c552
--- /dev/null
+++ b/packages/server/admin/trials/manage/create-trial-event.ts
@@ -0,0 +1,155 @@
+import type {
+ CreateAdminTrialEventRequest,
+ CreateAdminTrialEventResponse,
+ CurrentUserDto,
+} from "@beagle/contracts";
+import {
+ createAdminTrialEventWriteDb,
+ listActiveTrialRuleWindowsDb,
+} from "@beagle/db";
+import { requireAdmin } from "@server/admin/core/service";
+import { toErrorLog, withLogContext } from "@server/core/logger";
+import type { ServiceResult } from "@server/core/result";
+import { resolveTrialRuleWindowId } from "@server/trials/core";
+import { parseAdminTrialEventWriteInput } from "./internal/parse-admin-trial-event-write-input";
+
+type ServiceLogContext = {
+ requestId?: string;
+ actorUserId?: string;
+};
+
+function isUniqueConstraintError(error: unknown): boolean {
+ return Boolean(
+ error &&
+ typeof error === "object" &&
+ "code" in error &&
+ (error as { code?: string }).code === "P2002",
+ );
+}
+
+// Creates one empty canonical trial event for an authorized administrator.
+export async function createAdminTrialEvent(
+ input: CreateAdminTrialEventRequest,
+ currentUser: CurrentUserDto | null,
+ context?: ServiceLogContext,
+): Promise> {
+ const startedAt = Date.now();
+ const log = withLogContext({
+ layer: "service",
+ useCase: "admin-trials.createAdminTrialEvent",
+ ...(context?.requestId ? { requestId: context.requestId } : {}),
+ ...(context?.actorUserId ? { actorUserId: context.actorUserId } : {}),
+ });
+
+ const authResult = requireAdmin(currentUser);
+ if (!authResult.body.ok) {
+ log.warn(
+ {
+ event: "forbidden",
+ status: authResult.status,
+ durationMs: Date.now() - startedAt,
+ },
+ "admin trial event creation rejected by authorization",
+ );
+ return { status: authResult.status, body: authResult.body };
+ }
+
+ const parsed = parseAdminTrialEventWriteInput(input, {
+ requireSklKoeId: true,
+ });
+ if (!parsed.ok) {
+ log.warn(
+ { event: parsed.issue.event, durationMs: Date.now() - startedAt },
+ "admin trial event creation rejected because event metadata is invalid",
+ );
+ return {
+ status: 400,
+ body: {
+ ok: false,
+ error: parsed.issue.error,
+ code: parsed.issue.code,
+ },
+ };
+ }
+
+ const event = parsed.data;
+ log.info(
+ {
+ event: "start",
+ eventDate: event.normalizedEventDate,
+ eventPlace: event.eventPlace,
+ sklKoeId: event.sklKoeId,
+ },
+ "admin trial event creation started",
+ );
+
+ try {
+ const activeRuleWindows = await listActiveTrialRuleWindowsDb();
+ const trialRuleWindowId = resolveTrialRuleWindowId(
+ activeRuleWindows,
+ event.eventDate,
+ );
+ const result = await createAdminTrialEventWriteDb({
+ eventDate: event.eventDate,
+ eventPlace: event.eventPlace,
+ jarjestaja: event.jarjestaja,
+ ylituomari: event.ylituomari,
+ ylituomariNumero: event.ylituomariNumero,
+ ytKertomus: event.ytKertomus,
+ kennelpiiri: event.kennelpiiri,
+ kennelpiirinro: event.kennelpiirinro,
+ sklKoeId: event.sklKoeId!,
+ trialRuleWindowId,
+ });
+
+ log.info(
+ {
+ event: "success",
+ trialEventId: result.trialEventId,
+ sklKoeId: event.sklKoeId,
+ durationMs: Date.now() - startedAt,
+ },
+ "admin trial event creation succeeded",
+ );
+ return {
+ status: 201,
+ body: { ok: true, data: { trialEventId: result.trialEventId } },
+ };
+ } catch (error) {
+ if (isUniqueConstraintError(error)) {
+ log.warn(
+ {
+ event: "skl_koe_id_conflict",
+ sklKoeId: event.sklKoeId,
+ durationMs: Date.now() - startedAt,
+ },
+ "admin trial event creation rejected because sklKoeId is already used",
+ );
+ return {
+ status: 409,
+ body: {
+ ok: false,
+ error: "An event already uses this SKL koe id.",
+ code: "SKL_KOE_ID_CONFLICT",
+ },
+ };
+ }
+
+ log.error(
+ {
+ event: "exception",
+ durationMs: Date.now() - startedAt,
+ ...toErrorLog(error),
+ },
+ "admin trial event creation failed",
+ );
+ return {
+ status: 500,
+ body: {
+ ok: false,
+ error: "Failed to create admin trial event.",
+ code: "INTERNAL_ERROR",
+ },
+ };
+ }
+}
diff --git a/packages/server/admin/trials/manage/delete-trial-event.ts b/packages/server/admin/trials/manage/delete-trial-event.ts
new file mode 100644
index 00000000..61233360
--- /dev/null
+++ b/packages/server/admin/trials/manage/delete-trial-event.ts
@@ -0,0 +1,124 @@
+import type {
+ CurrentUserDto,
+ DeleteAdminTrialEventRequest,
+ DeleteAdminTrialEventResponse,
+} from "@beagle/contracts";
+import { deleteAdminTrialEventWriteDb } from "@beagle/db";
+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;
+};
+
+// Deletes one canonical trial event only while it has no result rows.
+export async function deleteAdminTrialEvent(
+ input: DeleteAdminTrialEventRequest,
+ currentUser: CurrentUserDto | null,
+ context?: ServiceLogContext,
+): Promise> {
+ const startedAt = Date.now();
+ const trialEventId = input.trialEventId.trim();
+ const log = withLogContext({
+ layer: "service",
+ useCase: "admin-trials.deleteAdminTrialEvent",
+ ...(context?.requestId ? { requestId: context.requestId } : {}),
+ ...(context?.actorUserId ? { actorUserId: context.actorUserId } : {}),
+ });
+
+ if (!trialEventId) {
+ log.warn(
+ { event: "invalid_trial_event_id", durationMs: Date.now() - startedAt },
+ "admin trial event deletion rejected because trialEventId is invalid",
+ );
+ return {
+ status: 400,
+ body: {
+ ok: false,
+ error: "Trial event id is required.",
+ code: "INVALID_TRIAL_EVENT_ID",
+ },
+ };
+ }
+
+ const authResult = requireAdmin(currentUser);
+ if (!authResult.body.ok) {
+ log.warn(
+ {
+ event: "forbidden",
+ status: authResult.status,
+ durationMs: Date.now() - startedAt,
+ },
+ "admin trial event deletion rejected by authorization",
+ );
+ return { status: authResult.status, body: authResult.body };
+ }
+
+ log.info(
+ { event: "start", trialEventId },
+ "admin trial event deletion started",
+ );
+ try {
+ const result = await deleteAdminTrialEventWriteDb({ trialEventId });
+ if (result.status === "not_found") {
+ return {
+ status: 404,
+ body: {
+ ok: false,
+ error: "Trial event not found.",
+ code: "TRIAL_EVENT_NOT_FOUND",
+ },
+ };
+ }
+ if (result.status === "not_empty") {
+ log.warn(
+ {
+ event: "not_empty",
+ trialEventId,
+ durationMs: Date.now() - startedAt,
+ },
+ "admin trial event deletion rejected because the event has entries",
+ );
+ return {
+ status: 409,
+ body: {
+ ok: false,
+ error: "A trial event with results cannot be deleted.",
+ code: "TRIAL_EVENT_NOT_EMPTY",
+ },
+ };
+ }
+
+ log.info(
+ { event: "success", trialEventId, durationMs: Date.now() - startedAt },
+ "admin trial event deletion succeeded",
+ );
+ return {
+ status: 200,
+ body: {
+ ok: true,
+ data: { deletedTrialEventId: result.deletedTrialEventId },
+ },
+ };
+ } catch (error) {
+ log.error(
+ {
+ event: "exception",
+ trialEventId,
+ durationMs: Date.now() - startedAt,
+ ...toErrorLog(error),
+ },
+ "admin trial event deletion failed",
+ );
+ return {
+ status: 500,
+ body: {
+ ok: false,
+ error: "Failed to delete admin trial event.",
+ code: "INTERNAL_ERROR",
+ },
+ };
+ }
+}
diff --git a/packages/server/admin/trials/manage/index.ts b/packages/server/admin/trials/manage/index.ts
index 2e5bada5..606751fb 100644
--- a/packages/server/admin/trials/manage/index.ts
+++ b/packages/server/admin/trials/manage/index.ts
@@ -1,5 +1,7 @@
export { getAdminTrialEvent } from "./get-trial-event";
export { listAdminTrialEvents } from "./list-trials";
+export { createAdminTrialEvent } from "./create-trial-event";
+export { deleteAdminTrialEvent } from "./delete-trial-event";
export { deleteAdminTrialEntry } from "./delete-trial-entry";
export { updateAdminTrialEvent } from "./update-trial-event";
export { updateAdminTrialEntry } from "./update-trial-entry";
diff --git a/packages/server/admin/trials/manage/internal/parse-admin-trial-event-write-input.ts b/packages/server/admin/trials/manage/internal/parse-admin-trial-event-write-input.ts
new file mode 100644
index 00000000..17408cf3
--- /dev/null
+++ b/packages/server/admin/trials/manage/internal/parse-admin-trial-event-write-input.ts
@@ -0,0 +1,108 @@
+import { parseIsoDateOnlyToUtcDate } from "@server/trials/internal/iso-date";
+
+type AdminTrialEventWriteInput = {
+ eventDate: string;
+ eventPlace: string;
+ jarjestaja: string | null;
+ ylituomari: string | null;
+ ylituomariNumero: string | null;
+ ytKertomus: string | null;
+ kennelpiiri: string | null;
+ kennelpiirinro: string | null;
+ sklKoeId: number | null;
+};
+
+type ParsedAdminTrialEventWriteInput = {
+ normalizedEventDate: string;
+ eventDate: Date;
+ eventPlace: string;
+ jarjestaja: string | null;
+ ylituomari: string | null;
+ ylituomariNumero: string | null;
+ ytKertomus: string | null;
+ kennelpiiri: string | null;
+ kennelpiirinro: string | null;
+ sklKoeId: number | null;
+};
+
+type AdminTrialEventWriteValidationIssue = {
+ event: "invalid_event_date" | "invalid_event_place" | "invalid_skl_koe_id";
+ code: "INVALID_EVENT_DATE" | "INVALID_EVENT_PLACE" | "INVALID_SKL_KOE_ID";
+ error: string;
+};
+
+function normalizeOptionalText(value: string | null): string | null {
+ if (value === null) {
+ return null;
+ }
+
+ const normalized = value.trim();
+ return normalized.length > 0 ? normalized : null;
+}
+
+// Normalizes the event metadata shared by admin event creation and editing.
+export function parseAdminTrialEventWriteInput(
+ input: AdminTrialEventWriteInput,
+ options: { requireSklKoeId: boolean },
+):
+ | { ok: true; data: ParsedAdminTrialEventWriteInput }
+ | { ok: false; issue: AdminTrialEventWriteValidationIssue } {
+ const normalizedEventDate = input.eventDate.trim();
+ const eventDate = parseIsoDateOnlyToUtcDate(normalizedEventDate);
+ if (!eventDate) {
+ return {
+ ok: false,
+ issue: {
+ event: "invalid_event_date",
+ code: "INVALID_EVENT_DATE",
+ error: "Event date must use YYYY-MM-DD format.",
+ },
+ };
+ }
+
+ const eventPlace = input.eventPlace.trim();
+ if (!eventPlace) {
+ return {
+ ok: false,
+ issue: {
+ event: "invalid_event_place",
+ code: "INVALID_EVENT_PLACE",
+ error: "Event place is required.",
+ },
+ };
+ }
+
+ const sklKoeId = input.sklKoeId;
+ if (
+ (options.requireSklKoeId && sklKoeId === null) ||
+ (sklKoeId !== null &&
+ (!Number.isInteger(sklKoeId) ||
+ !Number.isFinite(sklKoeId) ||
+ sklKoeId < 1))
+ ) {
+ return {
+ ok: false,
+ issue: {
+ event: "invalid_skl_koe_id",
+ code: "INVALID_SKL_KOE_ID",
+ error: "SKL koe id must be a positive integer.",
+ },
+ };
+ }
+
+ return {
+ ok: true,
+ data: {
+ normalizedEventDate,
+ eventDate,
+ eventPlace,
+ jarjestaja: normalizeOptionalText(input.jarjestaja),
+ ylituomari: normalizeOptionalText(input.ylituomari),
+ ylituomariNumero: normalizeOptionalText(input.ylituomariNumero),
+ ytKertomus: normalizeOptionalText(input.ytKertomus),
+ kennelpiiri: normalizeOptionalText(input.kennelpiiri),
+ kennelpiirinro: normalizeOptionalText(input.kennelpiirinro),
+ sklKoeId,
+ },
+ };
+}
diff --git a/packages/server/admin/trials/manage/update-trial-event.ts b/packages/server/admin/trials/manage/update-trial-event.ts
index 12c7b1de..1b4971f8 100644
--- a/packages/server/admin/trials/manage/update-trial-event.ts
+++ b/packages/server/admin/trials/manage/update-trial-event.ts
@@ -7,26 +7,13 @@ import { updateAdminTrialEventWriteDb } from "@beagle/db";
import { requireAdmin } from "@server/admin/core/service";
import { toErrorLog, withLogContext } from "@server/core/logger";
import type { ServiceResult } from "@server/core/result";
-import { parseIsoDateOnlyToUtcDate } from "@server/trials/internal/iso-date";
+import { parseAdminTrialEventWriteInput } from "./internal/parse-admin-trial-event-write-input";
type ServiceLogContext = {
requestId?: string;
actorUserId?: string;
};
-function normalizeRequiredText(value: string): string {
- return value.trim();
-}
-
-function normalizeOptionalText(value: string | null): string | null {
- if (value === null) {
- return null;
- }
-
- const normalized = value.trim();
- return normalized.length > 0 ? normalized : null;
-}
-
// Validates and updates canonical TrialEvent metadata for one admin-selected event.
export async function updateAdminTrialEvent(
input: UpdateAdminTrialEventRequest,
@@ -73,72 +60,39 @@ export async function updateAdminTrialEvent(
};
}
- const normalizedEventDate = input.eventDate.trim();
- const eventDate = parseIsoDateOnlyToUtcDate(normalizedEventDate);
- if (!eventDate) {
+ const parsed = parseAdminTrialEventWriteInput(input, {
+ requireSklKoeId: false,
+ });
+ if (!parsed.ok) {
log.warn(
{
- event: "invalid_event_date",
- eventDate: input.eventDate,
+ event: parsed.issue.event,
durationMs: Date.now() - startedAt,
},
- "admin trial event update rejected because eventDate is invalid",
- );
- return {
- status: 400,
- body: {
- ok: false,
- error: "Event date must use YYYY-MM-DD format.",
- code: "INVALID_EVENT_DATE",
- },
- };
- }
-
- const eventPlace = normalizeRequiredText(input.eventPlace);
- if (!eventPlace) {
- log.warn(
- { event: "invalid_event_place", durationMs: Date.now() - startedAt },
- "admin trial event update rejected because eventPlace is invalid",
+ "admin trial event update rejected because event metadata is invalid",
);
return {
status: 400,
body: {
ok: false,
- error: "Event place is required.",
- code: "INVALID_EVENT_PLACE",
+ error: parsed.issue.error,
+ code: parsed.issue.code,
},
};
}
- const jarjestaja = normalizeOptionalText(input.jarjestaja);
- const ylituomari = normalizeOptionalText(input.ylituomari);
- const ylituomariNumero = normalizeOptionalText(input.ylituomariNumero);
- const ytKertomus = normalizeOptionalText(input.ytKertomus);
- const kennelpiiri = normalizeOptionalText(input.kennelpiiri);
- const kennelpiirinro = normalizeOptionalText(input.kennelpiirinro);
-
- const sklKoeId = input.sklKoeId;
- if (
- sklKoeId !== null &&
- (!Number.isInteger(sklKoeId) || !Number.isFinite(sklKoeId) || sklKoeId < 1)
- ) {
- log.warn(
- {
- event: "invalid_skl_koe_id",
- sklKoeId: input.sklKoeId,
- durationMs: Date.now() - startedAt,
- },
- "admin trial event update rejected because sklKoeId is invalid",
- );
- return {
- status: 400,
- body: {
- ok: false,
- error: "SKL koe id must be a positive integer.",
- code: "INVALID_SKL_KOE_ID",
- },
- };
- }
+ const {
+ normalizedEventDate,
+ eventDate,
+ eventPlace,
+ jarjestaja,
+ ylituomari,
+ ylituomariNumero,
+ ytKertomus,
+ kennelpiiri,
+ kennelpiirinro,
+ sklKoeId,
+ } = parsed.data;
log.info(
{
diff --git a/packages/server/index.ts b/packages/server/index.ts
index 681c80d7..9be0bf62 100644
--- a/packages/server/index.ts
+++ b/packages/server/index.ts
@@ -45,6 +45,8 @@ export { createTrialsService, trialsService } from "./trials";
export { upsertKoiratietokantaAjokResultService } from "./trials";
export { listAdminTrialEvents } from "./admin";
export { getAdminTrialEvent } from "./admin";
+export { createAdminTrialEvent } from "./admin";
+export { deleteAdminTrialEvent } from "./admin";
export { deleteAdminTrialEntry } from "./admin";
export { updateAdminTrialEvent } from "./admin";
export { updateAdminTrialEntry } from "./admin";
diff --git a/packages/server/trials/integrations/koiratietokanta/__tests__/upsert-ajok-result.test.ts b/packages/server/trials/integrations/koiratietokanta/__tests__/upsert-ajok-result.test.ts
index 09b0b33c..ce205de9 100644
--- a/packages/server/trials/integrations/koiratietokanta/__tests__/upsert-ajok-result.test.ts
+++ b/packages/server/trials/integrations/koiratietokanta/__tests__/upsert-ajok-result.test.ts
@@ -1,9 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { upsertKoiratietokantaAjokResultService } from "../upsert-ajok-result";
-const { upsertDbMock, trialRuleWindowFindManyMock } = vi.hoisted(() => ({
+const { upsertDbMock, listActiveRuleWindowsMock } = vi.hoisted(() => ({
upsertDbMock: vi.fn(),
- trialRuleWindowFindManyMock: vi.fn(),
+ listActiveRuleWindowsMock: vi.fn(),
}));
vi.mock("@beagle/db", () => ({
@@ -22,19 +22,15 @@ vi.mock("@beagle/db", () => ({
SULJETTU: "SULJETTU",
KESKEYTETTY: "KESKEYTETTY",
},
- prisma: {
- trialRuleWindow: {
- findMany: trialRuleWindowFindManyMock,
- },
- },
+ listActiveTrialRuleWindowsDb: listActiveRuleWindowsMock,
upsertKoiratietokantaAjokResultDb: upsertDbMock,
}));
describe("upsertKoiratietokantaAjokResultService", () => {
beforeEach(() => {
upsertDbMock.mockReset();
- trialRuleWindowFindManyMock.mockReset();
- trialRuleWindowFindManyMock.mockResolvedValue([
+ listActiveRuleWindowsMock.mockReset();
+ listActiveRuleWindowsMock.mockResolvedValue([
{
id: "window-1",
fromYmd: 20200101,
diff --git a/packages/server/trials/integrations/koiratietokanta/upsert-ajok-result.ts b/packages/server/trials/integrations/koiratietokanta/upsert-ajok-result.ts
index f66bcf6f..193e9647 100644
--- a/packages/server/trials/integrations/koiratietokanta/upsert-ajok-result.ts
+++ b/packages/server/trials/integrations/koiratietokanta/upsert-ajok-result.ts
@@ -2,7 +2,10 @@ import {
type KoiratietokantaAjokUpsertRequest,
type KoiratietokantaAjokUpsertResponse,
} from "@beagle/contracts";
-import { prisma, upsertKoiratietokantaAjokResultDb } from "@beagle/db";
+import {
+ listActiveTrialRuleWindowsDb,
+ upsertKoiratietokantaAjokResultDb,
+} from "@beagle/db";
import { toErrorLog, withLogContext } from "@server/core/logger";
import type { ServiceResult } from "@server/core/result";
import { resolveTrialRuleWindowId } from "@server/trials/core";
@@ -35,10 +38,7 @@ export async function upsertKoiratietokantaAjokResultService(
}
try {
- const activeRuleWindows = await prisma.trialRuleWindow.findMany({
- where: { isActive: true },
- orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
- });
+ const activeRuleWindows = await listActiveTrialRuleWindowsDb();
const trialRuleWindowId = resolveTrialRuleWindowId(
activeRuleWindows,
mapped.event.koepaiva,