fix: Paket 0 – data integrity, security hardening and field usability
Backend - Backup covers every Prisma model (bundle v3); a guard test fails for any new model that is neither backed up nor explicitly excluded. Unsealed bundles may only add rows to 27 legacy master/field tables and never overwrite; counters, ledgers, settings, roles etc. need a sealed file. Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB) with warnings in log and UI; replaced document files are removed after commit. - Post-commit side effects (automation, calendar sync, webhooks) run via runAfterCommit and never turn a committed write into a 500, so retries with the same Idempotency-Key no longer create duplicates. - Calendar outbox is multi-replica safe: per-entry claims with stale takeover, revision-based settle, new Graph event only on 404/410, transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID validation for groupId, "run now" in background and resets FAILED. - Automation tasks are deduplicated under a row lock on the rule. - Update DTOs follow one null rule: omitted = unchanged, null clears a nullable field, null on a required field is a 400. - Varroa mites/day is recomputed on correction; withdrawal status is checked against the harvest date (?at=), bounded colonyIds; inspection create is audited; updates re-enqueue calendar entries. - Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS and QUEENLESS colonies as living. - Prisma errors and body-parser errors are logged without query values or request bodies. - Migration 20261012100000_calendar_sync_claims; schema index name aligned with the existing database object. Frontend - Sticky save bar sticks again (no scroll container ancestors, card uses overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms, sticky desktop sidebar. - Inspections record yes / no / not checked instead of silent defaults. - All living colonies are selectable; shared RecordSelect, EnumSelect, ChoiceGroup and TriStateField replace per-page copies; selects show names instead of ids; labels point at real controls. - Emptied optional fields are sent as null (emptyToNull) so every field can be cleared; dates default to the local day and are sent via toApiDate. - Harvest withdrawal warning uses the harvest date; backup page warns when a backup outgrows the import limit and lists skipped tables. Build and docs - Docker builds fail unless lint, typecheck and unit tests pass. - ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md for the backbone (api/worker/job roles, domain event outbox) and the feature roadmap T3-T7. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
f5b1964223
commit
10cbe9bf2d
310 files changed
+12177
-3746
No files matched your search
@@ -2,20 +2,117 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { colonyOptionLabel } from "@/lib/colonies/labels";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
|
||||
const PLACEHOLDER = "Volk wählen";
|
||||
const LOADING_PLACEHOLDER = "Völker werden geladen…";
|
||||
const LOAD_ERROR_HINT = "Völker konnten nicht geladen werden – bitte die Seite neu laden.";
|
||||
|
||||
function useColonies() {
|
||||
return useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
||||
}
|
||||
|
||||
interface ColonyChoicesProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (colonyId: string) => void;
|
||||
/** The colonies on offer; `undefined` while loading. */
|
||||
options: readonly Colony[] | undefined;
|
||||
/** Every known colony, so a chosen one is named even if it is not offered. */
|
||||
colonies: readonly Colony[] | undefined;
|
||||
isLoading: boolean;
|
||||
dataField?: string;
|
||||
describedBy?: string;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
/** The colony select: a RecordSelect named by code and status ("V-03 (Weisellos)"). */
|
||||
function ColonyChoices({ colonies, options, isLoading, ...props }: ColonyChoicesProps) {
|
||||
return (
|
||||
<RecordSelect
|
||||
{...props}
|
||||
items={options}
|
||||
labelItems={colonies}
|
||||
getLabel={colonyOptionLabel}
|
||||
placeholder={isLoading ? LOADING_PLACEHOLDER : PLACEHOLDER}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface LivingColonySelectFieldProps {
|
||||
/** Id of the select trigger, used to focus it on a validation error. */
|
||||
triggerId: string;
|
||||
value: string;
|
||||
onChange: (colonyId: string) => void;
|
||||
error?: string | null;
|
||||
label?: string;
|
||||
/** A colony that must not be offered, e.g. the one another is merged into. */
|
||||
excludeId?: string;
|
||||
/** Shown when there is nothing to choose from. */
|
||||
emptyHint?: string;
|
||||
/** `data-field` of the trigger; the capture forms focus "colonyId" on errors. */
|
||||
dataField?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a colony that still lives in the apiary: the one a new record
|
||||
* (inspection, varroa count, feeding, treatment, swarm control check) is filed
|
||||
* under, the colony a queen moves into, the colony merged into another.
|
||||
* Weak, conspicuous and queenless colonies are included - they need the most
|
||||
* attention - and none that has ended. A colony handed in by a link that has
|
||||
* ended since is still named, just not offered.
|
||||
*/
|
||||
export function LivingColonySelectField({
|
||||
triggerId,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
label = "Volk",
|
||||
excludeId,
|
||||
emptyHint = "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen.",
|
||||
dataField = "colonyId",
|
||||
}: LivingColonySelectFieldProps) {
|
||||
const { data: colonies, isLoading, isError } = useColonies();
|
||||
const living = colonies?.filter(
|
||||
(colony) => isLivingColony(colony) && colony.id !== excludeId,
|
||||
);
|
||||
|
||||
return (
|
||||
<Field
|
||||
id={triggerId}
|
||||
label={label}
|
||||
required
|
||||
error={error}
|
||||
hint={isError ? LOAD_ERROR_HINT : living?.length === 0 ? emptyHint : undefined}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<ColonyChoices
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={living}
|
||||
colonies={colonies}
|
||||
isLoading={isLoading}
|
||||
dataField={dataField}
|
||||
describedBy={describedBy}
|
||||
invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColonySelectFieldProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (colonyId: string) => void;
|
||||
label?: string;
|
||||
error?: string | null;
|
||||
dataField?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,37 +122,41 @@ interface ColonySelectFieldProps {
|
||||
* against the wrong colony is a data-entry error rather than a fact of
|
||||
* beekeeping - and the only alternative repair would be deleting the record.
|
||||
*
|
||||
* Dissolved colonies stay in the list: a record already filed against one has
|
||||
* Ended colonies stay in the list: a record already filed against one has
|
||||
* to remain visible and selectable, otherwise opening the form would silently
|
||||
* re-file it somewhere else.
|
||||
*/
|
||||
export function ColonySelectField({
|
||||
id,
|
||||
id: triggerId,
|
||||
value,
|
||||
onChange,
|
||||
label = "Volk",
|
||||
error,
|
||||
dataField,
|
||||
}: ColonySelectFieldProps) {
|
||||
const { data: colonies } = useQuery({
|
||||
queryKey: ["colonies"],
|
||||
queryFn: () => fetchColonies(),
|
||||
});
|
||||
const { data: colonies, isLoading, isError } = useColonies();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Select value={value} onValueChange={(next) => onChange(next ?? value)}>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue placeholder="Volk wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(colonies ?? []).map((colony) => (
|
||||
<SelectItem key={colony.id} value={colony.id}>
|
||||
{colony.code}
|
||||
{colony.status === "ACTIVE" ? "" : " (aufgelöst)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Field
|
||||
id={triggerId}
|
||||
label={label}
|
||||
required
|
||||
error={error}
|
||||
hint={isError ? LOAD_ERROR_HINT : undefined}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<ColonyChoices
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={colonies}
|
||||
colonies={colonies}
|
||||
isLoading={isLoading}
|
||||
dataField={dataField}
|
||||
describedBy={describedBy}
|
||||
invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user