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
@@ -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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user