fix: Paket 0 – data integrity, security hardening and field usability
Backend Image / build-and-push (push) Successful in 3m15s
Frontend Image / build-and-push (push) Successful in 6m2s

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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 16:38:55 +02:00
1 parent f5b1964223
commit 10cbe9bf2d
310 files changed
+12177 -3746

No files matched your search

@@ -0,0 +1,369 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateApiaryDto } from '../../apiaries/dto/update-apiary.dto';
import { UpdateAutomationRuleDto } from '../../automation/dto/update-automation-rule.dto';
import { UpdateBackupSettingsDto } from '../../backup/dto/update-backup-settings.dto';
import { UpdateCalendarSyncSettingsDto } from '../../calendar-sync/dto/update-calendar-sync-settings.dto';
import { UpdateBottlingBatchDto } from '../../bottling/dto/update-bottling-batch.dto';
import { UpdateColonyDto } from '../../colonies/dto/update-colony.dto';
import { UpdateDocumentDto } from '../../documents/dto/update-document.dto';
import { UpdateEquipmentDto } from '../../equipment/dto/update-equipment.dto';
import { UpdateExtractionDto } from '../../extractions/dto/update-extraction.dto';
import { UpdateFeedingDto } from '../../feedings/dto/update-feeding.dto';
import { UpdateHarvestDto } from '../../harvests/dto/update-harvest.dto';
import { UpdateHoneyBatchDto } from '../../honey-batches/dto/update-honey-batch.dto';
import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-measurement.dto';
import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto';
import { UpdateLabelTemplateDto } from '../../label-templates/dto/update-label-template.dto';
import { UpdateProductionBatchDto } from '../../production-batches/dto/update-production-batch.dto';
import { UpdateProductDto } from '../../products/dto/update-product.dto';
import { UpdateProfileDto } from '../../profile/dto/update-profile.dto';
import { UpdateQueenEvaluationDto } from '../../queens/dto/update-queen-evaluation.dto';
import { UpdateQueenDto } from '../../queens/dto/update-queen.dto';
import { UpdateRawMaterialLotDto } from '../../raw-materials/dto/update-raw-material-lot.dto';
import { UpdateRoleDto } from '../../roles/dto/update-role.dto';
import { UpdateStockItemDto } from '../../storage/dto/update-stock-item.dto';
import { UpdateStorageContainerDto } from '../../storage/dto/update-storage-container.dto';
import { UpdateSwarmControlCheckDto } from '../../swarm-control/dto/update-swarm-control-check.dto';
import { UpdateTaskTemplateDto } from '../../task-templates/dto/update-task-template.dto';
import { UpdateTaskDto } from '../../tasks/dto/update-task.dto';
import { UpdateTreatmentDto } from '../../treatments/dto/update-treatment.dto';
import { UpdateUserDto } from '../../users/dto/update-user.dto';
import { UpdateVarroaMeasurementDto } from '../../varroa/dto/update-varroa-measurement.dto';
import { UpdateWebhookDto } from '../../webhooks/dto/update-webhook.dto';
import { UpdateWinterCheckDto } from '../../winter-checks/dto/update-winter-check.dto';
// PATCH contract of every update DTO: an omitted field stays unchanged, null
// clears a field whose column is nullable, and null on a column that cannot
// hold it is a 400 - never silently ignored, never a 500 from the write.
interface NullContract {
dto: new () => object;
notNull: string[];
clearable: string[];
}
const contracts: Record<string, NullContract> = {
UpdateApiaryDto: {
dto: UpdateApiaryDto,
notNull: ['name'],
clearable: [
'description',
'location',
'latitude',
'longitude',
'address',
'notes',
],
},
UpdateAutomationRuleDto: {
dto: UpdateAutomationRuleDto,
notNull: ['active'],
clearable: ['config', 'actionTemplate'],
},
UpdateBackupSettingsDto: {
dto: UpdateBackupSettingsDto,
notNull: [],
clearable: ['storagePath', 'cronSchedule', 'retentionCount'],
},
// groupId: null clears it, the same as the empty string.
UpdateCalendarSyncSettingsDto: {
dto: UpdateCalendarSyncSettingsDto,
notNull: [
'enabled',
'syncTask',
'syncInspection',
'syncTreatment',
'syncFeeding',
'syncHarvest',
'syncQueenAssignment',
],
clearable: ['groupId'],
},
UpdateBottlingBatchDto: {
dto: UpdateBottlingBatchDto,
notNull: [
'honeyBatchId',
'date',
'jarSize',
'jarCount',
'bestBeforeDate',
'lotNumber',
],
clearable: ['productId'],
},
UpdateColonyDto: {
dto: UpdateColonyDto,
notNull: ['apiaryId', 'code', 'colonyType', 'status'],
clearable: [
'hiveType',
'originText',
'strength',
'broodStatus',
'feedStatus',
'healthStatus',
'notes',
],
},
UpdateDocumentDto: {
dto: UpdateDocumentDto,
notNull: ['entityType', 'entityId', 'fileName'],
clearable: [],
},
UpdateEquipmentDto: {
dto: UpdateEquipmentDto,
notNull: ['inventoryNumber', 'name', 'condition'],
clearable: ['manufacturer', 'purchaseDate', 'location', 'maintenanceDate'],
},
UpdateExtractionDto: {
dto: UpdateExtractionDto,
notNull: ['date', 'harvestIds'],
clearable: ['amount', 'room', 'waterContent', 'temperature', 'notes'],
},
UpdateFeedingDto: {
dto: UpdateFeedingDto,
notNull: ['colonyId', 'date', 'feedType', 'amount', 'unit'],
clearable: ['product', 'batchNumber', 'notes'],
},
UpdateHarvestDto: {
dto: UpdateHarvestDto,
notNull: ['apiaryId', 'date', 'colonyIds'],
clearable: [
'honeyType',
'honeySuperCount',
'grossWeight',
'netWeight',
'notes',
],
},
UpdateHoneyBatchDto: {
dto: UpdateHoneyBatchDto,
notNull: ['batchNumber', 'extractionId', 'status'],
clearable: ['honeyType', 'amount', 'waterContent', 'storageContainerId'],
},
UpdateHoneyMeasurementDto: {
dto: UpdateHoneyMeasurementDto,
notNull: ['waterContent', 'measuredAt'],
clearable: ['device', 'temperature', 'notes'],
},
UpdateInspectionDto: {
dto: UpdateInspectionDto,
notNull: ['colonyId', 'date'],
clearable: [
'weather',
'temperature',
'colonyStrength',
'broodAmount',
'broodPattern',
'openBroodPresent',
'cappedBroodPresent',
'eggsPresent',
'queenSeen',
'queenCellsPresent',
'swarmMood',
'gentleness',
'combBehavior',
'feedStatus',
'honeySuperStatus',
'healthIssues',
'varroaIssues',
'measures',
'notes',
],
},
UpdateLabelTemplateDto: {
dto: UpdateLabelTemplateDto,
notNull: [
'name',
'elements',
'widthMm',
'heightMm',
'marginTopMm',
'marginLeftMm',
'gapXMm',
'gapYMm',
'cropMarks',
'isDefault',
],
clearable: ['columns', 'rows'],
},
UpdateProductDto: {
dto: UpdateProductDto,
notNull: ['name', 'status', 'kind', 'shopCategory'],
clearable: ['description', 'sku', 'packagingSize', 'weight', 'salesUnit'],
},
UpdateProductionBatchDto: {
dto: UpdateProductionBatchDto,
notNull: ['lotNumber', 'date', 'quantity'],
clearable: ['bestBeforeDate', 'notes'],
},
UpdateProfileDto: {
dto: UpdateProfileDto,
notNull: ['name', 'email'],
clearable: [],
},
UpdateQueenDto: {
dto: UpdateQueenDto,
notNull: ['code', 'status'],
clearable: [
'hatchYear',
'breed',
'origin',
'breeder',
'markingColor',
'markingNumber',
'matingType',
'matingStatus',
'inseminationData',
'motherLine',
'fatherLine',
'motherQueenId',
'notes',
],
},
UpdateQueenEvaluationDto: {
dto: UpdateQueenEvaluationDto,
notNull: [
'date',
'colonyStrength',
'gentleness',
'combBuild',
'swarmingTendency',
'broodPattern',
'honeyYield',
],
clearable: ['colonyId', 'notes'],
},
UpdateRawMaterialLotDto: {
dto: UpdateRawMaterialLotDto,
notNull: ['lotNumber', 'date', 'quantity'],
clearable: ['apiaryId', 'harvestId', 'notes'],
},
UpdateRoleDto: {
dto: UpdateRoleDto,
notNull: ['key', 'name', 'permissionKeys'],
clearable: ['description'],
},
UpdateStockItemDto: {
dto: UpdateStockItemDto,
notNull: ['name', 'category', 'unit', 'quantity'],
clearable: ['minThreshold', 'notes'],
},
UpdateStorageContainerDto: {
dto: UpdateStorageContainerDto,
notNull: ['code'],
clearable: ['containerType', 'capacity', 'location'],
},
UpdateSwarmControlCheckDto: {
dto: UpdateSwarmControlCheckDto,
notNull: [
'colonyId',
'date',
'queenCellsCapped',
'swarmMood',
'actionTaken',
],
clearable: ['queenCellsFound', 'notes', 'nextCheckDate'],
},
UpdateTaskDto: {
dto: UpdateTaskDto,
notNull: ['title', 'priority', 'status', 'relatedType'],
clearable: ['description', 'dueDate', 'assignedUserId', 'relatedId'],
},
UpdateTaskTemplateDto: {
dto: UpdateTaskTemplateDto,
notNull: ['title', 'priority', 'recurrenceType', 'active'],
clearable: [
'description',
'assignedUserId',
'recurrenceMonth',
'recurrenceDayOfMonth',
'intervalDays',
],
},
UpdateTreatmentDto: {
dto: UpdateTreatmentDto,
notNull: ['colonyId', 'date', 'product'],
clearable: [
'startDate',
'endDate',
'activeIngredient',
'amount',
'method',
'reason',
'batchNumber',
'supplier',
'purchaseProof',
'prescribingVet',
'notes',
'withdrawalPeriodDays',
],
},
UpdateUserDto: {
dto: UpdateUserDto,
notNull: ['name', 'email', 'roleId'],
clearable: [],
},
UpdateVarroaMeasurementDto: {
dto: UpdateVarroaMeasurementDto,
notNull: ['colonyId', 'date', 'method'],
clearable: [
'durationMinutes',
'miteCount',
'calculatedValue',
'assessment',
'notes',
],
},
UpdateWebhookDto: {
dto: UpdateWebhookDto,
notNull: ['url', 'events', 'active'],
clearable: [],
},
UpdateWinterCheckDto: {
dto: UpdateWinterCheckDto,
notNull: ['colonyId', 'season', 'date'],
clearable: [
'occupiedSeams',
'feedStockKg',
'queenPresent',
'outcome',
'lossCause',
'notes',
],
},
};
async function errorPropertiesFor(
dto: new () => object,
body: Record<string, unknown>,
): Promise<string[]> {
const errors = await validate(plainToInstance(dto, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.map((error) => error.property);
}
describe('update DTO null handling', () => {
describe.each(Object.entries(contracts))('%s', (_name, contract) => {
it('accepts an empty patch', async () => {
await expect(errorPropertiesFor(contract.dto, {})).resolves.toEqual([]);
});
if (contract.notNull.length > 0) {
it.each(contract.notNull)('rejects null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([field]);
});
}
if (contract.clearable.length > 0) {
it.each(contract.clearable)('accepts null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([]);
});
}
});
});