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

@@ -1,5 +1,6 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { IsBoolean } from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
import { CreateWebhookDto } from './create-webhook.dto';
/**
@@ -10,13 +11,15 @@ import { CreateWebhookDto } from './create-webhook.dto';
* A changed URL goes through the same SSRF check as a new one - see
* WebhooksService.update.
*/
export class UpdateWebhookDto extends PartialType(CreateWebhookDto) {
export class UpdateWebhookDto extends PartialType(CreateWebhookDto, {
skipNullProperties: false,
}) {
/**
* Pausing a subscription without losing it. There was no way to set this
* after creation, so a misbehaving receiver could only be deleted.
*/
@ApiPropertyOptional()
@IsOptional()
@IsOptionalNotNull()
@IsBoolean()
active?: boolean;
}
@@ -1,5 +1,5 @@
import * as crypto from 'node:crypto';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { WebhooksService } from './webhooks.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -149,6 +149,47 @@ describe('WebhooksService', () => {
await flushDeliveries();
});
// dispatch() runs after the event's record is committed: a failure must
// not fail that request (the client would retry and store it twice).
describe('dispatch after commit', () => {
let errorSpy: jest.SpyInstance;
beforeEach(() => {
errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.webhookSubscription.findMany.mockRejectedValue(
new Error('db down'),
);
});
afterEach(() => {
errorSpy.mockRestore();
});
it('never throws when the subscriptions cannot be loaded and logs the record', async () => {
await expect(
service.dispatch('colony.created', { id: 'colony-1' }),
).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message] = errorSpy.mock.calls[0] as [string];
expect(message).toContain('colony.created colony-1');
expect(message).toContain('db down');
});
it('also logs a payload without its own id', async () => {
await service.dispatch('stock.changed', {
bottlingBatchIds: ['b-1'],
productIds: [],
});
const [message] = errorSpy.mock.calls[0] as [string];
expect(message).toContain('stock.changed');
expect(message).toContain('db down');
});
});
describe('update', () => {
const existing = {
id: 'wh-1',
@@ -9,9 +9,23 @@ import { WebhookEvent } from './webhook-events';
import { assertPublicWebhookUrl } from './webhook-url-guard';
import { postWebhook } from './webhook-delivery';
import { openWebhookSecret, sealWebhookSecret } from './webhook-secret';
import { runAfterCommit } from '../common/after-commit';
const SECRET_BYTES = 32;
/** The record an event is about, for the log - some payloads have none. */
function payloadId(payload: unknown): string {
if (
typeof payload === 'object' &&
payload !== null &&
'id' in payload &&
typeof payload.id === 'string'
) {
return payload.id;
}
return '(no id)';
}
@Injectable()
export class WebhooksService {
private readonly logger = new Logger(WebhooksService.name);
@@ -135,14 +149,27 @@ export class WebhooksService {
* Starts delivery and returns without waiting for the receivers: a slow
* or hanging endpoint must never hold up the request that caused the
* event. Failures are logged by deliver().
*
* Called after the event's record is committed, so it never throws - not
* even when the subscriptions cannot be loaded (see runAfterCommit).
*/
async dispatch(event: WebhookEvent, payload: unknown): Promise<void> {
const subscriptions = await this.prisma.webhookSubscription.findMany({
where: { active: true, events: { has: event } },
});
for (const subscription of subscriptions) {
void this.deliver(subscription, event, payload);
}
await runAfterCommit(
this.logger,
{
action: 'Webhook dispatch',
entityType: event,
entityId: payloadId(payload),
},
async () => {
const subscriptions = await this.prisma.webhookSubscription.findMany({
where: { active: true, events: { has: event } },
});
for (const subscription of subscriptions) {
void this.deliver(subscription, event, payload);
}
},
);
}
private async deliver(