Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/app/actions/admin/trials/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { deleteAdminTrialEntryAction } from "./manage";
export { updateAdminTrialEventAction } from "./manage";
export { updateAdminTrialEntryAction } from "./manage";
export { createAdminTrialEntryAction } from "./manage";
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAdminTrialEntryAction } from "../create-admin-trial-entry";

const { guard, user, create } = vi.hoisted(() => ({
guard: vi.fn(),
user: vi.fn(),
create: vi.fn(),
}));
vi.mock("@/lib/server/admin-guard", () => ({
requireAdminLayoutAccess: guard,
}));
vi.mock("@/lib/server/current-user", () => ({ getSessionCurrentUser: user }));
vi.mock("@beagle/server", () => ({ createAdminTrialEntry: create }));

const input = {
trialEventId: "event-1",
registrationNo: "FI1/20",
entry: {} as never,
eras: [],
lisatiedotRows: [],
};

describe("createAdminTrialEntryAction", () => {
beforeEach(() => {
guard.mockReset();
user.mockReset();
create.mockReset();
});

it("rejects missing admin access", async () => {
guard.mockResolvedValue({ ok: false, status: 403 });
await expect(createAdminTrialEntryAction(input)).resolves.toMatchObject({
hasError: true,
errorCode: "FORBIDDEN",
});
});

it("passes through stable service errors", async () => {
guard.mockResolvedValue({ ok: true });
user.mockResolvedValue({ id: "u1", email: "a@example.com", role: "ADMIN" });
create.mockResolvedValue({
status: 409,
body: {
ok: false,
code: "TRIAL_ENTRY_REGISTRATION_CONFLICT",
error: "Conflict",
},
});
await expect(createAdminTrialEntryAction(input)).resolves.toEqual({
data: null,
hasError: true,
errorCode: "TRIAL_ENTRY_REGISTRATION_CONFLICT",
message: "Conflict",
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use server";

import type {
CreateAdminTrialEntryRequest,
CreateAdminTrialEntryResponse,
} from "@beagle/contracts";
import { createAdminTrialEntry } from "@beagle/server";
import { requireAdminLayoutAccess } from "@/lib/server/admin-guard";
import { getSessionCurrentUser } from "@/lib/server/current-user";

export type CreateAdminTrialEntryActionResult = {
data: CreateAdminTrialEntryResponse | null;
hasError: boolean;
errorCode?: string;
message?: string;
};

export async function createAdminTrialEntryAction(
input: CreateAdminTrialEntryRequest,
): Promise<CreateAdminTrialEntryActionResult> {
const access = await requireAdminLayoutAccess();
if (!access.ok) {
return {
data: null,
hasError: true,
errorCode: access.status === 401 ? "UNAUTHENTICATED" : "FORBIDDEN",
message: "Admin access required.",
};
}
const user = await getSessionCurrentUser();
if (!user) {
return {
data: null,
hasError: true,
errorCode: "UNAUTHENTICATED",
message: "Admin access required.",
};
}
const result = await createAdminTrialEntry(
input,
{ id: user.id, email: user.email, username: null, role: user.role },
{ actorUserId: user.id },
);
if (!result.body.ok) {
return {
data: null,
hasError: true,
errorCode: result.body.code,
message: result.body.error,
};
}
return { data: result.body.data, hasError: false };
}
1 change: 1 addition & 0 deletions apps/web/app/actions/admin/trials/manage/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { createAdminTrialEventAction } from "./create-admin-trial-event";
export { createAdminTrialEntryAction } from "./create-admin-trial-entry";
export { deleteAdminTrialEventAction } from "./delete-admin-trial-event";
export { deleteAdminTrialEntryAction } from "./delete-admin-trial-entry";
export { updateAdminTrialEventAction } from "./update-admin-trial-event";
Expand Down
12 changes: 12 additions & 0 deletions docs/features/admin-trial-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and follow-up admin flow redesign).
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.
- The R1 backend contract can create one manual result at a time through an
admin Server Action. It is intentionally not linked to a UI until R2.

## Main files

Expand Down Expand Up @@ -44,6 +46,9 @@ and follow-up admin flow redesign).
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.
6. Manual result creation validates and canonicalizes the typed registration,
then atomically creates the entry, eras, and lisätiedot. A matching local
`DogRegistration` links the dog; an unknown registration remains unlinked.

## Contract rules

Expand All @@ -53,6 +58,13 @@ and follow-up admin flow redesign).
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`.
- Manual result identity is `SKL:<sklKoeId>|REG:<canonicalRegistration>` and
uses source `MANUAL_ADMIN`. Duplicate registration within an event returns
`TRIAL_ENTRY_REGISTRATION_CONFLICT`.
- Manual result writes require each normalized lisätieto `(koodi, osa)` pair
to occur only once across the submitted matrix rows.
- `TrialEvent.koepaiva` is a PostgreSQL `DATE`; all trial contracts serialize
it as timezone-free `YYYY-MM-DD`.
- 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.
Expand Down
3 changes: 2 additions & 1 deletion docs/features/schema/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ For show-domain deep details, see:
- `ImportIssueSeverity`: `INFO`, `WARNING`, `ERROR`
- `ShowSourceTag`: source tagging for legacy/workbook/manual show data
- `TrialSourceTag`: source tagging for canonical trial entry writes
(`LEGACY_AKOEALL`, `MANUAL_ADMIN`, `KOIRATIETOKANTA_API`)
- `ShowResultValueType`: `FLAG`, `CODE`, `TEXT`, `NUMERIC`, `DATE`
- `AuditAction`: `INSERT`, `UPDATE`, `DELETE`
- `AuditSource`: `WEB`, `SCRIPT`, `SYSTEM`
Expand Down Expand Up @@ -82,7 +83,7 @@ erDiagram
### Results

- `TrialResult`: canonical trial rows keyed by unique `sourceKey`.
- `TrialEvent`: canonical AJOK trial event (new schema event level).
- `TrialEvent`: canonical AJOK trial event; `koepaiva` is PostgreSQL `DATE`.
- `TrialEntry`: canonical AJOK trial dog entry (new schema entry level).
- `TrialEntry` stores the direct summary/core fields for one dog in one event.
- `TrialEntry.ke` stores the top-level weather/condition value from source
Expand Down
7 changes: 7 additions & 0 deletions docs/features/trials/koiratietokanta-api-ajok-upsert.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ result upsert contract.
- `KOEPAIKKA`
- Unknown fields are ignored.
- The raw payload is always preserved in `TrialEntry.raakadataJson`.
- If the same canonical identity was first created manually, the API update is
authoritative: it keeps technical identity and row creation time, replaces
all mapped business fields and eras/lisätiedot, clears manual-only `tja`, and
changes the source to `KOIRATIETOKANTA_API`.
- `tja` is the legacy-trial tie- ja estetyöskentely summary field. Current API
tie/obstacle values are stored as lisätiedot instead, so the API upsert does
not derive a new `tja` summary value.

## Main field mapping

Expand Down
20 changes: 16 additions & 4 deletions docs/planning/trials/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ on the next gate.

- [Event creation](./event-creation.md) defines the event workspace, manual
event creation, and the persisted empty-event lifecycle.
- [Result creation](./result-creation.md) defines the manual result write path
and the full-page result form.
- [Result creation R1](./result-creation.md) defines the manual-result schema,
identity, transaction, error, date-only, and Server Action backend contract.
- [Result creation R2](./result-creation-r2.md) defines the full-page result
form and admin UI workflow built on the approved R1 contract.
- [Later UX](./later-ux.md) records deferred ideas only and does not authorize
their implementation.

Expand Down Expand Up @@ -66,10 +68,20 @@ Repository guardrails and current feature documentation:

Implement gates in this order:

```text
E1
E2
R1 (backend)
R2 (UI)
```

1. `E1` - event workspace
2. `E2` - event creation and empty-event lifecycle
3. `R1` - manual result backend and schema foundation
4. `R2` - full-page manual result form
3. `R1` - manual result schema and backend
4. `R2` - manual result UI and workflow

For every gate:

Expand Down
154 changes: 154 additions & 0 deletions docs/planning/trials/result-creation-r2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# BEJ-103 Gate R2 — Manual Result UI and Workflow

## Purpose

This plan adds the full-page admin workflow for creating one manual
`TrialEntry` at a time after Gate R1 has been implemented, validated, reviewed,
and approved. It preserves the existing result-edit modal and the trials
master-detail page.

Cross-slice decisions and review rules are defined in the
[planning overview](./README.md). Event dependencies are defined in
[Event creation](./event-creation.md), and the complete backend contract is
owned by [Gate R1](./result-creation.md).

## R1 contract assumed by R2

R2 consumes, but does not redefine or weaken, the R1 decisions for:

- Prisma schema and date-only storage;
- manual-result request and response contracts;
- backend service and DB transaction behavior;
- admin Server Action transport;
- canonical registration and identity;
- duplicate protection and `DogRegistration` resolution;
- stable backend error codes;
- authoritative Koiratietokanta replacement; and
- timezone-free `YYYY-MM-DD` event-date behavior.

R2 maps stable `errorCode` values to localized presentation and never inspects
backend message strings to determine behavior.

## Scope

- Add a dedicated full-page result-create route under one event workspace.
- Keep the event identity visible while entering a dog result.
- Add a required free-text registration field. Do not require selecting an
existing dog.
- Reuse the existing result, era, lisatieto, judge, and note sections where
practical, with one initial era and the existing validation semantics.
- Prefill each clean form with the event's chief-judge name and number. Keep
all dog/result-specific values empty and the initial trial type `NORMAL`.
- Preserve entered values after server or validation failure and prevent
duplicate submissions while a save is pending.
- Add a React Query mutation over the R1 Server Action. On success, invalidate
and refetch the admin event/list and affected public trial queries. Do not
optimistically insert a partial result into query caches.
- Add two successful submission paths:
- **Save and add another** creates one result, shows localized success
feedback, and opens a clean form for the same event with event-level judge
defaults restored.
- **Save and finish** creates one result, shows localized success feedback,
and returns to the event workspace.
- Change E2's temporary successful event-create continuation so the primary
action opens the first result form for the newly persisted event.
- Define cancel and browser navigation:
- Cancel returns to the event workspace.
- Dirty cancel uses a localized application confirmation dialog before
discarding values.
- Browser-back and internal navigation use a route-blocking application
confirmation where technically supported.
- Refresh and tab/window close use browser-native unsaved-change protection
with browser-controlled wording.
- Clean browser back returns to the event workspace without confirmation.
- A failed or rejected save stays on the populated form.
- Add Finnish and Swedish labels, helper text, validation messages, stable
backend-error mappings, and success/error feedback.
- Add the user-visible result-creation change under `CHANGELOG.md` Unreleased
when R2 exposes the workflow.
- Keep existing result editing in its current modal.

## Error and validation presentation

- Client validation provides immediate field feedback but does not replace R1
server validation or canonical normalization.
- `INVALID_REGISTRATION_NUMBER`, `INVALID_TRIAL_ENTRY`,
`INVALID_TRIAL_ERAS`, and `INVALID_TRIAL_ADDITIONAL_INFO` keep the populated
form open and show actionable localized validation feedback.
- `TRIAL_ENTRY_REGISTRATION_CONFLICT` explains that the canonical registration
already exists in the selected event and keeps the form populated.
- `TRIAL_EVENT_NOT_FOUND` and `TRIAL_EVENT_MISSING_SKL_ID` show an event-level
state with navigation back to the workspace or trials list as appropriate.
- `UNAUTHENTICATED` and `FORBIDDEN` continue through the existing admin access
behavior.
- Unexpected failures keep the form populated and show localized generic error
feedback.

## Exclusions

- No migration of existing result editing to a page.
- No searchable dog picker or inline dog creation.
- No batch form, autosave, draft/publish state, or reconciliation UI.
- No optimistic cache insertion.
- No redesign of the existing trials master-detail list.
- No public UI or trial-statistics changes.
- No changes to R1 identity, transaction, error, date-only, or authoritative
upsert behavior.

## Dependencies

- E1 and E2 must be merged for the workspace and persisted event lifecycle.
- R1 must be merged and separately approved for its schema, contracts, Server
Action, stable errors, and persistence behavior.
- Existing entry form sections and form-model behavior are the reuse baseline;
any extraction must preserve the current edit-modal behavior.
- Existing admin trial query keys and localized message infrastructure are the
integration points for invalidation and user feedback.

## Acceptance criteria

- Successful event creation continues to the first result form for that exact
event.
- The event date is displayed from the R1 `YYYY-MM-DD` contract without client
timezone reinterpretation.
- A clean form has one initial era, `NORMAL` trial type, and the event's
chief-judge defaults.
- An admin can save a result for a known or unknown registration.
- Stable R1 error codes produce localized UI behavior without inspecting
backend messages.
- Save and add another clears dog/result-specific state, restores event-level
defaults, retains event context, and produces no duplicate submission.
- Save and finish returns to the event workspace where the invalidated event
query reloads the new result.
- No partial result is inserted optimistically into admin or public caches.
- Cancel, browser back, refresh, and close protect dirty input using the
documented native browser behavior.
- Validation and server errors preserve entered data and provide actionable
Finnish and Swedish feedback.
- The existing result-edit modal behaves as before.
- The user-visible change is recorded under `CHANGELOG.md` Unreleased.

## Targeted validation

- Component/form-model tests for initial event-level defaults, validation,
dirty state, reset, and Finnish/Swedish feedback.
- Mutation tests for stable error-code mapping, success, failure, query
invalidation/refetch, absence of optimistic insertion, and duplicate-submit
protection.
- Route/workflow tests for event-create continuation, add-another, finish,
missing-event and missing-SKL states, cancel, browser back, refresh/close
protection, and timezone-free event-date display.
- Regression tests for the existing result-edit modal and selected-event panel.
- Targeted web type checking and linting; do not run cycle lint in the agent
workflow.
- Manual desktop and mobile checks for the long form and both continuation
paths when browser tooling is available.

## Merge independence and review gate

R2 is the final BEJ-103 creation gate and can merge safely only after E1, E2,
and R1. It replaces E2's documented temporary redirect with the final
continuation behavior and leaves no dependency on deferred UX work.

Stop after validation and request final BEJ-103 review. Deferred items remain
unauthorized unless they are moved into an approved planning gate.
Loading
Loading