From 63c0e3b0c76bf9f6b42d7430b4691a02a8ce7fd6 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:53:24 +0300 Subject: [PATCH 1/5] trial entry planning docs ref bej-103 --- docs/planning/trials/README.md | 20 +- docs/planning/trials/result-creation-r2.md | 151 +++++++ docs/planning/trials/result-creation.md | 498 +++++++++++++-------- 3 files changed, 473 insertions(+), 196 deletions(-) create mode 100644 docs/planning/trials/result-creation-r2.md diff --git a/docs/planning/trials/README.md b/docs/planning/trials/README.md index 9180c064..0dc0ccb9 100644 --- a/docs/planning/trials/README.md +++ b/docs/planning/trials/README.md @@ -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. @@ -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: diff --git a/docs/planning/trials/result-creation-r2.md b/docs/planning/trials/result-creation-r2.md new file mode 100644 index 00000000..5d5ddfa0 --- /dev/null +++ b/docs/planning/trials/result-creation-r2.md @@ -0,0 +1,151 @@ +# 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 and browser-back navigation use a localized native + confirmation before discarding values. + - Refresh and tab/window close use browser-native unsaved-change protection. + - 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. diff --git a/docs/planning/trials/result-creation.md b/docs/planning/trials/result-creation.md index 49802df2..44e30042 100644 --- a/docs/planning/trials/result-creation.md +++ b/docs/planning/trials/result-creation.md @@ -1,197 +1,311 @@ -# BEJ-103 Result Creation +# BEJ-103 Gate R1 — Manual Result Schema and Backend ## Purpose -This plan adds one-at-a-time manual `TrialEntry` creation after the event -workspace and empty-event lifecycle are complete. It preserves the existing -result-edit modal. Cross-slice decisions and review rules are defined in the -[planning overview](./README.md), and event dependencies are defined in -[Event creation](./event-creation.md). - -## Existing identity and upsert behavior - -The current Koiratietokanta write path: - -- resolves a `TrialEvent` through unique `TrialEvent.sklKoeId`; -- resolves an entry inside that event through the existing - `TrialEvent` plus `TrialEntry.rekisterinumeroSnapshot` uniqueness; -- writes `TrialEntry.yksilointiAvain` as - `SKL:|REG:`; and -- upserts the entry in place before replacing its era and lisatieto rows. - -Manual result creation must use these existing identity semantics. This plan -does not add another database uniqueness constraint. - -## Authoritative matching upsert - -When a later Koiratietokanta payload has the same `TrialEvent.sklKoeId` and -normalized registration as a manually created result: - -- the existing manual `TrialEntry` is updated in place; -- a duplicate result is not created; -- incoming Koiratietokanta result, era, lisatieto, snapshot, and event data - become authoritative according to the existing ingestion mapper and upsert; -- `TrialEntry.lahde` changes to `KOIRATIETOKANTA_API`; and -- `dogId` is updated when the authoritative registration resolves through - `DogRegistration`. If it does not resolve, the current upsert behavior - preserves an existing dog link rather than clearing it. - -This is automatic matching, not a reconciliation UI or import redesign. - -## Gate R1: Manual result backend and schema foundation - -### Scope - -- Add `MANUAL_ADMIN` to the existing `TrialSourceTag` enum in an explicitly - approved Prisma migration. -- Add admin manual-result request/response contracts, a server use-case, a - focused transactional DB write, and a Server Action mutation path. -- Accept `trialEventId`, a registration number, and the existing entry, era, - and lisatieto write shape used by result editing. -- Normalize and validate registration using existing server-side registration - rules. -- Load the target event and require its `sklKoeId` for API-compatible manual - identity. -- Reject a second result with the same normalized registration in the event. -- Resolve `dogId` through `DogRegistration`; save an unlinked entry when no - local dog owns the normalized registration. -- Store `lahde=MANUAL_ADMIN`, the normalized - `rekisterinumeroSnapshot`, and the API-compatible `yksilointiAvain`. -- Create the entry, eras, and lisatiedot atomically. -- Share validation and normalization with result update where doing so avoids - divergent business rules, while keeping create and update as separate - use-cases and DB writes. -- Add a regression test for the authoritative matching upsert described above. - -### Exclusions - -- No result-create UI or navigation changes. +This plan defines the independently mergeable schema and backend foundation +for one-at-a-time manual `TrialEntry` creation. It is implemented from the +BEJ-103 integration branch after the event workspace and empty-event lifecycle +defined in [Event creation](./event-creation.md) are complete. + +This gate does not expose manual result creation in the admin UI. Cross-slice +decisions and review rules remain in the [planning overview](./README.md). + +## Canonical registration and identity + +Manual creation and Koiratietokanta API ingestion must use the same shared +registration normalization, validation, and trial-entry identity helpers. +Registration is validated and normalized before it is logged, used for a +database lookup, checked for duplicates, or included in an identity key. Raw +registration input must not be logged or used for identity generation, +duplicate detection, or registration lookup. Only the validated canonical +value may be used in database operations. + +The canonical manual/API identity is: + +`SKL:|REG:` + +The following invariants apply: + +- Identity generation depends only on the event's `sklKoeId` and canonical + registration. It does not depend on whether `DogRegistration` resolves. +- `TrialEntry.rekisterinumeroSnapshot` stores the canonical registration for + manual and Koiratietokanta API writes. +- Linking an initially unlinked entry to a dog later changes `dogId`, not the + registration snapshot or identity key. +- The same canonical identity is used whether the result was first created + manually or first received from the Koiratietokanta API. + +Source tags remain distinct: + +- the one-shot legacy bootstrap writes `LEGACY_AKOEALL`; +- manual admin creation writes `MANUAL_ADMIN`; and +- Koiratietokanta API ingestion writes `KOIRATIETOKANTA_API`. + +## Duplicate guarantee + +Creation must reject an entry when the selected event already contains the +same canonical registration, regardless of input formatting, dog linkage, or +whether the existing row is manual or from the Koiratietokanta API. + +The existing schema provides both required database guarantees: + +- `TrialEntry.yksilointiAvain` is globally unique; and +- `(TrialEntry.trialEventId, TrialEntry.rekisterinumeroSnapshot)` is unique. + +No new uniqueness constraint is added in R1. The DB write may check both +identities inside its transaction to return an intentional conflict early, but +concurrent creation must also be protected by the existing constraints. A +Prisma `P2002` targeting either trial-entry identity constraint maps to the +stable `TRIAL_ENTRY_REGISTRATION_CONFLICT` result. Other uniqueness failures +remain unexpected persistence errors. + +## DogRegistration resolution + +The DB lookup uses only the canonical registration. The current schema +guarantees `DogRegistration.registrationNo @unique`, so at most one dog can +match and the write must use `findUnique`; it must never select the first row +from a non-unique result set. + +- One match links its `dogId` to the new entry. +- No match creates a valid unlinked entry with `dogId=null`. +- Multiple matches are not a runtime branch because the database constraint + prevents that state. A uniqueness failure while maintaining registrations + is an internal invariant failure, not a manual-result conflict. + +## Authoritative Koiratietokanta replacement + +When a later Koiratietokanta API upsert matches a manual entry by canonical +identity, it updates the existing `TrialEntry` in place and does not create a +second entry. + +The replacement boundary is: + +- Preserve the existing entry `id`, `trialEventId`, canonical registration + snapshot, `yksilointiAvain`, and database-row `createdAt`; `updatedAt` + changes normally. Incoming API creation or modification timestamps, when + available, belong only in their designated source metadata and must not + replace the row creation timestamp. +- Replace all entry-level result, snapshot, judge, owner, status, score, and + other business fields with an explicit complete replacement object produced + from the Koiratietokanta mapper. Do not spread partial API values over the + existing row because absent properties could retain stale manual data. +- Explicitly clear fields that can be entered manually but have no + Koiratietokanta entry-level value, rather than retaining stale manual data. + The current known case is `TrialEntry.tja`. +- Set `lahde` from `MANUAL_ADMIN` to `KOIRATIETOKANTA_API` and replace + source-specific raw metadata with the incoming API payload. +- Delete all existing eras and create the authoritative API eras. This also + removes manual-only era values, including `tja` and `pin` when the API mapper + has no corresponding era value. +- Replace all lisatiedot through the recreated eras; no manual lisatieto row + survives unless it is also present in the API mapping. +- If canonical registration resolves through `DogRegistration`, set `dogId` + to that dog. If it does not resolve, preserve an existing dog link rather + than clearing it; an already unlinked entry remains unlinked. +- Keep the existing Koiratietokanta event upsert authoritative for its mapped + `TrialEvent` fields without changing the stable entry identity. + +The regression fixture must begin with values in every manually writable +field. It must assert this complete boundary by proving that each value is +replaced by the API mapping or explicitly cleared, including preserved +technical identity, nullable snapshots, entry-level `tja`, era/lisatieto +replacement, dog-link behavior, source tag, and raw metadata. + +## Shared validation boundary + +Create and update remain separate use-cases and separate DB writes. R1 may +extract reusable validation and normalization primitives for their shared +entry, era, and lisatieto shapes, but it must not introduce a generic “save +result” orchestration function. + +The following remain owned separately by create and update: + +- authorization and request contracts; +- event and entry lookup; +- registration normalization and duplicate rules; +- persistence orchestration and transaction ownership; +- structured audit logging; and +- response shape and service status mapping. + +Existing update behavior and its current error codes remain unchanged unless a +shared primitive can be adopted without changing that use-case's contract. + +## Transaction ownership + +The focused manual-result DB creation operation owns the transaction. The same +Prisma transaction client must perform, in order: + +1. load the selected `TrialEvent`; +2. require its `sklKoeId`; +3. generate and check the canonical identity from the already validated + canonical registration; +4. resolve `DogRegistration` by canonical registration; +5. create the `TrialEntry`; +6. create validated continuous eras starting from 1; +7. create each era's lisatiedot; and +8. perform any explicit related persistence that must be atomic; existing + database audit triggers execute within the same transaction. + +Any failure rolls back the complete write. Existing database audit triggers run +inside this transaction. Service-level `pino` logging remains outside the DB +transaction and records normalized identifiers only. + +The DB layer returns typed outcomes such as created, event missing, event +missing SKL ID, and registration conflict. It maps only relevant identity +constraint violations to the conflict outcome so a concurrency race cannot +leak as an unexpected error or create a duplicate. + +## Stable errors and layer ownership + +R1 defines stable codes for later R2 localization and mutation handling. R2 +must not inspect backend message strings. + +| Status | Code | Meaning | +| ------ | ----------------------------------- | ------------------------------------------------------------------------ | +| 400 | `INVALID_TRIAL_EVENT_ID` | The event identifier is empty or invalid. | +| 400 | `INVALID_REGISTRATION_NUMBER` | Registration is missing or fails canonical validation. | +| 400 | `INVALID_TRIAL_ENTRY` | Entry-level fields are invalid. | +| 400 | `INVALID_TRIAL_ERAS` | Eras are missing, non-continuous, duplicated, or contain invalid values. | +| 400 | `INVALID_TRIAL_ADDITIONAL_INFO` | Lisatieto codes, era references, or values are invalid. | +| 404 | `TRIAL_EVENT_NOT_FOUND` | The selected event does not exist. | +| 409 | `TRIAL_EVENT_MISSING_SKL_ID` | The event cannot produce API-compatible identity. | +| 409 | `TRIAL_ENTRY_REGISTRATION_CONFLICT` | Canonical registration already exists in the event. | + +Existing `UNAUTHENTICATED` and `FORBIDDEN` authorization codes remain in use. + +- Shared validators return typed validation issue categories and normalized + values; they do not select HTTP statuses. +- The create service selects the stable code and HTTP status, maps typed DB + outcomes, and owns structured success/failure logging. +- The Server Action performs its existing admin/session guard and passes the + service code through as `errorCode` without interpreting message text. + +## Date-only invariant + +`TrialEvent.koepaiva` is a calendar date with no timezone or time-of-day +semantics throughout contracts, services, database queries, filters, year +derivation, and serialization. + +- Prisma declares it as `DateTime @db.Date`, and PostgreSQL stores it as + `DATE`. +- Existing public and internal `YYYY-MM-DD` contracts remain unchanged. +- Application code must use timezone-free date-only parsing and serialization; + it must not derive the event date through Helsinki or runtime timezone + conversion. +- Year filters operate directly on `[YYYY-01-01, nextYear-01-01)` date-only + boundaries. +- User-facing inclusive ranges operate directly on + `[dateFrom, dayAfter(dateTo))` date-only boundaries. +- Rule-window resolution continues to use the date's UTC year/month/day carrier + solely as the Prisma representation of the calendar date. + +## Gate R1 scope + +- Add `MANUAL_ADMIN` to `TrialSourceTag`. +- Change `TrialEvent.koepaiva` to PostgreSQL `DATE` and update all trial date + handling to the date-only invariant. +- Add manual-result request/response contracts, a create service, the focused + transactional DB write, and the admin Server Action transport. +- Accept `trialEventId`, registration, and the existing entry, era, and + lisatieto write shape used by result editing. +- Store `lahde=MANUAL_ADMIN`, canonical `rekisterinumeroSnapshot`, and the + canonical API-compatible identity. +- Add the full backend and authoritative-upsert regression coverage described + by this plan. +- Update the nearest schema, integration, and admin-trial developer + documentation changed by R1. + +### Bootstrap migration rule + +The original canonical trial migration may be edited in place because these +tables are still recreated through the documented one-shot bootstrap and +forward migrations for this schema are not supported. The original migration +must create `TrialSourceTag` with `MANUAL_ADMIN` and create `koepaiva` as +`DATE`; no conversion or follow-up migration is added. + +Adding `MANUAL_ADMIN` here only declares an allowed enum value in fresh schema +DDL. It does not assign that source to legacy rows: the one-shot legacy +projection continues to write `LEGACY_AKOEALL` explicitly. + +No preserved environment may consume the edited migration incrementally. +Implementation and validation must not: + +- run `prisma migrate reset`; +- run any other Prisma migration, deploy, resolve, status, `db push`, or client + generation command; +- reset, recreate, or modify any local or remote database automatically; +- attempt to resolve migration checksum drift automatically; or +- run a fresh bootstrap without separate explicit approval. + +The user owns all Prisma migration, database, bootstrap, and client-generation +commands. If the unchanged generated client prevents a local type check, report +that limitation instead of regenerating it. + +## Explicit exclusions + +- No result-creation page or route. +- No React components or forms. +- No React Query mutation hook. +- No navigation or event-create continuation changes. +- No dirty-state handling or UI localization. +- No user-facing changelog entry. +- No manual browser testing for an unavailable UI. - No existing result-editing redesign. - No batch creation, autosave, draft/publish state, or reconciliation UI. -- No new ingestion endpoint or change to the external Koiratietokanta - contract. -- No new database uniqueness constraint. The only planned schema change in - this gate is the new source enum value. - -### Dependencies - -- E2 must be approved and merged so the target event and empty-event lifecycle - exist. -- The `MANUAL_ADMIN` migration requires explicit schema-change approval before - implementation. -- Existing registration normalization, admin authorization, structured - logging, entry validation, and transaction conventions must be reused. - -### Acceptance criteria - -- An authorized call creates exactly one manual entry with its complete nested - writes or creates nothing if the transaction fails. -- A matching local dog is linked; an unknown registration remains a valid - unlinked result. -- Invalid registration, missing event, event without an SKL ID, duplicate - event registration, and invalid entry/era/lisatieto data return stable errors. -- Manual source and identity fields match the documented API-compatible - semantics. -- A later matching Koiratietokanta upsert updates the same entry without a - duplicate and makes the incoming data authoritative. - -### Targeted validation - -- Migration/schema checks for the new enum value and regenerated Prisma client. -- DB tests for linked and unlinked creation, identity fields, duplicate - rejection, nested writes, and rollback. -- Server tests for authorization, registration normalization, event - validation, entry/era/lisatieto validation, conflict mapping, and logging. -- Server Action tests for authentication and service-result mapping. -- Existing Koiratietokanta DB/service tests plus a manual-to-API replacement - regression case. -- Targeted type checking and linting for contracts, server, DB, and web action - code; do not run cycle lint in the agent workflow. - -### Merge independence and review gate - -R1 can be merged safely after E2 without R2. The write path has no linked admin -UI yet, so it does not expose an unfinished user workflow. Its migration and -backend behavior must nevertheless be complete, documented, and validated. - -Stop after validation and request explicit review. Do not begin R2 without -separate approval. - -## Gate R2: Full-page manual result form - -### 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. -- Preserve entered values after server or validation failure and prevent - duplicate submissions while a save is pending. -- Add two successful submission paths: - - **Save and add another** creates one result, then opens a clean result form - for the same event. - - **Save and finish** creates one result, then 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. - - Cancel asks for confirmation before discarding a dirty form. - - Browser back follows the same dirty-form protection and otherwise returns - to the event workspace. - - A failed or rejected save stays on the populated form. -- Add Finnish and Swedish labels, helper text, errors, and success feedback. -- Keep existing result editing in its current modal. - -### 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 redesign of the existing trials master-detail list. -- No public UI or trial-statistics changes. - -### Dependencies - -- E1 and E2 must be merged for the workspace and persisted event lifecycle. -- R1 must be merged for the manual-result action and persistence contract. -- Existing entry form sections and form-model behavior are the reuse baseline; - any extraction must preserve the current edit-modal behavior. - -### Acceptance criteria - -- Successful event creation continues to the first result form for that exact - event. -- An admin can save a result for a known or unknown registration. -- Save and add another clears dog-specific state while retaining the event - context and produces no duplicate submission. -- Save and finish returns to the event workspace where the new result appears. -- Cancel and browser back protect dirty input and return to the correct event - when confirmed. -- Validation and server errors preserve entered data and provide actionable - localized feedback. -- The existing result-edit modal behaves as before. - -### Targeted validation - -- Component/form-model tests for initial state, validation, dirty state, - reset, and localized feedback. -- Mutation tests for success, failure, query invalidation, and duplicate-submit - protection. -- Route/workflow tests for event-create continuation, add-another, finish, - cancel, and browser-back handling. -- 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 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. +- No new ingestion endpoint or external Koiratietokanta contract change. +- No unrelated refactoring. + +The admin Server Action and its mapping tests are the only web-layer additions +permitted in R1. + +## Acceptance criteria + +- An authorized call creates exactly one complete manual entry or creates + nothing when any nested write fails. +- Canonically equivalent registration inputs conflict within the event for + linked, unlinked, manual, and Koiratietokanta API existing entries. +- Existing database uniqueness protects concurrent creation, and relevant + constraint failures return `TRIAL_ENTRY_REGISTRATION_CONFLICT`. +- A uniquely matching `DogRegistration` links the dog; no match creates an + unlinked entry without changing canonical identity. +- Source, registration snapshot, and identity match the documented invariants. +- All stable validation, not-found, missing-SKL, conflict, authorization, and + internal-error mappings are covered without relying on message strings. +- A later matching Koiratietokanta upsert satisfies the complete authoritative + replacement boundary. +- All trial event date behavior satisfies the date-only invariant without + changing existing `YYYY-MM-DD` contracts. + +## Targeted validation + +- Static Prisma-schema and SQL inspection for `MANUAL_ADMIN` and `@db.Date`, + proving the original bootstrap SQL creates the enum value and `DATE` + directly. Do not generate the Prisma client or apply the schema. +- Date tests in non-UTC runtime timezones with positive and negative offsets, + values around midnight, inclusive start/end dates, December 31/January 1, + and year filters without timezone drift. +- DB tests for linked and unlinked creation, identity fields, all duplicate + variants, nested writes, rollback, and typed transaction outcomes. +- A database-constraint-level or concurrent-create test proving the existing + uniqueness constraints—not only a read-before-write check—prevent duplicate + creation. This test must use an explicitly prepared test database and must + not reset one automatically. +- Server tests for authorization, canonical normalization, event validation, + stable error selection, entry/era/lisatieto validation, conflict mapping, + and normalized structured logging. +- Server Action tests for authentication and transparent service-result/error + mapping. +- Koiratietokanta DB/service regression tests for the complete manual-to-API + replacement boundary. +- Targeted type checking and linting for contracts, DB, server, and the web + Server Action; do not run cycle lint in the agent workflow. + +## Merge independence and review gate + +R1 can merge independently from the BEJ-103 integration branch after E2. Its +write path has no linked admin UI, and its schema, transaction, identity, +errors, date-only behavior, and authoritative-upsert contract are complete +without R2. + +Stop after validation and request explicit review. R2 must be planned and +approved separately without reopening the backend decisions finalized here. From 248cae88078953a1bcfef33cdbc52ec0290d91e6 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:01:55 +0300 Subject: [PATCH 2/5] fix r1 and r2 planning ref bej-103 --- docs/planning/trials/result-creation-r2.md | 9 ++++++--- docs/planning/trials/result-creation.md | 10 +++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/planning/trials/result-creation-r2.md b/docs/planning/trials/result-creation-r2.md index 5d5ddfa0..eca74759 100644 --- a/docs/planning/trials/result-creation-r2.md +++ b/docs/planning/trials/result-creation-r2.md @@ -54,9 +54,12 @@ backend message strings to determine behavior. action opens the first result form for the newly persisted event. - Define cancel and browser navigation: - Cancel returns to the event workspace. - - Dirty cancel and browser-back navigation use a localized native - confirmation before discarding values. - - Refresh and tab/window close use browser-native unsaved-change protection. + - 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 diff --git a/docs/planning/trials/result-creation.md b/docs/planning/trials/result-creation.md index 44e30042..1e4ad78a 100644 --- a/docs/planning/trials/result-creation.md +++ b/docs/planning/trials/result-creation.md @@ -231,15 +231,15 @@ No preserved environment may consume the edited migration incrementally. Implementation and validation must not: - run `prisma migrate reset`; -- run any other Prisma migration, deploy, resolve, status, `db push`, or client - generation command; +- run any other Prisma migration, deploy, resolve, status, or `db push` + command; - reset, recreate, or modify any local or remote database automatically; - attempt to resolve migration checksum drift automatically; or - run a fresh bootstrap without separate explicit approval. -The user owns all Prisma migration, database, bootstrap, and client-generation -commands. If the unchanged generated client prevents a local type check, report -that limitation instead of regenerating it. +The user owns all Prisma migration, database, and bootstrap commands. Prisma +client generation is allowed when required by the approved schema changes and +must not connect to or modify a database. ## Explicit exclusions From b4d8a63849cf7fa9b86f65e4155fabed7ff651a0 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:28:44 +0300 Subject: [PATCH 3/5] feat(trials): add manual admin result creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add contracts, server action, service, and atomic persistence for manual trial entries - share normalized entry validation between create and update flows - reject duplicate registrations, lisätieto rows, and per-era lisätieto values - link manually created results to known dog registrations - allow API upserts to replace matching manual results authoritatively - store trial event dates as PostgreSQL DATE values - add MANUAL_ADMIN trial source tagging and update trial documentation - cover creation, validation, identity, date-only, and API overwrite behavior ref bej-103 --- apps/web/app/actions/admin/trials/index.ts | 1 + .../create-admin-trial-entry.test.ts | 56 +++ .../trials/manage/create-admin-trial-entry.ts | 53 +++ .../app/actions/admin/trials/manage/index.ts | 1 + docs/features/admin-trial-management.md | 12 + docs/features/schema/schema.md | 3 +- .../trials/koiratietokanta-api-ajok-upsert.md | 7 + .../trials/manage/admin-trial-entry-write.ts | 55 +++ .../trials/manage/create-admin-trial-entry.ts | 11 + .../contracts/admin/trials/manage/index.ts | 10 + .../trials/manage/update-admin-trial-entry.ts | 55 +-- packages/contracts/index.ts | 6 + .../__tests__/create-trial-entry.test.ts | 160 ++++++++ .../admin/trials/manage/create-trial-entry.ts | 160 ++++++++ packages/db/admin/trials/manage/index.ts | 6 + .../admin/trials/manage/trial-entry-write.ts | 60 +++ .../admin/trials/manage/update-trial-entry.ts | 61 +-- packages/db/index.ts | 5 + .../migration.sql | 4 +- .../migration.sql | 4 +- packages/db/prisma/schema.prisma | 7 +- .../db/trials/core/trial-entry-identity.ts | 6 + packages/db/trials/index.ts | 1 + .../__tests__/upsert-ajok-result.test.ts | 46 +++ .../koiratietokanta/upsert-ajok-result.ts | 3 + packages/server/admin/index.ts | 1 + .../__tests__/create-trial-entry.test.ts | 172 ++++++++ .../manage/__tests__/list-trials.test.ts | 4 +- ...rse-admin-trial-event-search-input.test.ts | 6 +- ...resolve-admin-trial-event-defaults.test.ts | 4 +- .../__tests__/update-trial-entry.test.ts | 59 +++ .../admin/trials/manage/create-trial-entry.ts | 174 ++++++++ .../admin/trials/manage/get-trial-event.ts | 4 +- packages/server/admin/trials/manage/index.ts | 1 + ...arse-admin-trial-entry-write-input.test.ts | 170 ++++++++ .../map-admin-trial-event-search-response.ts | 10 +- .../parse-admin-trial-entry-write-input.ts | 247 +++++++++++ .../parse-admin-trial-event-search-input.ts | 7 +- .../resolve-admin-trial-event-defaults.ts | 12 +- .../admin/trials/manage/update-trial-entry.ts | 387 ++++-------------- packages/server/index.ts | 1 + .../server/trials/__tests__/service.test.ts | 10 +- .../__tests__/trial-entry-identity.test.ts | 35 ++ packages/server/trials/core/business-date.ts | 164 -------- packages/server/trials/core/date-only.ts | 39 ++ packages/server/trials/core/index.ts | 6 +- .../trials/core/trial-entry-identity.ts | 14 + .../server/trials/get-beagle-trial-details.ts | 4 +- .../__tests__/upsert-ajok-result.test.ts | 2 + .../internal/map-ajok-payload.ts | 24 +- .../internal/__tests__/business-date.test.ts | 38 -- .../internal/__tests__/date-only.test.ts | 38 ++ .../server/trials/search-beagle-trials.ts | 23 +- 53 files changed, 1774 insertions(+), 675 deletions(-) create mode 100644 apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-entry.test.ts create mode 100644 apps/web/app/actions/admin/trials/manage/create-admin-trial-entry.ts create mode 100644 packages/contracts/admin/trials/manage/admin-trial-entry-write.ts create mode 100644 packages/contracts/admin/trials/manage/create-admin-trial-entry.ts create mode 100644 packages/db/admin/trials/manage/__tests__/create-trial-entry.test.ts create mode 100644 packages/db/admin/trials/manage/create-trial-entry.ts create mode 100644 packages/db/admin/trials/manage/trial-entry-write.ts create mode 100644 packages/db/trials/core/trial-entry-identity.ts create mode 100644 packages/server/admin/trials/manage/__tests__/create-trial-entry.test.ts create mode 100644 packages/server/admin/trials/manage/create-trial-entry.ts create mode 100644 packages/server/admin/trials/manage/internal/__tests__/parse-admin-trial-entry-write-input.test.ts create mode 100644 packages/server/admin/trials/manage/internal/parse-admin-trial-entry-write-input.ts create mode 100644 packages/server/trials/core/__tests__/trial-entry-identity.test.ts delete mode 100644 packages/server/trials/core/business-date.ts create mode 100644 packages/server/trials/core/date-only.ts create mode 100644 packages/server/trials/core/trial-entry-identity.ts delete mode 100644 packages/server/trials/internal/__tests__/business-date.test.ts create mode 100644 packages/server/trials/internal/__tests__/date-only.test.ts diff --git a/apps/web/app/actions/admin/trials/index.ts b/apps/web/app/actions/admin/trials/index.ts index f4a9801b..75427cf5 100644 --- a/apps/web/app/actions/admin/trials/index.ts +++ b/apps/web/app/actions/admin/trials/index.ts @@ -1,3 +1,4 @@ export { deleteAdminTrialEntryAction } from "./manage"; export { updateAdminTrialEventAction } from "./manage"; export { updateAdminTrialEntryAction } from "./manage"; +export { createAdminTrialEntryAction } from "./manage"; diff --git a/apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-entry.test.ts b/apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-entry.test.ts new file mode 100644 index 00000000..10c2d43e --- /dev/null +++ b/apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-entry.test.ts @@ -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", + }); + }); +}); diff --git a/apps/web/app/actions/admin/trials/manage/create-admin-trial-entry.ts b/apps/web/app/actions/admin/trials/manage/create-admin-trial-entry.ts new file mode 100644 index 00000000..2c46291a --- /dev/null +++ b/apps/web/app/actions/admin/trials/manage/create-admin-trial-entry.ts @@ -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 { + 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 }; +} diff --git a/apps/web/app/actions/admin/trials/manage/index.ts b/apps/web/app/actions/admin/trials/manage/index.ts index d31db353..7732dfce 100644 --- a/apps/web/app/actions/admin/trials/manage/index.ts +++ b/apps/web/app/actions/admin/trials/manage/index.ts @@ -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"; diff --git a/docs/features/admin-trial-management.md b/docs/features/admin-trial-management.md index bb4f006d..f2f0c831 100644 --- a/docs/features/admin-trial-management.md +++ b/docs/features/admin-trial-management.md @@ -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 @@ -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 @@ -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:|REG:` 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. diff --git a/docs/features/schema/schema.md b/docs/features/schema/schema.md index f1b0ff30..f3bd3eab 100644 --- a/docs/features/schema/schema.md +++ b/docs/features/schema/schema.md @@ -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` @@ -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 diff --git a/docs/features/trials/koiratietokanta-api-ajok-upsert.md b/docs/features/trials/koiratietokanta-api-ajok-upsert.md index 34df5734..b0e6e80b 100644 --- a/docs/features/trials/koiratietokanta-api-ajok-upsert.md +++ b/docs/features/trials/koiratietokanta-api-ajok-upsert.md @@ -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 diff --git a/packages/contracts/admin/trials/manage/admin-trial-entry-write.ts b/packages/contracts/admin/trials/manage/admin-trial-entry-write.ts new file mode 100644 index 00000000..031d15f0 --- /dev/null +++ b/packages/contracts/admin/trials/manage/admin-trial-entry-write.ts @@ -0,0 +1,55 @@ +import type { + AdminTrialEntryEra, + AdminTrialEventEntry, +} from "./admin-trial-event-details"; + +export type AdminTrialEntryLisatietoWriteRow = { + koodi: string; + osa: string; + nimi: string | null; + jarjestys: number | null; + eraValues: Array<{ + era: number; + arvo: string | null; + }>; +}; + +export type AdminTrialEntryWriteFields = Pick< + AdminTrialEventEntry, + | "koemaasto" + | "koemuoto" + | "koetyyppi" + | "ke" + | "lk" + | "award" + | "rank" + | "points" + | "koiriaLuokassa" + | "hyvaksytytAjominuutit" + | "ajoajanPisteet" + | "haku" + | "hauk" + | "yva" + | "hlo" + | "alo" + | "tja" + | "pin" + | "ansiopisteetYhteensa" + | "tappiopisteetYhteensa" + | "judge" + | "huomautus" + | "huomautusTeksti" + | "ylituomariNumeroSnapshot" + | "ryhmatuomariNimi" + | "palkintotuomariNimi" + | "omistajaSnapshot" + | "omistajanKotikuntaSnapshot" +>; + +export type AdminTrialEntryEraWrite = Omit; + +export type AdminTrialEntryWriteData = { + entry: AdminTrialEntryWriteFields; + eras: AdminTrialEntryEraWrite[]; + lisatiedotRows: AdminTrialEntryLisatietoWriteRow[]; +}; diff --git a/packages/contracts/admin/trials/manage/create-admin-trial-entry.ts b/packages/contracts/admin/trials/manage/create-admin-trial-entry.ts new file mode 100644 index 00000000..a527e907 --- /dev/null +++ b/packages/contracts/admin/trials/manage/create-admin-trial-entry.ts @@ -0,0 +1,11 @@ +import type { AdminTrialEntryWriteData } from "./admin-trial-entry-write"; + +export type CreateAdminTrialEntryRequest = AdminTrialEntryWriteData & { + trialEventId: string; + registrationNo: string; +}; + +export type CreateAdminTrialEntryResponse = { + trialEventId: string; + trialEntryId: string; +}; diff --git a/packages/contracts/admin/trials/manage/index.ts b/packages/contracts/admin/trials/manage/index.ts index b335d5b6..75b239c3 100644 --- a/packages/contracts/admin/trials/manage/index.ts +++ b/packages/contracts/admin/trials/manage/index.ts @@ -1,3 +1,9 @@ +export type { + AdminTrialEntryEraWrite, + AdminTrialEntryLisatietoWriteRow, + AdminTrialEntryWriteData, + AdminTrialEntryWriteFields, +} from "./admin-trial-entry-write"; export type { AdminTrialEventSearchFilters, AdminTrialEventSearchMode, @@ -16,6 +22,10 @@ export type { CreateAdminTrialEventRequest, CreateAdminTrialEventResponse, } from "./create-admin-trial-event"; +export type { + CreateAdminTrialEntryRequest, + CreateAdminTrialEntryResponse, +} from "./create-admin-trial-entry"; export type { DeleteAdminTrialEventRequest, DeleteAdminTrialEventResponse, diff --git a/packages/contracts/admin/trials/manage/update-admin-trial-entry.ts b/packages/contracts/admin/trials/manage/update-admin-trial-entry.ts index cb59b61e..838160f4 100644 --- a/packages/contracts/admin/trials/manage/update-admin-trial-entry.ts +++ b/packages/contracts/admin/trials/manage/update-admin-trial-entry.ts @@ -1,57 +1,14 @@ -import type { - AdminTrialEntryEra, - AdminTrialEventEntry, -} from "./admin-trial-event-details"; +import type { AdminTrialEntryWriteData } from "./admin-trial-entry-write"; -export type UpdateAdminTrialEntryLisatietoRow = { - koodi: string; - osa: string; - nimi: string | null; - jarjestys: number | null; - eraValues: Array<{ - era: number; - arvo: string | null; - }>; -}; - -export type UpdateAdminTrialEntryRequest = { +export type UpdateAdminTrialEntryRequest = AdminTrialEntryWriteData & { trialEventId: string; trialEntryId: string; - entry: Pick< - AdminTrialEventEntry, - | "koemaasto" - | "koemuoto" - | "koetyyppi" - | "ke" - | "lk" - | "award" - | "rank" - | "points" - | "koiriaLuokassa" - | "hyvaksytytAjominuutit" - | "ajoajanPisteet" - | "haku" - | "hauk" - | "yva" - | "hlo" - | "alo" - | "tja" - | "pin" - | "ansiopisteetYhteensa" - | "tappiopisteetYhteensa" - | "judge" - | "huomautus" - | "huomautusTeksti" - | "ylituomariNumeroSnapshot" - | "ryhmatuomariNimi" - | "palkintotuomariNimi" - | "omistajaSnapshot" - | "omistajanKotikuntaSnapshot" - >; - eras: Array>; - lisatiedotRows: UpdateAdminTrialEntryLisatietoRow[]; }; +/** @deprecated Use AdminTrialEntryLisatietoWriteRow. */ +export type UpdateAdminTrialEntryLisatietoRow = + AdminTrialEntryWriteData["lisatiedotRows"][number]; + export type UpdateAdminTrialEntryResponse = { trialEventId: string; trialEntryId: string; diff --git a/packages/contracts/index.ts b/packages/contracts/index.ts index 2c61a7ef..01876e47 100644 --- a/packages/contracts/index.ts +++ b/packages/contracts/index.ts @@ -292,8 +292,14 @@ export type { AdminTrialEventDetailsRequest, AdminTrialEventDetailsResponse, AdminTrialEventEntry, + AdminTrialEntryEraWrite, + AdminTrialEntryLisatietoWriteRow, + AdminTrialEntryWriteData, + AdminTrialEntryWriteFields, CreateAdminTrialEventRequest, CreateAdminTrialEventResponse, + CreateAdminTrialEntryRequest, + CreateAdminTrialEntryResponse, DeleteAdminTrialEventRequest, DeleteAdminTrialEventResponse, DeleteAdminTrialEntryRequest, diff --git a/packages/db/admin/trials/manage/__tests__/create-trial-entry.test.ts b/packages/db/admin/trials/manage/__tests__/create-trial-entry.test.ts new file mode 100644 index 00000000..2aa021e8 --- /dev/null +++ b/packages/db/admin/trials/manage/__tests__/create-trial-entry.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Prisma } from "@prisma/client"; +import { createAdminTrialEntryWriteDb } from "../create-trial-entry"; + +const { prismaTransaction, tx } = vi.hoisted(() => { + const transaction = { + trialEvent: { findUnique: vi.fn() }, + trialEntry: { findFirst: vi.fn(), create: vi.fn() }, + dogRegistration: { findUnique: vi.fn() }, + trialEra: { create: vi.fn() }, + trialEraLisatieto: { createMany: vi.fn() }, + }; + return { + tx: transaction, + prismaTransaction: vi.fn((callback) => callback(transaction)), + }; +}); +vi.mock("@db/core/prisma", () => ({ + prisma: { $transaction: prismaTransaction }, +})); + +const entry = { + koemaasto: null, + koemuoto: "AJOK", + koetyyppi: "NORMAL" as const, + ke: null, + lk: null, + award: null, + rank: null, + points: null, + koiriaLuokassa: null, + hyvaksytytAjominuutit: null, + ajoajanPisteet: null, + haku: null, + hauk: null, + yva: null, + hlo: null, + alo: null, + tja: null, + pin: null, + ansiopisteetYhteensa: null, + tappiopisteetYhteensa: null, + judge: null, + huomautus: null, + huomautusTeksti: null, + ylituomariNumeroSnapshot: null, + ryhmatuomariNimi: null, + palkintotuomariNimi: null, + omistajaSnapshot: null, + omistajanKotikuntaSnapshot: null, +}; +const input = { + trialEventId: "event-1", + canonicalRegistrationNo: "FI12345/21", + entry, + eras: [ + { + era: 1, + alkoi: null, + hakumin: null, + ajomin: null, + haku: null, + hauk: null, + yva: null, + hlo: null, + alo: null, + tja: null, + pin: null, + huomautusTeksti: null, + }, + ], + lisatiedotByEra: [ + { + era: 1, + replaceKeys: [], + items: [ + { koodi: "11", osa: "", arvo: "1", nimi: "Paljas maa", jarjestys: 1 }, + ], + }, + ], +}; + +describe("createAdminTrialEntryWriteDb", () => { + beforeEach(() => { + vi.clearAllMocks(); + prismaTransaction.mockImplementation((callback) => callback(tx)); + tx.trialEvent.findUnique.mockResolvedValue({ + id: "event-1", + sklKoeId: 123, + }); + tx.trialEntry.findFirst.mockResolvedValue(null); + tx.dogRegistration.findUnique.mockResolvedValue({ dogId: "dog-1" }); + tx.trialEntry.create.mockResolvedValue({ id: "entry-1" }); + tx.trialEra.create.mockResolvedValue({ id: "era-1", era: 1 }); + }); + + it("creates a linked manual entry with canonical identity and nested rows", async () => { + await expect(createAdminTrialEntryWriteDb(input)).resolves.toEqual({ + status: "created", + trialEventId: "event-1", + trialEntryId: "entry-1", + }); + expect(tx.dogRegistration.findUnique).toHaveBeenCalledWith({ + where: { registrationNo: "FI12345/21" }, + select: { dogId: true }, + }); + expect(tx.trialEntry.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + dogId: "dog-1", + rekisterinumeroSnapshot: "FI12345/21", + yksilointiAvain: "SKL:123|REG:FI12345/21", + lahde: "MANUAL_ADMIN", + }), + }), + ); + expect(tx.trialEraLisatieto.createMany).toHaveBeenCalledOnce(); + }); + + it("permits an unlinked result", async () => { + tx.dogRegistration.findUnique.mockResolvedValue(null); + await createAdminTrialEntryWriteDb(input); + expect(tx.trialEntry.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ dogId: null }), + }), + ); + }); + + it("returns typed event and duplicate outcomes before writing", async () => { + tx.trialEvent.findUnique.mockResolvedValueOnce(null); + await expect(createAdminTrialEntryWriteDb(input)).resolves.toEqual({ + status: "event_not_found", + }); + tx.trialEvent.findUnique.mockResolvedValueOnce({ + id: "event-1", + sklKoeId: null, + }); + await expect(createAdminTrialEntryWriteDb(input)).resolves.toEqual({ + status: "event_missing_skl_id", + }); + tx.trialEntry.findFirst.mockResolvedValueOnce({ id: "existing" }); + await expect(createAdminTrialEntryWriteDb(input)).resolves.toEqual({ + status: "registration_conflict", + }); + }); + + it("maps a database identity constraint race to registration conflict", async () => { + prismaTransaction.mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("duplicate", { + code: "P2002", + clientVersion: "test", + meta: { target: ["trialEventId", "rekisterinumeroSnapshot"] }, + }), + ); + await expect(createAdminTrialEntryWriteDb(input)).resolves.toEqual({ + status: "registration_conflict", + }); + }); +}); diff --git a/packages/db/admin/trials/manage/create-trial-entry.ts b/packages/db/admin/trials/manage/create-trial-entry.ts new file mode 100644 index 00000000..2b6180ed --- /dev/null +++ b/packages/db/admin/trials/manage/create-trial-entry.ts @@ -0,0 +1,160 @@ +import { ADMIN_WRITE_TX_CONFIG } from "@db/core/interactive-write-transaction"; +import { prisma } from "@db/core/prisma"; +import { Prisma, type TrialSourceTag } from "@prisma/client"; +import { buildTrialEntryIdentity } from "@db/trials/core/trial-entry-identity"; +import type { AdminTrialEntryWriteDataDb } from "./trial-entry-write"; + +export type CreateAdminTrialEntryWriteRequestDb = AdminTrialEntryWriteDataDb & { + trialEventId: string; + canonicalRegistrationNo: string; +}; + +export type CreateAdminTrialEntryWriteResultDb = + | { status: "event_not_found" } + | { status: "event_missing_skl_id" } + | { status: "registration_conflict" } + | { status: "created"; trialEventId: string; trialEntryId: string }; + +function decimal(value: number | null): Prisma.Decimal | null { + return value === null ? null : new Prisma.Decimal(value); +} + +function isEntryIdentityConflict(error: unknown): boolean { + if ( + !(error instanceof Prisma.PrismaClientKnownRequestError) || + error.code !== "P2002" + ) { + return false; + } + const target = error.meta?.target; + const fields = Array.isArray(target) + ? target.map(String) + : [String(target ?? "")]; + return fields.some( + (field) => + field.includes("yksilointiAvain") || + field.includes("trialEventId_rekisterinumeroSnapshot") || + field.includes("trialEventId") || + field.includes("rekisterinumeroSnapshot"), + ); +} + +// Creates one complete manual result and all nested rows atomically. +export async function createAdminTrialEntryWriteDb( + input: CreateAdminTrialEntryWriteRequestDb, +): Promise { + try { + return await prisma.$transaction(async (tx) => { + const event = await tx.trialEvent.findUnique({ + where: { id: input.trialEventId }, + select: { id: true, sklKoeId: true }, + }); + if (!event) return { status: "event_not_found" } as const; + if (event.sklKoeId === null) + return { status: "event_missing_skl_id" } as const; + const yksilointiAvain = buildTrialEntryIdentity( + event.sklKoeId, + input.canonicalRegistrationNo, + ); + + const duplicate = await tx.trialEntry.findFirst({ + where: { + OR: [ + { yksilointiAvain }, + { + trialEventId: event.id, + rekisterinumeroSnapshot: input.canonicalRegistrationNo, + }, + ], + }, + select: { id: true }, + }); + if (duplicate) return { status: "registration_conflict" } as const; + + const registration = await tx.dogRegistration.findUnique({ + where: { registrationNo: input.canonicalRegistrationNo }, + select: { dogId: true }, + }); + const entry = input.entry; + const created = await tx.trialEntry.create({ + data: { + trialEventId: event.id, + dogId: registration?.dogId ?? null, + yksilointiAvain, + lahde: "MANUAL_ADMIN" as TrialSourceTag, + rekisterinumeroSnapshot: input.canonicalRegistrationNo, + raakadataJson: null, + koemaasto: entry.koemaasto, + koemuoto: entry.koemuoto, + koetyyppi: entry.koetyyppi, + ke: entry.ke, + lk: entry.lk, + pa: entry.award, + sija: entry.rank, + piste: decimal(entry.points), + koiriaLuokassa: entry.koiriaLuokassa, + hyvaksytytAjominuutit: entry.hyvaksytytAjominuutit, + ajoajanPisteet: decimal(entry.ajoajanPisteet), + haku: decimal(entry.haku), + hauk: decimal(entry.hauk), + yva: decimal(entry.yva), + hlo: decimal(entry.hlo), + alo: decimal(entry.alo), + tja: decimal(entry.tja), + pin: decimal(entry.pin), + ansiopisteetYhteensa: decimal(entry.ansiopisteetYhteensa), + tappiopisteetYhteensa: decimal(entry.tappiopisteetYhteensa), + tuom1: entry.judge, + huomautus: entry.huomautus, + huomautusTeksti: entry.huomautusTeksti, + ylituomariNumeroSnapshot: entry.ylituomariNumeroSnapshot, + ryhmatuomariNimi: entry.ryhmatuomariNimi, + palkintotuomariNimi: entry.palkintotuomariNimi, + omistajaSnapshot: entry.omistajaSnapshot, + omistajanKotikuntaSnapshot: entry.omistajanKotikuntaSnapshot, + }, + select: { id: true }, + }); + + for (const eraWrite of input.eras) { + const era = await tx.trialEra.create({ + data: { + trialEntryId: created.id, + era: eraWrite.era, + alkoi: eraWrite.alkoi, + hakumin: eraWrite.hakumin, + ajomin: eraWrite.ajomin, + haku: decimal(eraWrite.haku), + hauk: decimal(eraWrite.hauk), + yva: decimal(eraWrite.yva), + hlo: decimal(eraWrite.hlo), + alo: decimal(eraWrite.alo), + tja: decimal(eraWrite.tja), + pin: decimal(eraWrite.pin), + huomautusTeksti: eraWrite.huomautusTeksti, + raakadataJson: null, + }, + select: { id: true, era: true }, + }); + const items = + input.lisatiedotByEra.find((item) => item.era === era.era)?.items ?? + []; + if (items.length) { + await tx.trialEraLisatieto.createMany({ + data: items.map((item) => ({ trialEraId: era.id, ...item })), + }); + } + } + + return { + status: "created", + trialEventId: event.id, + trialEntryId: created.id, + } as const; + }, ADMIN_WRITE_TX_CONFIG); + } catch (error) { + if (isEntryIdentityConflict(error)) + return { status: "registration_conflict" }; + throw error; + } +} diff --git a/packages/db/admin/trials/manage/index.ts b/packages/db/admin/trials/manage/index.ts index 0cfddbea..26983632 100644 --- a/packages/db/admin/trials/manage/index.ts +++ b/packages/db/admin/trials/manage/index.ts @@ -5,6 +5,11 @@ export { type CreateAdminTrialEventWriteRequestDb, type CreateAdminTrialEventWriteResultDb, } from "./create-trial-event"; +export { + createAdminTrialEntryWriteDb, + type CreateAdminTrialEntryWriteRequestDb, + type CreateAdminTrialEntryWriteResultDb, +} from "./create-trial-entry"; export { deleteAdminTrialEventWriteDb, type DeleteAdminTrialEventWriteRequestDb, @@ -25,6 +30,7 @@ export { type UpdateAdminTrialEntryWriteRequestDb, type UpdateAdminTrialEntryWriteResultDb, } from "./update-trial-entry"; +export type { AdminTrialEntryWriteDataDb } from "./trial-entry-write"; export type { AdminTrialEventDetailsDb, AdminTrialEventDetailsRequestDb, diff --git a/packages/db/admin/trials/manage/trial-entry-write.ts b/packages/db/admin/trials/manage/trial-entry-write.ts new file mode 100644 index 00000000..2de4c303 --- /dev/null +++ b/packages/db/admin/trials/manage/trial-entry-write.ts @@ -0,0 +1,60 @@ +export type AdminTrialEntryWriteDataDb = { + entry: { + koemaasto: string | null; + koemuoto: string | null; + koetyyppi: "NORMAL" | "KOKOKAUDENKOE" | "PITKAKOE"; + ke: string | null; + lk: string | null; + award: string | null; + rank: string | null; + points: number | null; + koiriaLuokassa: number | null; + hyvaksytytAjominuutit: number | null; + ajoajanPisteet: number | null; + haku: number | null; + hauk: number | null; + yva: number | null; + hlo: number | null; + alo: number | null; + tja: number | null; + pin: number | null; + ansiopisteetYhteensa: number | null; + tappiopisteetYhteensa: number | null; + judge: string | null; + huomautus: "LUOPUI" | "SULJETTU" | "KESKEYTETTY" | null; + huomautusTeksti: string | null; + ylituomariNumeroSnapshot: string | null; + ryhmatuomariNimi: string | null; + palkintotuomariNimi: string | null; + omistajaSnapshot: string | null; + omistajanKotikuntaSnapshot: string | null; + }; + eras: Array<{ + era: number; + alkoi: string | null; + hakumin: number | null; + ajomin: number | null; + haku: number | null; + hauk: number | null; + yva: number | null; + hlo: number | null; + alo: number | null; + tja: number | null; + pin: number | null; + huomautusTeksti: string | null; + }>; + lisatiedotByEra: Array<{ + era: number; + replaceKeys: Array<{ + koodi: string; + osa: string; + }>; + items: Array<{ + koodi: string; + osa: string; + arvo: string; + nimi: string | null; + jarjestys: number | null; + }>; + }>; +}; diff --git a/packages/db/admin/trials/manage/update-trial-entry.ts b/packages/db/admin/trials/manage/update-trial-entry.ts index 67ea7bcd..203c1469 100644 --- a/packages/db/admin/trials/manage/update-trial-entry.ts +++ b/packages/db/admin/trials/manage/update-trial-entry.ts @@ -1,68 +1,11 @@ import { ADMIN_WRITE_TX_CONFIG } from "@db/core/interactive-write-transaction"; import { prisma } from "@db/core/prisma"; import { Prisma } from "@prisma/client"; +import type { AdminTrialEntryWriteDataDb } from "./trial-entry-write"; -export type UpdateAdminTrialEntryWriteRequestDb = { +export type UpdateAdminTrialEntryWriteRequestDb = AdminTrialEntryWriteDataDb & { trialEventId: string; trialEntryId: string; - entry: { - koemaasto: string | null; - koemuoto: string | null; - koetyyppi: "NORMAL" | "KOKOKAUDENKOE" | "PITKAKOE"; - ke: string | null; - lk: string | null; - award: string | null; - rank: string | null; - points: number | null; - koiriaLuokassa: number | null; - hyvaksytytAjominuutit: number | null; - ajoajanPisteet: number | null; - haku: number | null; - hauk: number | null; - yva: number | null; - hlo: number | null; - alo: number | null; - tja: number | null; - pin: number | null; - ansiopisteetYhteensa: number | null; - tappiopisteetYhteensa: number | null; - judge: string | null; - huomautus: "LUOPUI" | "SULJETTU" | "KESKEYTETTY" | null; - huomautusTeksti: string | null; - ylituomariNumeroSnapshot: string | null; - ryhmatuomariNimi: string | null; - palkintotuomariNimi: string | null; - omistajaSnapshot: string | null; - omistajanKotikuntaSnapshot: string | null; - }; - eras: Array<{ - era: number; - alkoi: string | null; - hakumin: number | null; - ajomin: number | null; - haku: number | null; - hauk: number | null; - yva: number | null; - hlo: number | null; - alo: number | null; - tja: number | null; - pin: number | null; - huomautusTeksti: string | null; - }>; - lisatiedotByEra: Array<{ - era: number; - replaceKeys: Array<{ - koodi: string; - osa: string; - }>; - items: Array<{ - koodi: string; - osa: string; - arvo: string; - nimi: string | null; - jarjestys: number | null; - }>; - }>; }; export type UpdateAdminTrialEntryWriteResultDb = diff --git a/packages/db/index.ts b/packages/db/index.ts index 779e44b0..a07c9e10 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -155,6 +155,7 @@ export { getBeagleTrialSummarySourceForDogDb, listActiveTrialRuleWindowsDb, searchBeagleTrialsDb, + buildTrialEntryIdentity, upsertKoiratietokantaAjokResultDb, type BeagleTrialDetailsRequestDb, type BeagleTrialDetailsResponseDb, @@ -180,6 +181,7 @@ export { getAdminTrialEventDetailsDb, searchAdminTrialsDb, createAdminTrialEventWriteDb, + createAdminTrialEntryWriteDb, deleteAdminTrialEventWriteDb, deleteAdminTrialEntryWriteDb, updateAdminTrialEventWriteDb, @@ -193,8 +195,11 @@ export { type AdminTrialEventSearchResponseDb, type AdminTrialEventSearchSortDb, type AdminTrialEventSummaryDb, + type AdminTrialEntryWriteDataDb, type CreateAdminTrialEventWriteRequestDb, type CreateAdminTrialEventWriteResultDb, + type CreateAdminTrialEntryWriteRequestDb, + type CreateAdminTrialEntryWriteResultDb, type DeleteAdminTrialEventWriteRequestDb, type DeleteAdminTrialEventWriteResultDb, type DeleteAdminTrialEntryWriteRequestDb, diff --git a/packages/db/prisma/migrations/20260414161000_bej79_add_ajok_trial_event_entry/migration.sql b/packages/db/prisma/migrations/20260414161000_bej79_add_ajok_trial_event_entry/migration.sql index 8aa7beeb..df76130d 100644 --- a/packages/db/prisma/migrations/20260414161000_bej79_add_ajok_trial_event_entry/migration.sql +++ b/packages/db/prisma/migrations/20260414161000_bej79_add_ajok_trial_event_entry/migration.sql @@ -4,14 +4,14 @@ -- once this migration is treated as immutable in shared deployed environments. -- CreateEnum -CREATE TYPE "TrialSourceTag" AS ENUM ('LEGACY_AKOEALL', 'KOIRATIETOKANTA_API'); +CREATE TYPE "TrialSourceTag" AS ENUM ('LEGACY_AKOEALL', 'MANUAL_ADMIN', 'KOIRATIETOKANTA_API'); -- CreateTable CREATE TABLE "TrialEvent" ( "id" TEXT NOT NULL, "sklKoeId" INTEGER, "legacyEventKey" TEXT, - "koepaiva" TIMESTAMP(3) NOT NULL, + "koepaiva" DATE NOT NULL, "koekunta" TEXT NOT NULL, "jarjestaja" TEXT, "kennelpiiri" TEXT, diff --git a/packages/db/prisma/migrations/20260423130000_phase5_trial_runtime_projection/migration.sql b/packages/db/prisma/migrations/20260423130000_phase5_trial_runtime_projection/migration.sql index 2ca0f0f5..7f290b5d 100644 --- a/packages/db/prisma/migrations/20260423130000_phase5_trial_runtime_projection/migration.sql +++ b/packages/db/prisma/migrations/20260423130000_phase5_trial_runtime_projection/migration.sql @@ -240,7 +240,7 @@ COMMENT ON COLUMN "TrialEntry"."hauk" IS 'Entry-level barking points from legacy COMMENT ON COLUMN "TrialEntry"."yva" IS 'Entry-level general impression points from legacy summary.'; COMMENT ON COLUMN "TrialEntry"."hlo" IS 'Entry-level search looseness points/penalty value from legacy summary.'; COMMENT ON COLUMN "TrialEntry"."alo" IS 'Entry-level driving-skill or driving looseness value from legacy summary.'; -COMMENT ON COLUMN "TrialEntry"."tja" IS 'Entry-level trail/backtrack work value from legacy summary.'; +COMMENT ON COLUMN "TrialEntry"."tja" IS 'Legacy-trial tie- ja estetyöskentely summary value; not a current Koiratietokanta API summary field.'; COMMENT ON COLUMN "TrialEntry"."pin" IS 'Entry-level Metsastysinto or total merit value from legacy summary.'; COMMENT ON COLUMN "TrialEntry"."tuom1" IS 'Primary/chief judge name snapshot from source.'; COMMENT ON COLUMN "TrialEntry"."vara" IS 'Legacy reserved/flag field.'; @@ -262,7 +262,7 @@ COMMENT ON COLUMN "TrialEra"."hauk" IS 'Barking points for the era.'; COMMENT ON COLUMN "TrialEra"."yva" IS 'entinen Yleisvaikutelma, nykyinen ajotaitokeskiarvo'; COMMENT ON COLUMN "TrialEra"."hlo" IS 'Search looseness points/penalty value for the era.'; COMMENT ON COLUMN "TrialEra"."alo" IS 'Ajolöysyys or driving looseness value for the era.'; -COMMENT ON COLUMN "TrialEra"."tja" IS 'Trail/backtrack work value for the era.'; +COMMENT ON COLUMN "TrialEra"."tja" IS 'Legacy-trial tie- ja estetyöskentely value for the era; not a current Koiratietokanta API era field.'; COMMENT ON COLUMN "TrialEra"."pin" IS 'Metsästysinto or merit value for the era.'; COMMENT ON COLUMN "TrialEra"."huomautusTeksti" IS 'Per-era free-text note rendered in the PDF huomautus section; sourced from API *_VIITE fields or legacy bealt*.VIITE.'; COMMENT ON COLUMN "TrialEra"."raakadataJson" IS 'Raw source payload for this era/detail row.'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index c72938bc..03f7f28f 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -73,6 +73,7 @@ enum ShowSourceTag { enum TrialSourceTag { LEGACY_AKOEALL + MANUAL_ADMIN KOIRATIETOKANTA_API } @@ -407,7 +408,7 @@ model TrialEvent { // 1) kokeen tiedot id String @id @default(cuid()) sklKoeId Int? @unique - koepaiva DateTime + koepaiva DateTime @db.Date koekunta String jarjestaja String? kennelpiiri String? @@ -458,7 +459,7 @@ model TrialEntry { yva Decimal? @db.Decimal(6, 2) // nykyinen ajotaito hlo Decimal? @db.Decimal(6, 2) alo Decimal? @db.Decimal(6, 2) - tja Decimal? @db.Decimal(6, 2) + tja Decimal? @db.Decimal(6, 2) // vanhojen kokeiden tie- ja estetyöskentely pin Decimal? @db.Decimal(6, 2) ansiopisteetYhteensa Decimal? @db.Decimal(6, 2) tappiopisteetYhteensa Decimal? @db.Decimal(6, 2) @@ -498,7 +499,7 @@ model TrialEra { yva Decimal? @db.Decimal(6, 2) // nykyinen ajotaito hlo Decimal? @db.Decimal(6, 2) alo Decimal? @db.Decimal(6, 2) - tja Decimal? @db.Decimal(6, 2) + tja Decimal? @db.Decimal(6, 2) // vanhojen kokeiden tie- ja estetyöskentely pin Decimal? @db.Decimal(6, 2) huomautusTeksti String? @db.Text raakadataJson String? @db.Text diff --git a/packages/db/trials/core/trial-entry-identity.ts b/packages/db/trials/core/trial-entry-identity.ts new file mode 100644 index 00000000..9b30dd26 --- /dev/null +++ b/packages/db/trials/core/trial-entry-identity.ts @@ -0,0 +1,6 @@ +export function buildTrialEntryIdentity( + sklKoeId: number, + canonicalRegistrationNo: string, +): string { + return `SKL:${sklKoeId}|REG:${canonicalRegistrationNo}`; +} diff --git a/packages/db/trials/index.ts b/packages/db/trials/index.ts index 0348bb80..5c4917cd 100644 --- a/packages/db/trials/index.ts +++ b/packages/db/trials/index.ts @@ -31,3 +31,4 @@ export { type KoiratietokantaAjokUpsertDbInput, type KoiratietokantaAjokUpsertDbResult, } from "./integrations/koiratietokanta"; +export { buildTrialEntryIdentity } from "./core/trial-entry-identity"; diff --git a/packages/db/trials/integrations/koiratietokanta/__tests__/upsert-ajok-result.test.ts b/packages/db/trials/integrations/koiratietokanta/__tests__/upsert-ajok-result.test.ts index 1b284f77..b71bcdf8 100644 --- a/packages/db/trials/integrations/koiratietokanta/__tests__/upsert-ajok-result.test.ts +++ b/packages/db/trials/integrations/koiratietokanta/__tests__/upsert-ajok-result.test.ts @@ -199,6 +199,7 @@ describe("upsertKoiratietokantaAjokResultDb", () => { yva: 9.25, hlo: null, alo: null, + tja: null, ansiopisteetYhteensa: null, tappiopisteetYhteensa: null, lk: null, @@ -270,6 +271,51 @@ describe("upsertKoiratietokantaAjokResultDb", () => { expect(txMock.trialEraLisatieto.createMany).not.toHaveBeenCalled(); }); + it("authoritatively replaces a manual entry while preserving an unresolved dog link", async () => { + txMock.trialEntry.findUnique.mockResolvedValue({ id: "entry-1" }); + txMock.dogRegistration.findUnique.mockResolvedValue(null); + + const result = await upsertKoiratietokantaAjokResultDb({ + event: { + sklKoeId: 431477, + koepaiva: new Date("2025-09-07T00:00:00.000Z"), + koekunta: "Ristijarvi", + jarjestaja: null, + kennelpiiri: null, + kennelpiirinro: null, + trialRuleWindowId: null, + ylituomariNimi: "API judge", + ylituomariNumero: null, + ytKertomus: null, + }, + entry: { ...entryInput, raakadataJson: '{"source":"api"}', haku: 8.5 }, + eras: eraInputs, + }); + + expect(result).toMatchObject({ + created: false, + updated: true, + dogFound: false, + }); + expect(txMock.trialEntry.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + lahde: "KOIRATIETOKANTA_API", + raakadataJson: '{"source":"api"}', + haku: 8.5, + tja: null, + }), + }), + ); + const update = txMock.trialEntry.upsert.mock.calls.at(-1)?.[0].update; + expect(update).not.toHaveProperty("dogId"); + expect(update).not.toHaveProperty("createdAt"); + expect(txMock.trialEra.deleteMany).toHaveBeenCalledWith({ + where: { trialEntryId: "entry-1" }, + }); + expect(txMock.trialEra.create).toHaveBeenCalledTimes(2); + }); + it("writes canonical huomautus status", async () => { await upsertKoiratietokantaAjokResultDb({ event: { diff --git a/packages/db/trials/integrations/koiratietokanta/upsert-ajok-result.ts b/packages/db/trials/integrations/koiratietokanta/upsert-ajok-result.ts index d8f3c0d1..40cc9fb2 100644 --- a/packages/db/trials/integrations/koiratietokanta/upsert-ajok-result.ts +++ b/packages/db/trials/integrations/koiratietokanta/upsert-ajok-result.ts @@ -211,6 +211,9 @@ export async function upsertKoiratietokantaAjokResultDb( yva: input.entry.yva, hlo: input.entry.hlo, alo: input.entry.alo, + // Legacy trials stored tie- ja estetyöskentely in this summary field. + // Current API values live in lisatiedot, so authoritative upsert clears it. + tja: null, ansiopisteetYhteensa: input.entry.ansiopisteetYhteensa, tappiopisteetYhteensa: input.entry.tappiopisteetYhteensa, // Event-level chief judge is canonical for imported AJOK entries. diff --git a/packages/server/admin/index.ts b/packages/server/admin/index.ts index 92a08cf3..8dc4f334 100644 --- a/packages/server/admin/index.ts +++ b/packages/server/admin/index.ts @@ -22,6 +22,7 @@ export { deleteAdminDogDisease } from "./dogs"; export { listAdminTrialEvents } from "./trials"; export { getAdminTrialEvent } from "./trials"; export { createAdminTrialEvent } from "./trials"; +export { createAdminTrialEntry } from "./trials"; export { deleteAdminTrialEvent } from "./trials"; export { deleteAdminTrialEntry } from "./trials"; export { updateAdminTrialEvent } from "./trials"; diff --git a/packages/server/admin/trials/manage/__tests__/create-trial-entry.test.ts b/packages/server/admin/trials/manage/__tests__/create-trial-entry.test.ts new file mode 100644 index 00000000..d3eb4ae5 --- /dev/null +++ b/packages/server/admin/trials/manage/__tests__/create-trial-entry.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + CreateAdminTrialEntryRequest, + CurrentUserDto, +} from "@beagle/contracts"; +import { createAdminTrialEntry } from "../create-trial-entry"; + +const { writeMock } = vi.hoisted(() => ({ writeMock: vi.fn() })); +vi.mock("@beagle/db", () => ({ createAdminTrialEntryWriteDb: writeMock })); + +const admin: CurrentUserDto = { + id: "u1", + email: "a@example.com", + username: null, + role: "ADMIN", +}; +const input: CreateAdminTrialEntryRequest = { + trialEventId: "event-1", + registrationNo: " fi12345/21 ", + entry: { + koemaasto: null, + koemuoto: "AJOK", + koetyyppi: "NORMAL", + ke: null, + lk: null, + award: null, + rank: null, + points: null, + koiriaLuokassa: null, + hyvaksytytAjominuutit: null, + ajoajanPisteet: null, + haku: null, + hauk: null, + yva: null, + hlo: null, + alo: null, + tja: null, + pin: null, + ansiopisteetYhteensa: null, + tappiopisteetYhteensa: null, + judge: null, + huomautus: null, + huomautusTeksti: null, + ylituomariNumeroSnapshot: null, + ryhmatuomariNimi: null, + palkintotuomariNimi: null, + omistajaSnapshot: null, + omistajanKotikuntaSnapshot: null, + }, + eras: [ + { + era: 1, + alkoi: null, + hakumin: null, + ajomin: null, + haku: null, + hauk: null, + yva: null, + hlo: null, + alo: null, + tja: null, + pin: null, + huomautusTeksti: null, + }, + ], + lisatiedotRows: [], +}; + +describe("createAdminTrialEntry", () => { + beforeEach(() => writeMock.mockReset()); + + it("requires admin authorization", async () => { + await expect(createAdminTrialEntry(input, null)).resolves.toMatchObject({ + status: 401, + body: { ok: false, code: "UNAUTHENTICATED" }, + }); + expect(writeMock).not.toHaveBeenCalled(); + }); + + it("normalizes registration before persistence", async () => { + writeMock.mockResolvedValue({ + status: "created", + trialEventId: "event-1", + trialEntryId: "entry-1", + }); + await expect(createAdminTrialEntry(input, admin)).resolves.toMatchObject({ + status: 201, + body: { ok: true }, + }); + expect(writeMock).toHaveBeenCalledWith( + expect.objectContaining({ canonicalRegistrationNo: "FI12345/21" }), + ); + }); + + it.each([ + ["event_not_found", 404, "TRIAL_EVENT_NOT_FOUND"], + ["event_missing_skl_id", 409, "TRIAL_EVENT_MISSING_SKL_ID"], + ["registration_conflict", 409, "TRIAL_ENTRY_REGISTRATION_CONFLICT"], + ] as const)("maps %s", async (status, expectedStatus, code) => { + writeMock.mockResolvedValue({ status }); + await expect(createAdminTrialEntry(input, admin)).resolves.toMatchObject({ + status: expectedStatus, + body: { ok: false, code }, + }); + }); + + it("uses stable validation categories", async () => { + await expect( + createAdminTrialEntry({ ...input, eras: [] }, admin), + ).resolves.toMatchObject({ + status: 400, + body: { ok: false, code: "INVALID_TRIAL_ERAS" }, + }); + await expect( + createAdminTrialEntry({ ...input, registrationNo: "bad value" }, admin), + ).resolves.toMatchObject({ + status: 400, + body: { ok: false, code: "INVALID_REGISTRATION_NUMBER" }, + }); + }); + + it("rejects duplicate normalized lisatieto keys before persistence", async () => { + const row = { + koodi: "11", + osa: "", + nimi: null, + jarjestys: 1, + eraValues: [{ era: 1, arvo: "1" }], + }; + + await expect( + createAdminTrialEntry( + { + ...input, + lisatiedotRows: [row, { ...row, koodi: " 11 ", osa: " " }], + }, + admin, + ), + ).resolves.toMatchObject({ + status: 400, + body: { ok: false, code: "INVALID_TRIAL_ADDITIONAL_INFO" }, + }); + expect(writeMock).not.toHaveBeenCalled(); + }); + + it("rejects duplicate lisatieto era values before persistence", async () => { + await expect( + createAdminTrialEntry( + { + ...input, + lisatiedotRows: [ + { + koodi: "11", + osa: "", + nimi: null, + jarjestys: 1, + eraValues: [ + { era: 1, arvo: "1" }, + { era: 1, arvo: "2" }, + ], + }, + ], + }, + admin, + ), + ).resolves.toMatchObject({ + status: 400, + body: { ok: false, code: "INVALID_TRIAL_ADDITIONAL_INFO" }, + }); + expect(writeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/admin/trials/manage/__tests__/list-trials.test.ts b/packages/server/admin/trials/manage/__tests__/list-trials.test.ts index 8c7bf856..eebb0f16 100644 --- a/packages/server/admin/trials/manage/__tests__/list-trials.test.ts +++ b/packages/server/admin/trials/manage/__tests__/list-trials.test.ts @@ -94,8 +94,8 @@ describe("listAdminTrialEvents", () => { expect(searchAdminTrialsDbMock).toHaveBeenCalledWith({ query: "helsinki", - dateFrom: new Date("2025-12-31T22:00:00.000Z"), - dateTo: new Date("2026-12-31T22:00:00.000Z"), + dateFrom: new Date("2026-01-01T00:00:00.000Z"), + dateTo: new Date("2027-01-01T00:00:00.000Z"), page: 2, pageSize: 100, sort: "date-asc", diff --git a/packages/server/admin/trials/manage/__tests__/parse-admin-trial-event-search-input.test.ts b/packages/server/admin/trials/manage/__tests__/parse-admin-trial-event-search-input.test.ts index f525519b..26780719 100644 --- a/packages/server/admin/trials/manage/__tests__/parse-admin-trial-event-search-input.test.ts +++ b/packages/server/admin/trials/manage/__tests__/parse-admin-trial-event-search-input.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getTrialBusinessDateStartUtc } from "@server/trials/core/business-date"; +import { getTrialDateOnlyStartUtc } from "@server/trials/core/date-only"; import { parseAdminTrialEventSearchInput } from "../internal/parse-admin-trial-event-search-input"; describe("parseAdminTrialEventSearchInput", () => { @@ -46,8 +46,8 @@ describe("parseAdminTrialEventSearchInput", () => { year: null, dateFromIso: "2026-06-01", dateToIso: "2026-06-30", - rangeFromDate: getTrialBusinessDateStartUtc("2026-06-01"), - rangeToExclusive: getTrialBusinessDateStartUtc("2026-07-01"), + rangeFromDate: getTrialDateOnlyStartUtc("2026-06-01"), + rangeToExclusive: getTrialDateOnlyStartUtc("2026-07-01"), }, }); }); diff --git a/packages/server/admin/trials/manage/__tests__/resolve-admin-trial-event-defaults.test.ts b/packages/server/admin/trials/manage/__tests__/resolve-admin-trial-event-defaults.test.ts index 51bcfdc0..417678dd 100644 --- a/packages/server/admin/trials/manage/__tests__/resolve-admin-trial-event-defaults.test.ts +++ b/packages/server/admin/trials/manage/__tests__/resolve-admin-trial-event-defaults.test.ts @@ -125,8 +125,8 @@ describe("resolveAdminTrialEventSearchResponseDb", () => { }); expect(searchAdminTrialsDbMock).toHaveBeenNthCalledWith(2, { query: "", - dateFrom: new Date("2025-12-31T22:00:00.000Z"), - dateTo: new Date("2026-12-31T22:00:00.000Z"), + dateFrom: new Date("2026-01-01T00:00:00.000Z"), + dateTo: new Date("2027-01-01T00:00:00.000Z"), page: 1, pageSize: 20, sort: "date-desc", diff --git a/packages/server/admin/trials/manage/__tests__/update-trial-entry.test.ts b/packages/server/admin/trials/manage/__tests__/update-trial-entry.test.ts index 8776351c..5bded1cd 100644 --- a/packages/server/admin/trials/manage/__tests__/update-trial-entry.test.ts +++ b/packages/server/admin/trials/manage/__tests__/update-trial-entry.test.ts @@ -260,6 +260,65 @@ describe("updateAdminTrialEntry", () => { expect(updateAdminTrialEntryWriteDbMock).not.toHaveBeenCalled(); }); + it("rejects duplicate normalized lisatieto keys before persistence", async () => { + const row = { + koodi: "11", + osa: "", + nimi: null, + jarjestys: null, + eraValues: [{ era: 1, arvo: "1" }], + }; + + await expect( + updateAdminTrialEntry( + { + ...baseInput(), + lisatiedotRows: [row, { ...row, koodi: " 11 ", osa: " " }], + }, + adminUser, + ), + ).resolves.toEqual({ + status: 400, + body: { + ok: false, + error: "Duplicate lisatieto rows are not allowed.", + code: "INVALID_TRIAL_ADDITIONAL_INFO", + }, + }); + expect(updateAdminTrialEntryWriteDbMock).not.toHaveBeenCalled(); + }); + + it("rejects duplicate lisatieto era values before persistence", async () => { + await expect( + updateAdminTrialEntry( + { + ...baseInput(), + lisatiedotRows: [ + { + koodi: "11", + osa: "", + nimi: null, + jarjestys: null, + eraValues: [ + { era: 1, arvo: "1" }, + { era: 1, arvo: "2" }, + ], + }, + ], + }, + adminUser, + ), + ).resolves.toEqual({ + status: 400, + body: { + ok: false, + error: "Duplicate lisatieto era values are not allowed.", + code: "INVALID_TRIAL_ADDITIONAL_INFO", + }, + }); + expect(updateAdminTrialEntryWriteDbMock).not.toHaveBeenCalled(); + }); + it("accepts canonical and existing numeric lisatieto codes by koodi and osa", async () => { updateAdminTrialEntryWriteDbMock.mockResolvedValue({ status: "updated", diff --git a/packages/server/admin/trials/manage/create-trial-entry.ts b/packages/server/admin/trials/manage/create-trial-entry.ts new file mode 100644 index 00000000..2d963c06 --- /dev/null +++ b/packages/server/admin/trials/manage/create-trial-entry.ts @@ -0,0 +1,174 @@ +import type { + CreateAdminTrialEntryRequest, + CreateAdminTrialEntryResponse, + CurrentUserDto, +} from "@beagle/contracts"; +import { createAdminTrialEntryWriteDb } 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 { + isValidTrialRegistrationNo, + normalizeTrialRegistrationNo, +} from "@server/trials/core"; +import { parseAdminTrialEntryWriteInput } from "./internal/parse-admin-trial-entry-write-input"; + +type ServiceLogContext = { requestId?: string; actorUserId?: string }; + +const ISSUE_CODES = { + entry: "INVALID_TRIAL_ENTRY", + eras: "INVALID_TRIAL_ERAS", + additional_info: "INVALID_TRIAL_ADDITIONAL_INFO", +} as const; + +// Creates one API-compatible manual trial result for an authorized administrator. +export async function createAdminTrialEntry( + input: CreateAdminTrialEntryRequest, + currentUser: CurrentUserDto | null, + context?: ServiceLogContext, +): Promise> { + const startedAt = Date.now(); + const log = withLogContext({ + layer: "service", + useCase: "admin-trials.createAdminTrialEntry", + ...(context?.requestId ? { requestId: context.requestId } : {}), + ...(context?.actorUserId ? { actorUserId: context.actorUserId } : {}), + }); + const auth = requireAdmin(currentUser); + if (!auth.body.ok) { + log.warn( + { event: "forbidden", status: auth.status }, + "admin trial entry creation rejected by authorization", + ); + return { status: auth.status, body: auth.body }; + } + + const trialEventId = input.trialEventId.trim(); + if (!trialEventId) { + log.warn( + { event: "invalid_trial_event_id" }, + "admin trial entry creation rejected before persistence", + ); + return { + status: 400, + body: { + ok: false, + error: "Trial event id is required.", + code: "INVALID_TRIAL_EVENT_ID", + }, + }; + } + + const registrationNo = normalizeTrialRegistrationNo(input.registrationNo); + if (!registrationNo || !isValidTrialRegistrationNo(registrationNo)) { + log.warn( + { event: "invalid_registration_number", trialEventId }, + "admin trial entry creation rejected before persistence", + ); + return { + status: 400, + body: { + ok: false, + error: "Registration number is invalid.", + code: "INVALID_REGISTRATION_NUMBER", + }, + }; + } + + const parsed = parseAdminTrialEntryWriteInput(input, { mode: "create" }); + if (!parsed.ok) { + log.warn( + { event: parsed.issue.reason, trialEventId, registrationNo }, + "admin trial entry creation rejected before persistence", + ); + return { + status: 400, + body: { + ok: false, + error: "Trial result fields are invalid.", + code: ISSUE_CODES[parsed.issue.area], + }, + }; + } + + log.info( + { event: "start", trialEventId, registrationNo }, + "admin trial entry creation started", + ); + try { + const result = await createAdminTrialEntryWriteDb({ + trialEventId, + canonicalRegistrationNo: registrationNo, + ...parsed.data, + }); + if (result.status === "event_not_found") { + return { + status: 404, + body: { + ok: false, + error: "Trial event was not found.", + code: "TRIAL_EVENT_NOT_FOUND", + }, + }; + } + if (result.status === "event_missing_skl_id") { + return { + status: 409, + body: { + ok: false, + error: "Trial event is missing its SKL id.", + code: "TRIAL_EVENT_MISSING_SKL_ID", + }, + }; + } + if (result.status === "registration_conflict") { + return { + status: 409, + body: { + ok: false, + error: "Registration already exists in this event.", + code: "TRIAL_ENTRY_REGISTRATION_CONFLICT", + }, + }; + } + log.info( + { + event: "success", + trialEventId, + trialEntryId: result.trialEntryId, + registrationNo, + durationMs: Date.now() - startedAt, + }, + "admin trial entry creation succeeded", + ); + return { + status: 201, + body: { + ok: true, + data: { + trialEventId: result.trialEventId, + trialEntryId: result.trialEntryId, + }, + }, + }; + } catch (error) { + log.error( + { + event: "exception", + trialEventId, + registrationNo, + durationMs: Date.now() - startedAt, + ...toErrorLog(error), + }, + "admin trial entry creation failed", + ); + return { + status: 500, + body: { + ok: false, + error: "Failed to create admin trial entry.", + code: "INTERNAL_ERROR", + }, + }; + } +} diff --git a/packages/server/admin/trials/manage/get-trial-event.ts b/packages/server/admin/trials/manage/get-trial-event.ts index 47310046..053e043b 100644 --- a/packages/server/admin/trials/manage/get-trial-event.ts +++ b/packages/server/admin/trials/manage/get-trial-event.ts @@ -4,7 +4,7 @@ import type { AdminTrialEventDetailsResponse, CurrentUserDto, } from "@beagle/contracts"; -import { toBusinessDateOnly } from "@server/core/date-only"; +import { formatTrialDateOnly } from "@server/trials/core/date-only"; import { toErrorLog, withLogContext } from "@server/core/logger"; import type { ServiceResult } from "@server/core/result"; import { requireAdmin } from "@server/admin/core/service"; @@ -74,7 +74,7 @@ export async function getAdminTrialEvent( data: { event: { trialEventId: result.trialEventId, - eventDate: toBusinessDateOnly(result.eventDate), + eventDate: formatTrialDateOnly(result.eventDate), eventPlace: result.eventPlace, eventName: result.eventName, jarjestaja: result.jarjestaja, diff --git a/packages/server/admin/trials/manage/index.ts b/packages/server/admin/trials/manage/index.ts index 606751fb..b1256a0a 100644 --- a/packages/server/admin/trials/manage/index.ts +++ b/packages/server/admin/trials/manage/index.ts @@ -1,6 +1,7 @@ export { getAdminTrialEvent } from "./get-trial-event"; export { listAdminTrialEvents } from "./list-trials"; export { createAdminTrialEvent } from "./create-trial-event"; +export { createAdminTrialEntry } from "./create-trial-entry"; export { deleteAdminTrialEvent } from "./delete-trial-event"; export { deleteAdminTrialEntry } from "./delete-trial-entry"; export { updateAdminTrialEvent } from "./update-trial-event"; diff --git a/packages/server/admin/trials/manage/internal/__tests__/parse-admin-trial-entry-write-input.test.ts b/packages/server/admin/trials/manage/internal/__tests__/parse-admin-trial-entry-write-input.test.ts new file mode 100644 index 00000000..c664256b --- /dev/null +++ b/packages/server/admin/trials/manage/internal/__tests__/parse-admin-trial-entry-write-input.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import type { AdminTrialEntryWriteData } from "@beagle/contracts"; +import { parseAdminTrialEntryWriteInput } from "../parse-admin-trial-entry-write-input"; + +function createInput(): AdminTrialEntryWriteData { + return { + entry: { + koemaasto: " Forest ", + koemuoto: " AJOK ", + koetyyppi: "NORMAL", + ke: null, + lk: null, + award: null, + rank: null, + points: null, + koiriaLuokassa: null, + hyvaksytytAjominuutit: null, + ajoajanPisteet: null, + haku: null, + hauk: null, + yva: null, + hlo: null, + alo: null, + tja: null, + pin: null, + ansiopisteetYhteensa: null, + tappiopisteetYhteensa: null, + judge: null, + huomautus: null, + huomautusTeksti: null, + ylituomariNumeroSnapshot: null, + ryhmatuomariNimi: null, + palkintotuomariNimi: null, + omistajaSnapshot: null, + omistajanKotikuntaSnapshot: null, + }, + eras: [ + { + era: 1, + alkoi: null, + hakumin: null, + ajomin: null, + haku: null, + hauk: null, + yva: null, + hlo: null, + alo: null, + tja: null, + pin: null, + huomautusTeksti: null, + }, + ], + lisatiedotRows: [ + { + koodi: " 11 ", + osa: " ", + nimi: " Paljas maa ", + jarjestys: 1, + eraValues: [{ era: 1, arvo: " 1 " }], + }, + ], + }; +} + +describe("parseAdminTrialEntryWriteInput", () => { + it("normalizes the shared write shape", () => { + const result = parseAdminTrialEntryWriteInput(createInput(), { + mode: "create", + }); + + expect(result).toMatchObject({ + ok: true, + data: { + entry: { koemaasto: "Forest", koemuoto: "AJOK" }, + eras: [{ era: 1 }], + lisatiedotByEra: [ + { + era: 1, + items: [ + { + koodi: "11", + osa: "", + nimi: "Paljas maa", + arvo: "1", + jarjestys: 1, + }, + ], + }, + ], + }, + }); + }); + + it("returns detailed issue categories for service-specific mappings", () => { + expect( + parseAdminTrialEntryWriteInput( + { ...createInput(), eras: [] }, + { mode: "create" }, + ), + ).toEqual({ + ok: false, + issue: { area: "eras", reason: "missing_eras" }, + }); + }); + + it.each([ + { koodi: " 11 ", osa: " " }, + { koodi: "11", osa: "" }, + ])("rejects duplicate normalized lisatieto key %#", (duplicate) => { + const input = createInput(); + input.lisatiedotRows.push({ + ...input.lisatiedotRows[0], + ...duplicate, + }); + + expect(parseAdminTrialEntryWriteInput(input, { mode: "create" })).toEqual({ + ok: false, + issue: { + area: "additional_info", + reason: "duplicate_lisatieto_key", + }, + }); + }); + + it("allows the same lisatieto code with different osa values", () => { + const input = createInput(); + input.lisatiedotRows.push({ + ...input.lisatiedotRows[0], + osa: "2", + }); + + expect( + parseAdminTrialEntryWriteInput(input, { mode: "create" }), + ).toMatchObject({ ok: true }); + }); + + it("rejects duplicate era values within one lisatieto row", () => { + const input = createInput(); + input.lisatiedotRows[0].eraValues.push({ era: 1, arvo: "2" }); + + expect(parseAdminTrialEntryWriteInput(input, { mode: "create" })).toEqual({ + ok: false, + issue: { + area: "additional_info", + reason: "duplicate_lisatieto_era_value", + }, + }); + }); + + it("preserves update compatibility for invalid lisatieto ordering", () => { + const input = createInput(); + input.lisatiedotRows[0].jarjestys = 1.5; + + expect(parseAdminTrialEntryWriteInput(input, { mode: "create" })).toEqual({ + ok: false, + issue: { + area: "additional_info", + reason: "invalid_lisatieto_order", + }, + }); + expect( + parseAdminTrialEntryWriteInput(input, { mode: "update" }), + ).toMatchObject({ + ok: true, + data: { + lisatiedotByEra: [{ items: [{ jarjestys: null }] }], + }, + }); + }); +}); diff --git a/packages/server/admin/trials/manage/internal/map-admin-trial-event-search-response.ts b/packages/server/admin/trials/manage/internal/map-admin-trial-event-search-response.ts index 194e0939..ea343c50 100644 --- a/packages/server/admin/trials/manage/internal/map-admin-trial-event-search-response.ts +++ b/packages/server/admin/trials/manage/internal/map-admin-trial-event-search-response.ts @@ -1,11 +1,13 @@ -import { toBusinessDateOnly } from "@server/core/date-only"; +import { + formatTrialDateOnly, + toTrialDateOnlyYear, +} from "@server/trials/core/date-only"; import type { AdminTrialEventSearchFilters, AdminTrialEventSearchResponse, } from "@beagle/contracts"; import type { ParsedAdminTrialEventSearchInput } from "./parse-admin-trial-event-search-input"; import type { ResolvedAdminTrialEventSearch } from "./resolve-admin-trial-event-defaults"; -import { toTrialBusinessYear } from "@server/trials/core/business-date"; function resolveFilters( input: ParsedAdminTrialEventSearchInput, @@ -46,7 +48,7 @@ export function mapAdminTrialEventSearchResponse( const result = resolved.result; const availableYears = Array.from( new Set( - result.availableEventDates.map((value) => toTrialBusinessYear(value)), + result.availableEventDates.map((value) => toTrialDateOnlyYear(value)), ), ).sort((left, right) => right - left); @@ -58,7 +60,7 @@ export function mapAdminTrialEventSearchResponse( page: result.page, items: result.items.map((item) => ({ trialEventId: item.trialEventId, - eventDate: toBusinessDateOnly(item.eventDate), + eventDate: formatTrialDateOnly(item.eventDate), eventPlace: item.eventPlace, eventName: item.eventName, jarjestaja: item.jarjestaja, diff --git a/packages/server/admin/trials/manage/internal/parse-admin-trial-entry-write-input.ts b/packages/server/admin/trials/manage/internal/parse-admin-trial-entry-write-input.ts new file mode 100644 index 00000000..c5ed8f64 --- /dev/null +++ b/packages/server/admin/trials/manage/internal/parse-admin-trial-entry-write-input.ts @@ -0,0 +1,247 @@ +import type { AdminTrialEntryWriteData } from "@beagle/contracts"; +import type { AdminTrialEntryWriteDataDb } from "@beagle/db"; + +export type AdminTrialEntryWriteValidationReason = + | "invalid_koetyyppi" + | "invalid_huomautus" + | "invalid_entry_integer" + | "invalid_entry_number" + | "missing_eras" + | "invalid_era_number" + | "duplicate_eras" + | "non_continuous_eras" + | "invalid_era_integer" + | "invalid_era_number_field" + | "invalid_lisatieto_code" + | "duplicate_lisatieto_key" + | "invalid_lisatieto_era" + | "duplicate_lisatieto_era_value" + | "invalid_lisatieto_order"; + +export type AdminTrialEntryWriteValidationIssue = { + area: "entry" | "eras" | "additional_info"; + reason: AdminTrialEntryWriteValidationReason; + value?: string; +}; + +const VALID_KOETYYPIT = new Set(["NORMAL", "KOKOKAUDENKOE", "PITKAKOE"]); +const VALID_HUOMAUTUKSET = new Set(["LUOPUI", "SULJETTU", "KESKEYTETTY"]); + +function normalizeNullableText( + value: string | null | undefined, +): string | null { + const normalized = typeof value === "string" ? value.trim() : ""; + return normalized || null; +} + +function normalizeNullableInteger( + value: number | null | undefined, +): number | null { + return value == null || !Number.isInteger(value) ? null : value; +} + +function normalizeNullableNumber( + value: number | null | undefined, +): number | null { + return value == null || !Number.isFinite(value) ? null : value; +} + +function isNullableInteger(value: unknown): boolean { + return ( + value == null || (typeof value === "number" && Number.isSafeInteger(value)) + ); +} + +function isNullableNumber(value: unknown): boolean { + return value == null || (typeof value === "number" && Number.isFinite(value)); +} + +function failure( + area: AdminTrialEntryWriteValidationIssue["area"], + reason: AdminTrialEntryWriteValidationReason, + value?: string, +): { ok: false; issue: AdminTrialEntryWriteValidationIssue } { + return { ok: false, issue: { area, reason, ...(value ? { value } : {}) } }; +} + +// Validates and normalizes the result shape shared by create and update writes. +export function parseAdminTrialEntryWriteInput( + input: AdminTrialEntryWriteData, + options: { mode: "create" | "update" }, +): + | { ok: true; data: AdminTrialEntryWriteDataDb } + | { ok: false; issue: AdminTrialEntryWriteValidationIssue } { + if (!VALID_KOETYYPIT.has(input.entry.koetyyppi)) { + return failure("entry", "invalid_koetyyppi"); + } + if ( + input.entry.huomautus != null && + !VALID_HUOMAUTUKSET.has(input.entry.huomautus) + ) { + return failure("entry", "invalid_huomautus"); + } + if ( + ![input.entry.koiriaLuokassa, input.entry.hyvaksytytAjominuutit].every( + isNullableInteger, + ) + ) { + return failure("entry", "invalid_entry_integer"); + } + if ( + ![ + input.entry.points, + input.entry.ajoajanPisteet, + input.entry.haku, + input.entry.hauk, + input.entry.yva, + input.entry.hlo, + input.entry.alo, + input.entry.tja, + input.entry.pin, + input.entry.ansiopisteetYhteensa, + input.entry.tappiopisteetYhteensa, + ].every(isNullableNumber) + ) { + return failure("entry", "invalid_entry_number"); + } + + if (input.eras.length === 0) { + return failure("eras", "missing_eras"); + } + const eraNumbers = input.eras.map((era) => era.era); + if ( + eraNumbers.some( + (era) => !Number.isSafeInteger(era) || !Number.isFinite(era) || era < 1, + ) + ) { + return failure("eras", "invalid_era_number"); + } + if (new Set(eraNumbers).size !== eraNumbers.length) { + return failure("eras", "duplicate_eras"); + } + const sortedEras = [...eraNumbers].sort((left, right) => left - right); + if (sortedEras.some((era, index) => era !== index + 1)) { + return failure("eras", "non_continuous_eras"); + } + for (const era of input.eras) { + if (![era.hakumin, era.ajomin].every(isNullableInteger)) { + return failure("eras", "invalid_era_integer"); + } + if ( + ![era.haku, era.hauk, era.yva, era.hlo, era.alo, era.tja, era.pin].every( + isNullableNumber, + ) + ) { + return failure("eras", "invalid_era_number_field"); + } + } + + const lisatietoKeys = new Set(); + for (const row of input.lisatiedotRows) { + const koodi = normalizeNullableText(row.koodi); + if (!koodi || !/^[1-9]\d*$/u.test(koodi)) { + return failure("additional_info", "invalid_lisatieto_code", row.koodi); + } + const osa = normalizeNullableText(row.osa) ?? ""; + const key = JSON.stringify([koodi, osa]); + if (lisatietoKeys.has(key)) { + return failure("additional_info", "duplicate_lisatieto_key"); + } + lisatietoKeys.add(key); + if (options.mode === "create" && !isNullableInteger(row.jarjestys)) { + return failure("additional_info", "invalid_lisatieto_order"); + } + if (row.eraValues.some((value) => !eraNumbers.includes(value.era))) { + return failure("additional_info", "invalid_lisatieto_era"); + } + const lisatietoEraNumbers = row.eraValues.map((value) => value.era); + if (new Set(lisatietoEraNumbers).size !== lisatietoEraNumbers.length) { + return failure("additional_info", "duplicate_lisatieto_era_value"); + } + } + + return { + ok: true, + data: { + entry: { + koemaasto: normalizeNullableText(input.entry.koemaasto), + koemuoto: normalizeNullableText(input.entry.koemuoto), + koetyyppi: input.entry.koetyyppi, + ke: normalizeNullableText(input.entry.ke), + lk: normalizeNullableText(input.entry.lk), + award: normalizeNullableText(input.entry.award), + rank: normalizeNullableText(input.entry.rank), + points: normalizeNullableNumber(input.entry.points), + koiriaLuokassa: normalizeNullableInteger(input.entry.koiriaLuokassa), + hyvaksytytAjominuutit: normalizeNullableInteger( + input.entry.hyvaksytytAjominuutit, + ), + ajoajanPisteet: normalizeNullableNumber(input.entry.ajoajanPisteet), + haku: normalizeNullableNumber(input.entry.haku), + hauk: normalizeNullableNumber(input.entry.hauk), + yva: normalizeNullableNumber(input.entry.yva), + hlo: normalizeNullableNumber(input.entry.hlo), + alo: normalizeNullableNumber(input.entry.alo), + tja: normalizeNullableNumber(input.entry.tja), + pin: normalizeNullableNumber(input.entry.pin), + ansiopisteetYhteensa: normalizeNullableNumber( + input.entry.ansiopisteetYhteensa, + ), + tappiopisteetYhteensa: normalizeNullableNumber( + input.entry.tappiopisteetYhteensa, + ), + judge: normalizeNullableText(input.entry.judge), + huomautus: input.entry.huomautus ?? null, + huomautusTeksti: normalizeNullableText(input.entry.huomautusTeksti), + ylituomariNumeroSnapshot: normalizeNullableText( + input.entry.ylituomariNumeroSnapshot, + ), + ryhmatuomariNimi: normalizeNullableText(input.entry.ryhmatuomariNimi), + palkintotuomariNimi: normalizeNullableText( + input.entry.palkintotuomariNimi, + ), + omistajaSnapshot: normalizeNullableText(input.entry.omistajaSnapshot), + omistajanKotikuntaSnapshot: normalizeNullableText( + input.entry.omistajanKotikuntaSnapshot, + ), + }, + eras: input.eras.map((era) => ({ + era: era.era, + alkoi: normalizeNullableText(era.alkoi), + hakumin: normalizeNullableInteger(era.hakumin), + ajomin: normalizeNullableInteger(era.ajomin), + haku: normalizeNullableNumber(era.haku), + hauk: normalizeNullableNumber(era.hauk), + yva: normalizeNullableNumber(era.yva), + hlo: normalizeNullableNumber(era.hlo), + alo: normalizeNullableNumber(era.alo), + tja: normalizeNullableNumber(era.tja), + pin: normalizeNullableNumber(era.pin), + huomautusTeksti: normalizeNullableText(era.huomautusTeksti), + })), + lisatiedotByEra: eraNumbers.map((era) => ({ + era, + replaceKeys: input.lisatiedotRows.map((row) => ({ + koodi: normalizeNullableText(row.koodi) ?? row.koodi, + osa: normalizeNullableText(row.osa) ?? "", + })), + items: input.lisatiedotRows.flatMap((row) => { + const arvo = normalizeNullableText( + row.eraValues.find((value) => value.era === era)?.arvo, + ); + return arvo + ? [ + { + koodi: normalizeNullableText(row.koodi) ?? row.koodi, + osa: normalizeNullableText(row.osa) ?? "", + arvo, + nimi: normalizeNullableText(row.nimi), + jarjestys: normalizeNullableInteger(row.jarjestys), + }, + ] + : []; + }), + })), + }, + }; +} diff --git a/packages/server/admin/trials/manage/internal/parse-admin-trial-event-search-input.ts b/packages/server/admin/trials/manage/internal/parse-admin-trial-event-search-input.ts index 6d5692bb..5508c809 100644 --- a/packages/server/admin/trials/manage/internal/parse-admin-trial-event-search-input.ts +++ b/packages/server/admin/trials/manage/internal/parse-admin-trial-event-search-input.ts @@ -3,7 +3,7 @@ import type { AdminTrialEventSearchSort, } from "@beagle/contracts"; import type { AdminTrialEventSearchSortDb } from "@beagle/db"; -import { getTrialBusinessDateUtcRange } from "@server/trials/core/business-date"; +import { getTrialDateOnlyUtcRange } from "@server/trials/core/date-only"; export type ParsedAdminTrialEventSearchInput = { query: string; @@ -201,11 +201,10 @@ export function parseAdminTrialEventSearchInput( const query = normalizeQuery(input.query); const mode = year != null ? "year" : hasRangeInput ? "range" : null; const rangeFromDate = dateFromIso - ? getTrialBusinessDateUtcRange(new Date(`${dateFromIso}T00:00:00.000Z`)) - .start + ? getTrialDateOnlyUtcRange(new Date(`${dateFromIso}T00:00:00.000Z`)).start : null; const rangeToExclusive = dateToIso - ? getTrialBusinessDateUtcRange(new Date(`${dateToIso}T00:00:00.000Z`)) + ? getTrialDateOnlyUtcRange(new Date(`${dateToIso}T00:00:00.000Z`)) .endExclusive : null; diff --git a/packages/server/admin/trials/manage/internal/resolve-admin-trial-event-defaults.ts b/packages/server/admin/trials/manage/internal/resolve-admin-trial-event-defaults.ts index a064b55d..999de75f 100644 --- a/packages/server/admin/trials/manage/internal/resolve-admin-trial-event-defaults.ts +++ b/packages/server/admin/trials/manage/internal/resolve-admin-trial-event-defaults.ts @@ -1,8 +1,8 @@ import { searchAdminTrialsDb } from "@beagle/db"; import { - getTrialBusinessYearUtcRange, - toTrialBusinessYear, -} from "@server/trials/core/business-date"; + getTrialDateOnlyYearUtcRange, + toTrialDateOnlyYear, +} from "@server/trials/core/date-only"; import type { ParsedAdminTrialEventSearchInput } from "./parse-admin-trial-event-search-input"; export type ResolvedAdminTrialEventSearch = { @@ -15,7 +15,7 @@ export type ResolvedAdminTrialEventSearch = { function collectAvailableYears(availableEventDates: Date[]): number[] { return Array.from( - new Set(availableEventDates.map((value) => toTrialBusinessYear(value))), + new Set(availableEventDates.map((value) => toTrialDateOnlyYear(value))), ).sort((left, right) => right - left); } @@ -25,7 +25,7 @@ export async function resolveAdminTrialEventSearchResponseDb( const hasQuery = input.query.length > 0; if (input.mode === "year") { - const yearRange = getTrialBusinessYearUtcRange(input.year ?? 0); + const yearRange = getTrialDateOnlyYearUtcRange(input.year ?? 0); if (!yearRange) { throw new Error("Failed to build admin trial year range."); } @@ -98,7 +98,7 @@ export async function resolveAdminTrialEventSearchResponseDb( }; } - const yearRange = getTrialBusinessYearUtcRange(latestYear); + const yearRange = getTrialDateOnlyYearUtcRange(latestYear); if (!yearRange) { throw new Error("Failed to build admin trial year range."); } diff --git a/packages/server/admin/trials/manage/update-trial-entry.ts b/packages/server/admin/trials/manage/update-trial-entry.ts index cbfe5027..6fc7a48a 100644 --- a/packages/server/admin/trials/manage/update-trial-entry.ts +++ b/packages/server/admin/trials/manage/update-trial-entry.ts @@ -7,99 +7,94 @@ import { updateAdminTrialEntryWriteDb } 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 { + parseAdminTrialEntryWriteInput, + type AdminTrialEntryWriteValidationIssue, +} from "./internal/parse-admin-trial-entry-write-input"; type ServiceLogContext = { requestId?: string; actorUserId?: string; }; -const VALID_KOETYYPIT = new Set(["NORMAL", "KOKOKAUDENKOE", "PITKAKOE"]); -const VALID_HUOMAUTUKSET = new Set(["LUOPUI", "SULJETTU", "KESKEYTETTY"]); - -function isValidLisatietoCode(value: string): boolean { - return /^[1-9]\d*$/.test(value.trim()); -} - -function normalizeRequiredId(value: string): string { - return value.trim(); -} - -function normalizeNullableText( - value: string | null | undefined, -): string | null { - if (typeof value !== "string") { - return null; - } - const normalized = value.trim(); - return normalized.length > 0 ? normalized : null; -} - -function normalizeNullableInteger( - value: number | null | undefined, -): number | null { - if (value === null || typeof value === "undefined") { - return null; - } - return Number.isInteger(value) ? value : null; -} - -function isNullableInteger(value: unknown): value is number | null | undefined { - return ( - value === null || - typeof value === "undefined" || - (typeof value === "number" && - Number.isSafeInteger(value) && - Number.isFinite(value)) - ); -} - -function normalizeNullableNumber( - value: number | null | undefined, -): number | null { - if (value === null || typeof value === "undefined") { - return null; - } - return Number.isFinite(value) ? value : null; -} - -function isNullableFiniteNumber( - value: unknown, -): value is number | null | undefined { - return ( - value === null || - typeof value === "undefined" || - (typeof value === "number" && Number.isFinite(value)) - ); -} - -function hasContinuousEras(eras: number[]): boolean { - if (eras.length === 0) { - return false; - } - const sorted = [...eras].sort((left, right) => left - right); - if (sorted[0] !== 1) { - return false; - } - for (let index = 1; index < sorted.length; index += 1) { - if (sorted[index] !== sorted[index - 1] + 1) { - return false; - } +function validationFailure( + issue: AdminTrialEntryWriteValidationIssue, +): ServiceResult { + switch (issue.reason) { + case "missing_eras": + return badRequest("At least one era is required.", "INVALID_ERAS"); + case "invalid_era_number": + return badRequest( + "Era numbers must be positive safe integers.", + "INVALID_ERAS", + ); + case "duplicate_eras": + return badRequest( + "Duplicate era numbers are not allowed.", + "DUPLICATE_ERAS", + ); + case "non_continuous_eras": + return badRequest( + "Eras must be continuous starting from 1.", + "INVALID_ERAS", + ); + case "invalid_koetyyppi": + return badRequest("Unsupported koetyyppi.", "INVALID_KOETYYPPI"); + case "invalid_huomautus": + return badRequest("Unsupported huomautus.", "INVALID_HUOMAUTUS"); + case "invalid_entry_integer": + return badRequest( + "Integer fields must be safe integers or null.", + "INVALID_INTEGER_FIELD", + ); + case "invalid_entry_number": + return badRequest( + "Numeric fields must be finite numbers or null.", + "INVALID_NUMERIC_FIELD", + ); + case "invalid_era_integer": + return badRequest( + "Era integer fields must be safe integers or null.", + "INVALID_ERA_INTEGER_FIELD", + ); + case "invalid_era_number_field": + return badRequest( + "Era numeric fields must be finite numbers or null.", + "INVALID_ERA_NUMERIC_FIELD", + ); + case "invalid_lisatieto_code": + return badRequest( + `Unsupported lisatieto code: ${issue.value ?? ""}`, + "INVALID_LISATIETO_CODE", + ); + case "duplicate_lisatieto_key": + return badRequest( + "Duplicate lisatieto rows are not allowed.", + "INVALID_TRIAL_ADDITIONAL_INFO", + ); + case "invalid_lisatieto_era": + return badRequest( + "Lisatiedot era value references unknown era.", + "INVALID_LISATIETO_ERA", + ); + case "duplicate_lisatieto_era_value": + return badRequest( + "Duplicate lisatieto era values are not allowed.", + "INVALID_TRIAL_ADDITIONAL_INFO", + ); + case "invalid_lisatieto_order": + return badRequest( + "Lisatieto order must be a safe integer or null.", + "INVALID_INTEGER_FIELD", + ); } - return true; } function badRequest( error: string, code: string, ): ServiceResult { - return { - status: 400, - body: { - ok: false, - error, - code, - }, - }; + return { status: 400, body: { ok: false, error, code } }; } export async function updateAdminTrialEntry( @@ -108,8 +103,8 @@ export async function updateAdminTrialEntry( context?: ServiceLogContext, ): Promise> { const startedAt = Date.now(); - const trialEventId = normalizeRequiredId(input.trialEventId); - const trialEntryId = normalizeRequiredId(input.trialEntryId); + const trialEventId = input.trialEventId.trim(); + const trialEntryId = input.trialEntryId.trim(); const log = withLogContext({ layer: "service", useCase: "admin-trials.updateAdminTrialEntry", @@ -118,241 +113,28 @@ export async function updateAdminTrialEntry( }); if (!trialEventId) { - return { - status: 400, - body: { - ok: false, - error: "Trial event id is required.", - code: "INVALID_TRIAL_EVENT_ID", - }, - }; + return badRequest("Trial event id is required.", "INVALID_TRIAL_EVENT_ID"); } - if (!trialEntryId) { - return { - status: 400, - body: { - ok: false, - error: "Trial entry id is required.", - code: "INVALID_TRIAL_ENTRY_ID", - }, - }; + return badRequest("Trial entry id is required.", "INVALID_TRIAL_ENTRY_ID"); } const authResult = requireAdmin(currentUser); if (!authResult.body.ok) { - return { - status: authResult.status, - body: authResult.body, - }; - } - - if (!input.eras.length) { - return badRequest("At least one era is required.", "INVALID_ERAS"); - } - - const eraNumbers = input.eras.map((era) => era.era); - if ( - eraNumbers.some( - (era) => !Number.isSafeInteger(era) || !Number.isFinite(era) || era < 1, - ) - ) { - return badRequest( - "Era numbers must be positive safe integers.", - "INVALID_ERAS", - ); - } - - if (new Set(eraNumbers).size !== eraNumbers.length) { - return badRequest( - "Duplicate era numbers are not allowed.", - "DUPLICATE_ERAS", - ); - } - - if (!hasContinuousEras(eraNumbers)) { - return badRequest( - "Eras must be continuous starting from 1.", - "INVALID_ERAS", - ); - } - - if (!VALID_KOETYYPIT.has(input.entry.koetyyppi)) { - return badRequest("Unsupported koetyyppi.", "INVALID_KOETYYPPI"); - } - - if ( - input.entry.huomautus !== null && - typeof input.entry.huomautus !== "undefined" && - !VALID_HUOMAUTUKSET.has(input.entry.huomautus) - ) { - return badRequest("Unsupported huomautus.", "INVALID_HUOMAUTUS"); - } - - const entryIntegerFields = [ - input.entry.koiriaLuokassa, - input.entry.hyvaksytytAjominuutit, - ]; - if (!entryIntegerFields.every(isNullableInteger)) { - return badRequest( - "Integer fields must be safe integers or null.", - "INVALID_INTEGER_FIELD", - ); + return { status: authResult.status, body: authResult.body }; } - const entryNumberFields = [ - input.entry.points, - input.entry.ajoajanPisteet, - input.entry.haku, - input.entry.hauk, - input.entry.yva, - input.entry.hlo, - input.entry.alo, - input.entry.tja, - input.entry.pin, - input.entry.ansiopisteetYhteensa, - input.entry.tappiopisteetYhteensa, - ]; - if (!entryNumberFields.every(isNullableFiniteNumber)) { - return badRequest( - "Numeric fields must be finite numbers or null.", - "INVALID_NUMERIC_FIELD", - ); - } - - for (const era of input.eras) { - if (![era.hakumin, era.ajomin].every(isNullableInteger)) { - return badRequest( - "Era integer fields must be safe integers or null.", - "INVALID_ERA_INTEGER_FIELD", - ); - } - if ( - ![era.haku, era.hauk, era.yva, era.hlo, era.alo, era.tja, era.pin].every( - isNullableFiniteNumber, - ) - ) { - return badRequest( - "Era numeric fields must be finite numbers or null.", - "INVALID_ERA_NUMERIC_FIELD", - ); - } + const parsed = parseAdminTrialEntryWriteInput(input, { mode: "update" }); + if (!parsed.ok) { + return validationFailure(parsed.issue); } - for (const row of input.lisatiedotRows) { - const normalizedKoodi = normalizeNullableText(row.koodi); - if (!normalizedKoodi || !isValidLisatietoCode(normalizedKoodi)) { - return { - status: 400, - body: { - ok: false, - error: `Unsupported lisatieto code: ${row.koodi}`, - code: "INVALID_LISATIETO_CODE", - }, - }; - } - - for (const value of row.eraValues) { - if (!eraNumbers.includes(value.era)) { - return { - status: 400, - body: { - ok: false, - error: "Lisatiedot era value references unknown era.", - code: "INVALID_LISATIETO_ERA", - }, - }; - } - } - } - - const lisatiedotByEra = eraNumbers.map((era) => ({ - era, - replaceKeys: input.lisatiedotRows.map((row) => ({ - koodi: normalizeNullableText(row.koodi) ?? row.koodi, - osa: normalizeNullableText(row.osa) ?? "", - })), - items: input.lisatiedotRows - .map((row) => { - const eraValue = row.eraValues.find((value) => value.era === era); - const normalizedArvo = normalizeNullableText(eraValue?.arvo ?? null); - if (!normalizedArvo) { - return null; - } - return { - koodi: normalizeNullableText(row.koodi) ?? row.koodi, - osa: normalizeNullableText(row.osa) ?? "", - arvo: normalizedArvo, - nimi: normalizeNullableText(row.nimi), - jarjestys: normalizeNullableInteger(row.jarjestys), - }; - }) - .filter((item): item is NonNullable => item !== null), - })); - try { const result = await updateAdminTrialEntryWriteDb({ trialEventId, trialEntryId, - entry: { - koemaasto: normalizeNullableText(input.entry.koemaasto), - koemuoto: normalizeNullableText(input.entry.koemuoto), - koetyyppi: input.entry.koetyyppi, - ke: normalizeNullableText(input.entry.ke), - lk: normalizeNullableText(input.entry.lk), - award: normalizeNullableText(input.entry.award), - rank: normalizeNullableText(input.entry.rank), - points: normalizeNullableNumber(input.entry.points), - koiriaLuokassa: normalizeNullableInteger(input.entry.koiriaLuokassa), - hyvaksytytAjominuutit: normalizeNullableInteger( - input.entry.hyvaksytytAjominuutit, - ), - ajoajanPisteet: normalizeNullableNumber(input.entry.ajoajanPisteet), - haku: normalizeNullableNumber(input.entry.haku), - hauk: normalizeNullableNumber(input.entry.hauk), - yva: normalizeNullableNumber(input.entry.yva), - hlo: normalizeNullableNumber(input.entry.hlo), - alo: normalizeNullableNumber(input.entry.alo), - tja: normalizeNullableNumber(input.entry.tja), - pin: normalizeNullableNumber(input.entry.pin), - ansiopisteetYhteensa: normalizeNullableNumber( - input.entry.ansiopisteetYhteensa, - ), - tappiopisteetYhteensa: normalizeNullableNumber( - input.entry.tappiopisteetYhteensa, - ), - judge: normalizeNullableText(input.entry.judge), - huomautus: input.entry.huomautus ?? null, - huomautusTeksti: normalizeNullableText(input.entry.huomautusTeksti), - ylituomariNumeroSnapshot: normalizeNullableText( - input.entry.ylituomariNumeroSnapshot, - ), - ryhmatuomariNimi: normalizeNullableText(input.entry.ryhmatuomariNimi), - palkintotuomariNimi: normalizeNullableText( - input.entry.palkintotuomariNimi, - ), - omistajaSnapshot: normalizeNullableText(input.entry.omistajaSnapshot), - omistajanKotikuntaSnapshot: normalizeNullableText( - input.entry.omistajanKotikuntaSnapshot, - ), - }, - eras: input.eras.map((era) => ({ - era: era.era, - alkoi: normalizeNullableText(era.alkoi), - hakumin: normalizeNullableInteger(era.hakumin), - ajomin: normalizeNullableInteger(era.ajomin), - haku: normalizeNullableNumber(era.haku), - hauk: normalizeNullableNumber(era.hauk), - yva: normalizeNullableNumber(era.yva), - hlo: normalizeNullableNumber(era.hlo), - alo: normalizeNullableNumber(era.alo), - tja: normalizeNullableNumber(era.tja), - pin: normalizeNullableNumber(era.pin), - huomautusTeksti: normalizeNullableText(era.huomautusTeksti), - })), - lisatiedotByEra, + ...parsed.data, }); - if (result.status === "not_found") { return { status: 404, @@ -363,7 +145,6 @@ export async function updateAdminTrialEntry( }, }; } - return { status: 200, body: { diff --git a/packages/server/index.ts b/packages/server/index.ts index 9be0bf62..0d9de9c5 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -46,6 +46,7 @@ export { upsertKoiratietokantaAjokResultService } from "./trials"; export { listAdminTrialEvents } from "./admin"; export { getAdminTrialEvent } from "./admin"; export { createAdminTrialEvent } from "./admin"; +export { createAdminTrialEntry } from "./admin"; export { deleteAdminTrialEvent } from "./admin"; export { deleteAdminTrialEntry } from "./admin"; export { updateAdminTrialEvent } from "./admin"; diff --git a/packages/server/trials/__tests__/service.test.ts b/packages/server/trials/__tests__/service.test.ts index 9dea6a4e..a8e65b01 100644 --- a/packages/server/trials/__tests__/service.test.ts +++ b/packages/server/trials/__tests__/service.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createTrialsService } from "../service"; -import { getTrialBusinessDateStartUtc } from "../core/business-date"; +import { getTrialDateOnlyStartUtc } from "../core/date-only"; const { searchBeagleTrialsDbMock, getBeagleTrialDetailsDbMock } = vi.hoisted( () => ({ @@ -97,8 +97,8 @@ describe("trials service", () => { sort: "date-desc", }); expect(searchBeagleTrialsDbMock).toHaveBeenNthCalledWith(2, { - dateFrom: new Date("2024-12-31T22:00:00.000Z"), - dateTo: new Date("2025-12-31T22:00:00.000Z"), + dateFrom: new Date("2025-01-01T00:00:00.000Z"), + dateTo: new Date("2026-01-01T00:00:00.000Z"), page: 1, pageSize: 10, sort: "date-desc", @@ -131,8 +131,8 @@ describe("trials service", () => { dateTo: "2026-06-30", }); expect(searchBeagleTrialsDbMock).toHaveBeenCalledWith({ - dateFrom: getTrialBusinessDateStartUtc("2026-06-01"), - dateTo: getTrialBusinessDateStartUtc("2026-07-01"), + dateFrom: getTrialDateOnlyStartUtc("2026-06-01"), + dateTo: getTrialDateOnlyStartUtc("2026-07-01"), page: 1, pageSize: 10, sort: "date-asc", diff --git a/packages/server/trials/core/__tests__/trial-entry-identity.test.ts b/packages/server/trials/core/__tests__/trial-entry-identity.test.ts new file mode 100644 index 00000000..06336517 --- /dev/null +++ b/packages/server/trials/core/__tests__/trial-entry-identity.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + isValidTrialRegistrationNo, + normalizeTrialRegistrationNo, +} from "@server/trials/core/trial-entry-identity"; + +describe("trial entry registration-number helpers", () => { + it.each([ + "FI12345/21", + "FI12345A/21", + "EST.REG-12345", + "ABC/DEF/1234/20", + "ÅÄÖ123/24", + "FI١٢٣/٢٤", + ])("accepts supported international registration format %s", (value) => { + expect(isValidTrialRegistrationNo(value)).toBe(true); + }); + + it.each(["", "BAD VALUE", "FI_123/24", "FI:123/24", "FI@123/24"])( + "rejects unsupported registration format %s", + (value) => { + expect(isValidTrialRegistrationNo(value)).toBe(false); + }, + ); + + it("trims and uppercases registration numbers", () => { + expect(normalizeTrialRegistrationNo(" fi.ab-123/24 ")).toBe( + "FI.AB-123/24", + ); + }); + + it.each([null, "", " "])("normalizes empty value %s to null", (value) => { + expect(normalizeTrialRegistrationNo(value)).toBeNull(); + }); +}); diff --git a/packages/server/trials/core/business-date.ts b/packages/server/trials/core/business-date.ts deleted file mode 100644 index 365b7d85..00000000 --- a/packages/server/trials/core/business-date.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { BUSINESS_TIME_ZONE, toBusinessDateOnly } from "@server/core/date-only"; - -function parseIsoDateOnlyParts(value: string): { - year: number; - month: number; - day: number; -} | null { - const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(value); - if (!match) { - return null; - } - - const year = Number.parseInt(match[1], 10); - const month = Number.parseInt(match[2], 10); - const day = Number.parseInt(match[3], 10); - if ( - !Number.isFinite(year) || - !Number.isFinite(month) || - !Number.isFinite(day) - ) { - return null; - } - - const utcDate = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0)); - if ( - utcDate.getUTCFullYear() !== year || - utcDate.getUTCMonth() !== month - 1 || - utcDate.getUTCDate() !== day - ) { - return null; - } - - return { year, month, day }; -} - -function getTimeZoneOffsetMs(date: Date, timeZone: string): number { - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hourCycle: "h23", - hour12: false, - }); - const parts = formatter.formatToParts(date); - const year = Number.parseInt( - parts.find((part) => part.type === "year")?.value ?? "0", - 10, - ); - const month = Number.parseInt( - parts.find((part) => part.type === "month")?.value ?? "0", - 10, - ); - const day = Number.parseInt( - parts.find((part) => part.type === "day")?.value ?? "0", - 10, - ); - const hour = Number.parseInt( - parts.find((part) => part.type === "hour")?.value ?? "0", - 10, - ); - const minute = Number.parseInt( - parts.find((part) => part.type === "minute")?.value ?? "0", - 10, - ); - const second = Number.parseInt( - parts.find((part) => part.type === "second")?.value ?? "0", - 10, - ); - - const normalizedHour = hour === 24 ? 0 : hour; - const asUtc = Date.UTC( - year, - month - 1, - day, - normalizedHour, - minute, - second, - 0, - ); - return asUtc - date.getTime(); -} - -function toBusinessDateStartUtc(isoDate: string): Date | null { - const parsed = parseIsoDateOnlyParts(isoDate); - if (!parsed) { - return null; - } - - const midnightAsUtcMs = Date.UTC( - parsed.year, - parsed.month - 1, - parsed.day, - 0, - 0, - 0, - 0, - ); - let utcTimeMs = midnightAsUtcMs; - - for (let index = 0; index < 3; index += 1) { - const offsetMs = getTimeZoneOffsetMs( - new Date(utcTimeMs), - BUSINESS_TIME_ZONE, - ); - utcTimeMs = midnightAsUtcMs - offsetMs; - } - - return new Date(utcTimeMs); -} - -function addIsoDateDays(isoDate: string, days: number): string | null { - const parsed = parseIsoDateOnlyParts(isoDate); - if (!parsed) { - return null; - } - const base = new Date( - Date.UTC(parsed.year, parsed.month - 1, parsed.day, 0, 0, 0, 0), - ); - base.setUTCDate(base.getUTCDate() + days); - return base.toISOString().slice(0, 10); -} - -export function getTrialBusinessDateStartUtc(isoDate: string): Date | null { - return toBusinessDateStartUtc(isoDate); -} - -export function getTrialBusinessYearUtcRange(year: number): { - start: Date; - endExclusive: Date; -} | null { - const start = toBusinessDateStartUtc(`${year}-01-01`); - const endExclusive = toBusinessDateStartUtc(`${year + 1}-01-01`); - if (!start || !endExclusive) { - return null; - } - - return { start, endExclusive }; -} - -export function getTrialBusinessDateUtcRange(value: Date): { - start: Date; - endExclusive: Date; -} { - const isoDate = toBusinessDateOnly(value); - const start = toBusinessDateStartUtc(isoDate); - const nextIsoDate = addIsoDateDays(isoDate, 1); - const endExclusive = nextIsoDate ? toBusinessDateStartUtc(nextIsoDate) : null; - - if (!start || !endExclusive) { - throw new Error("Failed to build business date UTC range."); - } - - return { start, endExclusive }; -} - -export function toTrialBusinessYear(value: Date): number { - const year = toBusinessDateOnly(value).slice(0, 4); - const parsed = Number.parseInt(year, 10); - return Number.isFinite(parsed) ? parsed : value.getUTCFullYear(); -} diff --git a/packages/server/trials/core/date-only.ts b/packages/server/trials/core/date-only.ts new file mode 100644 index 00000000..5c71934f --- /dev/null +++ b/packages/server/trials/core/date-only.ts @@ -0,0 +1,39 @@ +import { parseIsoDateOnlyToUtcDate } from "@server/trials/internal/iso-date"; + +function addUtcDays(value: Date, days: number): Date { + const result = new Date(value.getTime()); + result.setUTCDate(result.getUTCDate() + days); + return result; +} + +// Trial dates are PostgreSQL DATE values represented by Prisma as UTC carriers. +export function getTrialDateOnlyStartUtc(isoDate: string): Date | null { + return parseIsoDateOnlyToUtcDate(isoDate); +} + +export function getTrialDateOnlyYearUtcRange(year: number): { + start: Date; + endExclusive: Date; +} | null { + const start = parseIsoDateOnlyToUtcDate(`${year}-01-01`); + const endExclusive = parseIsoDateOnlyToUtcDate(`${year + 1}-01-01`); + return start && endExclusive ? { start, endExclusive } : null; +} + +export function getTrialDateOnlyUtcRange(value: Date): { + start: Date; + endExclusive: Date; +} { + const start = new Date( + Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()), + ); + return { start, endExclusive: addUtcDays(start, 1) }; +} + +export function toTrialDateOnlyYear(value: Date): number { + return value.getUTCFullYear(); +} + +export function formatTrialDateOnly(value: Date): string { + return value.toISOString().slice(0, 10); +} diff --git a/packages/server/trials/core/index.ts b/packages/server/trials/core/index.ts index 96c7bd52..3da7f9a2 100644 --- a/packages/server/trials/core/index.ts +++ b/packages/server/trials/core/index.ts @@ -1,3 +1,7 @@ export { formatTrialAward } from "./format-trial-award"; -export { getTrialBusinessDateStartUtc } from "./business-date"; +export { getTrialDateOnlyStartUtc, formatTrialDateOnly } from "./date-only"; export { resolveTrialRuleWindowId } from "./trial-rule-window"; +export { + isValidTrialRegistrationNo, + normalizeTrialRegistrationNo, +} from "./trial-entry-identity"; diff --git a/packages/server/trials/core/trial-entry-identity.ts b/packages/server/trials/core/trial-entry-identity.ts new file mode 100644 index 00000000..95400699 --- /dev/null +++ b/packages/server/trials/core/trial-entry-identity.ts @@ -0,0 +1,14 @@ +// Registration identifiers vary by country, so validate a safe character set +// instead of enforcing a country-specific shape. +const REGISTRATION_NO_PATTERN = /^[\p{L}\p{N}/.-]+$/u; + +export function normalizeTrialRegistrationNo( + value: string | null, +): string | null { + const normalized = value?.trim().toUpperCase() ?? ""; + return normalized.length > 0 ? normalized : null; +} + +export function isValidTrialRegistrationNo(value: string): boolean { + return REGISTRATION_NO_PATTERN.test(value); +} diff --git a/packages/server/trials/get-beagle-trial-details.ts b/packages/server/trials/get-beagle-trial-details.ts index f5be96ce..fc10ccab 100644 --- a/packages/server/trials/get-beagle-trial-details.ts +++ b/packages/server/trials/get-beagle-trial-details.ts @@ -1,6 +1,6 @@ import { getBeagleTrialDetailsDb } from "@beagle/db"; import type { BeagleTrialDetailsResponse } from "@beagle/contracts"; -import { toBusinessDateOnly } from "../core/date-only"; +import { formatTrialDateOnly } from "./core/date-only"; import { toErrorLog, withLogContext } from "../core/logger"; import type { ServiceResult } from "../core/result"; import { formatTrialAward } from "./core"; @@ -60,7 +60,7 @@ export async function getBeagleTrialDetailsService( const data: BeagleTrialDetailsResponse = { trial: { trialId: result.trialEventId, - eventDate: toBusinessDateOnly(result.eventDate), + eventDate: formatTrialDateOnly(result.eventDate), eventPlace: result.eventPlace, judge: result.judge, dogCount: result.dogCount, 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 ce205de9..1f272e68 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 @@ -7,6 +7,8 @@ const { upsertDbMock, listActiveRuleWindowsMock } = vi.hoisted(() => ({ })); vi.mock("@beagle/db", () => ({ + buildTrialEntryIdentity: (sklKoeId: number, registrationNo: string) => + `SKL:${sklKoeId}|REG:${registrationNo}`, DogSex: { MALE: "MALE", FEMALE: "FEMALE", diff --git a/packages/server/trials/integrations/koiratietokanta/internal/map-ajok-payload.ts b/packages/server/trials/integrations/koiratietokanta/internal/map-ajok-payload.ts index 32114061..2afb0de0 100644 --- a/packages/server/trials/integrations/koiratietokanta/internal/map-ajok-payload.ts +++ b/packages/server/trials/integrations/koiratietokanta/internal/map-ajok-payload.ts @@ -3,16 +3,18 @@ import { type KoiratietokantaAjokValidationIssue, type KoiratietokantaAjokWarning, } from "@beagle/contracts"; -import type { - KoiratietokantaAjokEntryDbInput, - KoiratietokantaAjokEraDbInput, - KoiratietokantaAjokEventDbInput, +import { + buildTrialEntryIdentity, + type KoiratietokantaAjokEntryDbInput, + type KoiratietokantaAjokEraDbInput, + type KoiratietokantaAjokEventDbInput, + TrialEntryHuomautus, + TrialEntryKoetyyppi, } from "@beagle/db"; -import { TrialEntryHuomautus, TrialEntryKoetyyppi } from "@beagle/db"; import { - isValidRegistrationNo, - normalizeRegistrationNo, -} from "@server/imports/core"; + isValidTrialRegistrationNo, + normalizeTrialRegistrationNo, +} from "@server/trials/core"; import { mapKoiratietokantaAjokEraWrites } from "./map-ajok-era-writes"; import { isRecord, @@ -83,10 +85,10 @@ export function mapKoiratietokantaAjokPayload( }); } - const registrationNo = normalizeRegistrationNo( + const registrationNo = normalizeTrialRegistrationNo( normalizeText(payload.REKISTERINUMERO), ); - if (!registrationNo || !isValidRegistrationNo(registrationNo)) { + if (!registrationNo || !isValidTrialRegistrationNo(registrationNo)) { issues.push({ field: "REKISTERINUMERO", code: registrationNo ? "INVALID" : "REQUIRED", @@ -185,7 +187,7 @@ export function mapKoiratietokantaAjokPayload( ]); const entry: KoiratietokantaAjokEntryDbInput = { rekisterinumeroSnapshot: registrationNo, - yksilointiAvain: `SKL:${sklKoeId}|REG:${registrationNo}`, + yksilointiAvain: buildTrialEntryIdentity(sklKoeId, registrationNo), raakadataJson: JSON.stringify(payload), luokka: normalizeText(payload.LUOKKA), omistajaSnapshot: normalizeText(payload.Omistaja), diff --git a/packages/server/trials/internal/__tests__/business-date.test.ts b/packages/server/trials/internal/__tests__/business-date.test.ts deleted file mode 100644 index 64fea4a8..00000000 --- a/packages/server/trials/internal/__tests__/business-date.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getTrialBusinessDateStartUtc, - getTrialBusinessDateUtcRange, - getTrialBusinessYearUtcRange, - toTrialBusinessYear, -} from "@server/trials/core/business-date"; - -describe("business-date helpers", () => { - it("builds a business date start from an iso date", () => { - expect(getTrialBusinessDateStartUtc("2025-06-01")?.toISOString()).toBe( - "2025-05-31T21:00:00.000Z", - ); - }); - - it("returns null for invalid iso dates", () => { - expect(getTrialBusinessDateStartUtc("2025-13-01")).toBeNull(); - }); - - it("builds a business date utc range", () => { - const range = getTrialBusinessDateUtcRange( - new Date("2025-06-01T00:00:00.000Z"), - ); - - expect(range.start.toISOString()).toBe("2025-05-31T21:00:00.000Z"); - expect(range.endExclusive.toISOString()).toBe("2025-06-01T21:00:00.000Z"); - }); - - it("builds a business year utc range and year", () => { - const range = getTrialBusinessYearUtcRange(2025); - - expect(range?.start.toISOString()).toBe("2024-12-31T22:00:00.000Z"); - expect(range?.endExclusive.toISOString()).toBe("2025-12-31T22:00:00.000Z"); - expect(toTrialBusinessYear(new Date("2025-06-01T00:00:00.000Z"))).toBe( - 2025, - ); - }); -}); diff --git a/packages/server/trials/internal/__tests__/date-only.test.ts b/packages/server/trials/internal/__tests__/date-only.test.ts new file mode 100644 index 00000000..3674ef0d --- /dev/null +++ b/packages/server/trials/internal/__tests__/date-only.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + getTrialDateOnlyStartUtc, + getTrialDateOnlyUtcRange, + getTrialDateOnlyYearUtcRange, + toTrialDateOnlyYear, +} from "@server/trials/core/date-only"; + +describe("trial date-only helpers", () => { + it("builds a date-only UTC carrier from an ISO date", () => { + expect(getTrialDateOnlyStartUtc("2025-06-01")?.toISOString()).toBe( + "2025-06-01T00:00:00.000Z", + ); + }); + + it("returns null for invalid ISO dates", () => { + expect(getTrialDateOnlyStartUtc("2025-13-01")).toBeNull(); + }); + + it("builds an inclusive day range with an exclusive next-day boundary", () => { + const range = getTrialDateOnlyUtcRange( + new Date("2025-06-01T00:00:00.000Z"), + ); + + expect(range.start.toISOString()).toBe("2025-06-01T00:00:00.000Z"); + expect(range.endExclusive.toISOString()).toBe("2025-06-02T00:00:00.000Z"); + }); + + it("builds a date-only year range and reads its UTC carrier year", () => { + const range = getTrialDateOnlyYearUtcRange(2025); + + expect(range?.start.toISOString()).toBe("2025-01-01T00:00:00.000Z"); + expect(range?.endExclusive.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + expect(toTrialDateOnlyYear(new Date("2025-06-01T00:00:00.000Z"))).toBe( + 2025, + ); + }); +}); diff --git a/packages/server/trials/search-beagle-trials.ts b/packages/server/trials/search-beagle-trials.ts index 390e445b..a9247a18 100644 --- a/packages/server/trials/search-beagle-trials.ts +++ b/packages/server/trials/search-beagle-trials.ts @@ -4,14 +4,14 @@ import type { BeagleTrialSearchMode, BeagleTrialSearchResponse, } from "@beagle/contracts"; -import { toBusinessDateOnly } from "../core/date-only"; import { toErrorLog, withLogContext } from "../core/logger"; import type { ServiceResult } from "../core/result"; import { - getTrialBusinessDateUtcRange, - getTrialBusinessYearUtcRange, - toTrialBusinessYear, -} from "./core/business-date"; + getTrialDateOnlyUtcRange, + getTrialDateOnlyYearUtcRange, + formatTrialDateOnly, + toTrialDateOnlyYear, +} from "./core/date-only"; import { parseIsoDateOnly } from "./internal/iso-date"; import type { TrialsServiceLogContext } from "./types"; @@ -49,7 +49,7 @@ function parseYear(value: number | undefined): number | null { function collectAvailableYears(availableEventDates: Date[]): number[] { return Array.from( - new Set(availableEventDates.map((value) => toTrialBusinessYear(value))), + new Set(availableEventDates.map((value) => toTrialDateOnlyYear(value))), ).sort((left, right) => right - left); } @@ -187,11 +187,10 @@ export async function searchBeagleTrialsService( try { const rangeFromDate = dateFromIso - ? getTrialBusinessDateUtcRange(new Date(`${dateFromIso}T00:00:00.000Z`)) - .start + ? getTrialDateOnlyUtcRange(new Date(`${dateFromIso}T00:00:00.000Z`)).start : null; const rangeToExclusive = dateToIso - ? getTrialBusinessDateUtcRange(new Date(`${dateToIso}T00:00:00.000Z`)) + ? getTrialDateOnlyUtcRange(new Date(`${dateToIso}T00:00:00.000Z`)) .endExclusive : null; @@ -204,7 +203,7 @@ export async function searchBeagleTrialsService( let result: Awaited>; if (resolvedMode === "year") { - const yearRange = getTrialBusinessYearUtcRange(year ?? 0); + const yearRange = getTrialDateOnlyYearUtcRange(year ?? 0); if (!yearRange) { throw new Error("Failed to build trial year range."); } @@ -240,7 +239,7 @@ export async function searchBeagleTrialsService( if (!latestYear) { result = available; } else { - const yearRange = getTrialBusinessYearUtcRange(latestYear); + const yearRange = getTrialDateOnlyYearUtcRange(latestYear); if (!yearRange) { throw new Error("Failed to build trial year range."); } @@ -270,7 +269,7 @@ export async function searchBeagleTrialsService( page: result.page, items: result.items.map((item) => ({ trialId: item.trialEventId, - eventDate: toBusinessDateOnly(item.eventDate), + eventDate: formatTrialDateOnly(item.eventDate), eventPlace: item.eventPlace, judge: item.judge, dogCount: item.dogCount, From ef0d0b07de89676eaa1670190f528281a22c5705 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:51:00 +0300 Subject: [PATCH 4/5] fix(trials): harden manual entry validation and conflict detection ref bej-103 --- .../__tests__/create-trial-entry.test.ts | 49 ++++++++++++----- .../admin/trials/manage/create-trial-entry.ts | 26 +++++---- .../__tests__/create-trial-entry.test.ts | 16 ++++++ .../admin/trials/manage/create-trial-entry.ts | 13 ++--- ...arse-admin-trial-entry-write-input.test.ts | 51 ++++++++++++++++++ .../parse-admin-trial-entry-write-input.ts | 54 +++++++++++++++++-- .../admin/trials/manage/update-trial-entry.ts | 11 +++- .../dogs/core/__tests__/registration.test.ts | 36 +++++++++++++ packages/server/dogs/core/index.ts | 1 + packages/server/dogs/core/registration.ts | 12 +++++ .../__tests__/trial-entry-identity.test.ts | 35 ------------ packages/server/trials/core/index.ts | 4 -- .../internal/map-ajok-payload.ts | 10 ++-- 13 files changed, 239 insertions(+), 79 deletions(-) create mode 100644 packages/server/dogs/core/__tests__/registration.test.ts create mode 100644 packages/server/dogs/core/registration.ts delete mode 100644 packages/server/trials/core/__tests__/trial-entry-identity.test.ts diff --git a/packages/db/admin/trials/manage/__tests__/create-trial-entry.test.ts b/packages/db/admin/trials/manage/__tests__/create-trial-entry.test.ts index 2aa021e8..efe48ba0 100644 --- a/packages/db/admin/trials/manage/__tests__/create-trial-entry.test.ts +++ b/packages/db/admin/trials/manage/__tests__/create-trial-entry.test.ts @@ -80,6 +80,14 @@ const input = { ], }; +function uniqueConstraintError(target: string | string[]) { + return new Prisma.PrismaClientKnownRequestError("duplicate", { + code: "P2002", + clientVersion: "test", + meta: { target }, + }); +} + describe("createAdminTrialEntryWriteDb", () => { beforeEach(() => { vi.clearAllMocks(); @@ -145,16 +153,33 @@ describe("createAdminTrialEntryWriteDb", () => { }); }); - it("maps a database identity constraint race to registration conflict", async () => { - prismaTransaction.mockRejectedValueOnce( - new Prisma.PrismaClientKnownRequestError("duplicate", { - code: "P2002", - clientVersion: "test", - meta: { target: ["trialEventId", "rekisterinumeroSnapshot"] }, - }), - ); - await expect(createAdminTrialEntryWriteDb(input)).resolves.toEqual({ - status: "registration_conflict", - }); - }); + it.each([ + [["yksilointiAvain"]], + [["trialEventId", "rekisterinumeroSnapshot"]], + ["TrialEntry_yksilointiAvain_key"], + ["TrialEntry_trialEventId_rekisterinumeroSnapshot_key"], + ])( + "maps identity constraint target %j to registration conflict", + async (target) => { + prismaTransaction.mockRejectedValueOnce(uniqueConstraintError(target)); + await expect(createAdminTrialEntryWriteDb(input)).resolves.toEqual({ + status: "registration_conflict", + }); + }, + ); + + it.each([ + [["trialEntryId", "era"]], + [["trialEraId", "koodi", "osa"]], + [["trialEventId", "futureIdentityField"]], + [["futureIdentityField", "rekisterinumeroSnapshot"]], + ["TrialEntry_trialEventId_futureIdentityField_key"], + ])( + "does not map unrelated constraint target %j to registration conflict", + async (target) => { + const error = uniqueConstraintError(target); + prismaTransaction.mockRejectedValueOnce(error); + await expect(createAdminTrialEntryWriteDb(input)).rejects.toBe(error); + }, + ); }); diff --git a/packages/db/admin/trials/manage/create-trial-entry.ts b/packages/db/admin/trials/manage/create-trial-entry.ts index 2b6180ed..29b4350b 100644 --- a/packages/db/admin/trials/manage/create-trial-entry.ts +++ b/packages/db/admin/trials/manage/create-trial-entry.ts @@ -27,16 +27,22 @@ function isEntryIdentityConflict(error: unknown): boolean { return false; } const target = error.meta?.target; - const fields = Array.isArray(target) - ? target.map(String) - : [String(target ?? "")]; - return fields.some( - (field) => - field.includes("yksilointiAvain") || - field.includes("trialEventId_rekisterinumeroSnapshot") || - field.includes("trialEventId") || - field.includes("rekisterinumeroSnapshot"), - ); + if (Array.isArray(target)) { + const fields = target.map(String); + return ( + (fields.length === 1 && fields[0] === "yksilointiAvain") || + (fields.length === 2 && + fields.includes("trialEventId") && + fields.includes("rekisterinumeroSnapshot")) + ); + } + + return [ + "yksilointiAvain", + "trialEventId_rekisterinumeroSnapshot", + "TrialEntry_yksilointiAvain_key", + "TrialEntry_trialEventId_rekisterinumeroSnapshot_key", + ].includes(String(target ?? "")); } // Creates one complete manual result and all nested rows atomically. diff --git a/packages/server/admin/trials/manage/__tests__/create-trial-entry.test.ts b/packages/server/admin/trials/manage/__tests__/create-trial-entry.test.ts index d3eb4ae5..b5b8d861 100644 --- a/packages/server/admin/trials/manage/__tests__/create-trial-entry.test.ts +++ b/packages/server/admin/trials/manage/__tests__/create-trial-entry.test.ts @@ -119,6 +119,22 @@ describe("createAdminTrialEntry", () => { }); }); + it("maps malformed runtime input to a stable validation error", async () => { + await expect( + createAdminTrialEntry( + { + ...input, + entry: null, + } as unknown as CreateAdminTrialEntryRequest, + admin, + ), + ).resolves.toMatchObject({ + status: 400, + body: { ok: false, code: "INVALID_TRIAL_ENTRY" }, + }); + expect(writeMock).not.toHaveBeenCalled(); + }); + it("rejects duplicate normalized lisatieto keys before persistence", async () => { const row = { koodi: "11", diff --git a/packages/server/admin/trials/manage/create-trial-entry.ts b/packages/server/admin/trials/manage/create-trial-entry.ts index 2d963c06..242c5ce8 100644 --- a/packages/server/admin/trials/manage/create-trial-entry.ts +++ b/packages/server/admin/trials/manage/create-trial-entry.ts @@ -8,9 +8,9 @@ import { requireAdmin } from "@server/admin/core/service"; import { toErrorLog, withLogContext } from "@server/core/logger"; import type { ServiceResult } from "@server/core/result"; import { - isValidTrialRegistrationNo, - normalizeTrialRegistrationNo, -} from "@server/trials/core"; + isValidRegistrationNo, + normalizeRegistrationNo, +} from "@server/dogs/core"; import { parseAdminTrialEntryWriteInput } from "./internal/parse-admin-trial-entry-write-input"; type ServiceLogContext = { requestId?: string; actorUserId?: string }; @@ -43,7 +43,8 @@ export async function createAdminTrialEntry( return { status: auth.status, body: auth.body }; } - const trialEventId = input.trialEventId.trim(); + const trialEventId = + typeof input?.trialEventId === "string" ? input.trialEventId.trim() : ""; if (!trialEventId) { log.warn( { event: "invalid_trial_event_id" }, @@ -59,8 +60,8 @@ export async function createAdminTrialEntry( }; } - const registrationNo = normalizeTrialRegistrationNo(input.registrationNo); - if (!registrationNo || !isValidTrialRegistrationNo(registrationNo)) { + const registrationNo = normalizeRegistrationNo(input?.registrationNo); + if (!registrationNo || !isValidRegistrationNo(registrationNo)) { log.warn( { event: "invalid_registration_number", trialEventId }, "admin trial entry creation rejected before persistence", diff --git a/packages/server/admin/trials/manage/internal/__tests__/parse-admin-trial-entry-write-input.test.ts b/packages/server/admin/trials/manage/internal/__tests__/parse-admin-trial-entry-write-input.test.ts index c664256b..47c1760e 100644 --- a/packages/server/admin/trials/manage/internal/__tests__/parse-admin-trial-entry-write-input.test.ts +++ b/packages/server/admin/trials/manage/internal/__tests__/parse-admin-trial-entry-write-input.test.ts @@ -63,6 +63,23 @@ function createInput(): AdminTrialEntryWriteData { } describe("parseAdminTrialEntryWriteInput", () => { + it.each([ + null, + {}, + { entry: {}, eras: null, lisatiedotRows: [] }, + { entry: {}, eras: [], lisatiedotRows: [{}] }, + ])("rejects malformed write container %#", (input) => { + expect( + parseAdminTrialEntryWriteInput( + input as unknown as AdminTrialEntryWriteData, + { mode: "create" }, + ), + ).toEqual({ + ok: false, + issue: { area: "entry", reason: "invalid_write_shape" }, + }); + }); + it("normalizes the shared write shape", () => { const result = parseAdminTrialEntryWriteInput(createInput(), { mode: "create", @@ -103,6 +120,26 @@ describe("parseAdminTrialEntryWriteInput", () => { }); }); + it("rejects integers outside the PostgreSQL Int range", () => { + const input = createInput(); + input.entry.koiriaLuokassa = 2_147_483_648; + + expect(parseAdminTrialEntryWriteInput(input, { mode: "create" })).toEqual({ + ok: false, + issue: { area: "entry", reason: "invalid_entry_integer" }, + }); + }); + + it("rejects numbers outside the Decimal(6,2) range", () => { + const input = createInput(); + input.entry.points = 10_000; + + expect(parseAdminTrialEntryWriteInput(input, { mode: "create" })).toEqual({ + ok: false, + issue: { area: "entry", reason: "invalid_entry_number" }, + }); + }); + it.each([ { koodi: " 11 ", osa: " " }, { koodi: "11", osa: "" }, @@ -167,4 +204,18 @@ describe("parseAdminTrialEntryWriteInput", () => { }, }); }); + + it("normalizes out-of-range update ordering to null", () => { + const input = createInput(); + input.lisatiedotRows[0].jarjestys = 2_147_483_648; + + expect( + parseAdminTrialEntryWriteInput(input, { mode: "update" }), + ).toMatchObject({ + ok: true, + data: { + lisatiedotByEra: [{ items: [{ jarjestys: null }] }], + }, + }); + }); }); diff --git a/packages/server/admin/trials/manage/internal/parse-admin-trial-entry-write-input.ts b/packages/server/admin/trials/manage/internal/parse-admin-trial-entry-write-input.ts index c5ed8f64..ffedf754 100644 --- a/packages/server/admin/trials/manage/internal/parse-admin-trial-entry-write-input.ts +++ b/packages/server/admin/trials/manage/internal/parse-admin-trial-entry-write-input.ts @@ -2,6 +2,7 @@ import type { AdminTrialEntryWriteData } from "@beagle/contracts"; import type { AdminTrialEntryWriteDataDb } from "@beagle/db"; export type AdminTrialEntryWriteValidationReason = + | "invalid_write_shape" | "invalid_koetyyppi" | "invalid_huomautus" | "invalid_entry_integer" @@ -26,6 +27,31 @@ export type AdminTrialEntryWriteValidationIssue = { const VALID_KOETYYPIT = new Set(["NORMAL", "KOKOKAUDENKOE", "PITKAKOE"]); const VALID_HUOMAUTUKSET = new Set(["LUOPUI", "SULJETTU", "KESKEYTETTY"]); +const MAX_DB_INT = 2_147_483_647; +const MIN_DB_INT = -2_147_483_648; +const MAX_DECIMAL_6_2 = 9_999.99; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function hasWriteContainerShape(input: unknown): boolean { + if (!isRecord(input)) return false; + if (!isRecord(input.entry)) return false; + if (!Array.isArray(input.eras) || !input.eras.every(isRecord)) return false; + if ( + !Array.isArray(input.lisatiedotRows) || + !input.lisatiedotRows.every( + (row) => + isRecord(row) && + Array.isArray(row.eraValues) && + row.eraValues.every(isRecord), + ) + ) { + return false; + } + return true; +} function normalizeNullableText( value: string | null | undefined, @@ -37,23 +63,38 @@ function normalizeNullableText( function normalizeNullableInteger( value: number | null | undefined, ): number | null { - return value == null || !Number.isInteger(value) ? null : value; + return isDbInteger(value) ? value : null; } function normalizeNullableNumber( value: number | null | undefined, ): number | null { - return value == null || !Number.isFinite(value) ? null : value; + return isDbDecimal(value) ? value : null; } -function isNullableInteger(value: unknown): boolean { +function isDbInteger(value: unknown): value is number { return ( - value == null || (typeof value === "number" && Number.isSafeInteger(value)) + typeof value === "number" && + Number.isSafeInteger(value) && + value >= MIN_DB_INT && + value <= MAX_DB_INT ); } +function isDbDecimal(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isFinite(value) && + Math.abs(value) <= MAX_DECIMAL_6_2 + ); +} + +function isNullableInteger(value: unknown): boolean { + return value == null || isDbInteger(value); +} + function isNullableNumber(value: unknown): boolean { - return value == null || (typeof value === "number" && Number.isFinite(value)); + return value == null || isDbDecimal(value); } function failure( @@ -71,6 +112,9 @@ export function parseAdminTrialEntryWriteInput( ): | { ok: true; data: AdminTrialEntryWriteDataDb } | { ok: false; issue: AdminTrialEntryWriteValidationIssue } { + if (!hasWriteContainerShape(input)) { + return failure("entry", "invalid_write_shape"); + } if (!VALID_KOETYYPIT.has(input.entry.koetyyppi)) { return failure("entry", "invalid_koetyyppi"); } diff --git a/packages/server/admin/trials/manage/update-trial-entry.ts b/packages/server/admin/trials/manage/update-trial-entry.ts index 6fc7a48a..aae24921 100644 --- a/packages/server/admin/trials/manage/update-trial-entry.ts +++ b/packages/server/admin/trials/manage/update-trial-entry.ts @@ -21,6 +21,11 @@ function validationFailure( issue: AdminTrialEntryWriteValidationIssue, ): ServiceResult { switch (issue.reason) { + case "invalid_write_shape": + return badRequest( + "Trial result fields are invalid.", + "INVALID_TRIAL_ENTRY", + ); case "missing_eras": return badRequest("At least one era is required.", "INVALID_ERAS"); case "invalid_era_number": @@ -103,8 +108,10 @@ export async function updateAdminTrialEntry( context?: ServiceLogContext, ): Promise> { const startedAt = Date.now(); - const trialEventId = input.trialEventId.trim(); - const trialEntryId = input.trialEntryId.trim(); + const trialEventId = + typeof input?.trialEventId === "string" ? input.trialEventId.trim() : ""; + const trialEntryId = + typeof input?.trialEntryId === "string" ? input.trialEntryId.trim() : ""; const log = withLogContext({ layer: "service", useCase: "admin-trials.updateAdminTrialEntry", diff --git a/packages/server/dogs/core/__tests__/registration.test.ts b/packages/server/dogs/core/__tests__/registration.test.ts new file mode 100644 index 00000000..42d84bd2 --- /dev/null +++ b/packages/server/dogs/core/__tests__/registration.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + isValidRegistrationNo, + normalizeRegistrationNo, +} from "@server/dogs/core/registration"; + +describe("registration-number helpers", () => { + it.each([ + "FI12345/21", + "FI12345A/21", + "EST.REG-12345", + "ABC/DEF/1234/20", + "ÅÄÖ123/24", + "FI١٢٣/٢٤", + ])("accepts supported international registration format %s", (value) => { + expect(isValidRegistrationNo(value)).toBe(true); + }); + + it.each(["", "BAD VALUE", "FI_123/24", "FI:123/24", "FI@123/24"])( + "rejects unsupported registration format %s", + (value) => { + expect(isValidRegistrationNo(value)).toBe(false); + }, + ); + + it("trims and uppercases registration numbers", () => { + expect(normalizeRegistrationNo(" fi.ab-123/24 ")).toBe("FI.AB-123/24"); + }); + + it.each([null, undefined, 123, "", " "])( + "normalizes invalid or empty value %s to null", + (value) => { + expect(normalizeRegistrationNo(value)).toBeNull(); + }, + ); +}); diff --git a/packages/server/dogs/core/index.ts b/packages/server/dogs/core/index.ts index 2125fcf8..9d6d8220 100644 --- a/packages/server/dogs/core/index.ts +++ b/packages/server/dogs/core/index.ts @@ -1,4 +1,5 @@ export { parseDogId } from "./dog-id"; +export { isValidRegistrationNo, normalizeRegistrationNo } from "./registration"; export { calculateInbreedingCoefficientBreakdownForParentsPct, calculateInbreedingCoefficientForParentsPct, diff --git a/packages/server/dogs/core/registration.ts b/packages/server/dogs/core/registration.ts new file mode 100644 index 00000000..495a7492 --- /dev/null +++ b/packages/server/dogs/core/registration.ts @@ -0,0 +1,12 @@ +// Normalizes and validates canonical dog registration identifiers shared across domains. +const REGISTRATION_NO_PATTERN = /^[\p{L}\p{N}/.-]+$/u; + +export function normalizeRegistrationNo(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toUpperCase(); + return normalized.length > 0 ? normalized : null; +} + +export function isValidRegistrationNo(value: string): boolean { + return REGISTRATION_NO_PATTERN.test(value); +} diff --git a/packages/server/trials/core/__tests__/trial-entry-identity.test.ts b/packages/server/trials/core/__tests__/trial-entry-identity.test.ts deleted file mode 100644 index 06336517..00000000 --- a/packages/server/trials/core/__tests__/trial-entry-identity.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - isValidTrialRegistrationNo, - normalizeTrialRegistrationNo, -} from "@server/trials/core/trial-entry-identity"; - -describe("trial entry registration-number helpers", () => { - it.each([ - "FI12345/21", - "FI12345A/21", - "EST.REG-12345", - "ABC/DEF/1234/20", - "ÅÄÖ123/24", - "FI١٢٣/٢٤", - ])("accepts supported international registration format %s", (value) => { - expect(isValidTrialRegistrationNo(value)).toBe(true); - }); - - it.each(["", "BAD VALUE", "FI_123/24", "FI:123/24", "FI@123/24"])( - "rejects unsupported registration format %s", - (value) => { - expect(isValidTrialRegistrationNo(value)).toBe(false); - }, - ); - - it("trims and uppercases registration numbers", () => { - expect(normalizeTrialRegistrationNo(" fi.ab-123/24 ")).toBe( - "FI.AB-123/24", - ); - }); - - it.each([null, "", " "])("normalizes empty value %s to null", (value) => { - expect(normalizeTrialRegistrationNo(value)).toBeNull(); - }); -}); diff --git a/packages/server/trials/core/index.ts b/packages/server/trials/core/index.ts index 3da7f9a2..40b26147 100644 --- a/packages/server/trials/core/index.ts +++ b/packages/server/trials/core/index.ts @@ -1,7 +1,3 @@ export { formatTrialAward } from "./format-trial-award"; export { getTrialDateOnlyStartUtc, formatTrialDateOnly } from "./date-only"; export { resolveTrialRuleWindowId } from "./trial-rule-window"; -export { - isValidTrialRegistrationNo, - normalizeTrialRegistrationNo, -} from "./trial-entry-identity"; diff --git a/packages/server/trials/integrations/koiratietokanta/internal/map-ajok-payload.ts b/packages/server/trials/integrations/koiratietokanta/internal/map-ajok-payload.ts index 2afb0de0..428d9611 100644 --- a/packages/server/trials/integrations/koiratietokanta/internal/map-ajok-payload.ts +++ b/packages/server/trials/integrations/koiratietokanta/internal/map-ajok-payload.ts @@ -12,9 +12,9 @@ import { TrialEntryKoetyyppi, } from "@beagle/db"; import { - isValidTrialRegistrationNo, - normalizeTrialRegistrationNo, -} from "@server/trials/core"; + isValidRegistrationNo, + normalizeRegistrationNo, +} from "@server/dogs/core"; import { mapKoiratietokantaAjokEraWrites } from "./map-ajok-era-writes"; import { isRecord, @@ -85,10 +85,10 @@ export function mapKoiratietokantaAjokPayload( }); } - const registrationNo = normalizeTrialRegistrationNo( + const registrationNo = normalizeRegistrationNo( normalizeText(payload.REKISTERINUMERO), ); - if (!registrationNo || !isValidTrialRegistrationNo(registrationNo)) { + if (!registrationNo || !isValidRegistrationNo(registrationNo)) { issues.push({ field: "REKISTERINUMERO", code: registrationNo ? "INVALID" : "REQUIRED", From eaef9121989d775e2eb731aff82a2e42b76a8665 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:54:31 +0300 Subject: [PATCH 5/5] refactor(trials): remove obsolete registration helper --- .../server/trials/core/trial-entry-identity.ts | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 packages/server/trials/core/trial-entry-identity.ts diff --git a/packages/server/trials/core/trial-entry-identity.ts b/packages/server/trials/core/trial-entry-identity.ts deleted file mode 100644 index 95400699..00000000 --- a/packages/server/trials/core/trial-entry-identity.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Registration identifiers vary by country, so validate a safe character set -// instead of enforcing a country-specific shape. -const REGISTRATION_NO_PATTERN = /^[\p{L}\p{N}/.-]+$/u; - -export function normalizeTrialRegistrationNo( - value: string | null, -): string | null { - const normalized = value?.trim().toUpperCase() ?? ""; - return normalized.length > 0 ? normalized : null; -} - -export function isValidTrialRegistrationNo(value: string): boolean { - return REGISTRATION_NO_PATTERN.test(value); -}