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,4 +1,4 @@
import { GraphClientService } from './graph-client.service';
import { GraphClientService, GraphRequestError } from './graph-client.service';
function jsonResponse(status: number, body: unknown): Response {
return {
@@ -53,10 +53,11 @@ describe('GraphClientService', () => {
)
.mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' }));
const result = await service.createEvent('group-1', {
title: 'Durchsicht V-001',
date: new Date(Date.UTC(2026, 2, 7)),
});
const result = await service.createEvent(
'group-1',
{ title: 'Durchsicht V-001', date: new Date(Date.UTC(2026, 2, 7)) },
'tx-durchsicht',
);
expect(result).toBe('graph-event-1');
expect(fetchMock).toHaveBeenCalledTimes(2);
@@ -82,8 +83,11 @@ describe('GraphClientService', () => {
isAllDay: boolean;
start: { dateTime: string };
end: { dateTime: string };
transactionId: string;
};
expect(body.subject).toBe('Durchsicht V-001');
// Lets Graph drop a repeated create after a timed-out first attempt.
expect(body.transactionId).toBe('tx-durchsicht');
expect(body.isAllDay).toBe(true);
expect(body.start.dateTime).toBe('2026-03-07T00:00:00.0000000');
expect(body.end.dateTime).toBe('2026-03-08T00:00:00.0000000');
@@ -97,14 +101,16 @@ describe('GraphClientService', () => {
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-a' }))
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-b' }));
await service.createEvent('group-1', {
title: 'A',
date: new Date(Date.UTC(2026, 2, 7)),
});
await service.createEvent('group-1', {
title: 'B',
date: new Date(Date.UTC(2026, 2, 8)),
});
await service.createEvent(
'group-1',
{ title: 'A', date: new Date(Date.UTC(2026, 2, 7)) },
'tx-a',
);
await service.createEvent(
'group-1',
{ title: 'B', date: new Date(Date.UTC(2026, 2, 8)) },
'tx-b',
);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
@@ -117,14 +123,99 @@ describe('GraphClientService', () => {
.mockResolvedValueOnce(jsonResponse(403, { error: 'Forbidden' }));
await expect(
service.createEvent('group-1', {
title: 'A',
date: new Date(Date.UTC(2026, 2, 7)),
}),
service.createEvent(
'group-1',
{ title: 'A', date: new Date(Date.UTC(2026, 2, 7)) },
'tx-a',
),
).rejects.toThrow(/createEvent fehlgeschlagen \(403\)/);
});
});
describe('updateEvent', () => {
it('never sends a transactionId, which Graph does not let an update change', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse(200, {}));
await service.updateEvent('group-1', 'event-1', {
title: 'A',
date: new Date(Date.UTC(2026, 2, 7)),
});
const [, init] = fetchMock.mock.calls[1] as [string, { body: string }];
expect(JSON.parse(init.body)).not.toHaveProperty('transactionId');
});
it('rejects with a GraphRequestError carrying the HTTP status', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse(404, { error: 'NotFound' }));
const update = service.updateEvent('group-1', 'event-1', {
title: 'A',
date: new Date(Date.UTC(2026, 2, 7)),
});
await expect(update).rejects.toBeInstanceOf(GraphRequestError);
await expect(update).rejects.toMatchObject({ status: 404 });
});
});
// The outbox claim expires after a fixed time; a Graph call hanging longer
// would let a second pod take over the same row while this one still works.
it('bounds every request to Microsoft with a timeout', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-1' }))
.mockResolvedValueOnce(jsonResponse(200, {}))
.mockResolvedValueOnce(jsonResponse(204, {}));
const input = { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) };
await service.createEvent('group-1', input, 'tx-1');
await service.updateEvent('group-1', 'event-1', input);
await service.deleteEvent('group-1', 'event-1');
expect(fetchMock).toHaveBeenCalledTimes(4);
for (const [, init] of fetchMock.mock.calls as [string, RequestInit][]) {
expect(init.signal).toBeInstanceOf(AbortSignal);
}
});
// Ids end up in the URL path: a value with "/", "?" or "#" must not be
// able to point the request at another Graph resource.
it('encodes group and event ids in every request path', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-1' }))
.mockResolvedValueOnce(jsonResponse(200, {}))
.mockResolvedValueOnce(jsonResponse(204, {}));
const input = { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) };
const groupId = '../users/x?$top=1#';
const eventId = 'AAMk/abc+def=';
await service.createEvent(groupId, input, 'tx-1');
await service.updateEvent(groupId, eventId, input);
await service.deleteEvent(groupId, eventId);
const urls = (fetchMock.mock.calls as [string][]).map(([url]) => url);
const group = '..%2Fusers%2Fx%3F%24top%3D1%23';
const event = 'AAMk%2Fabc%2Bdef%3D';
expect(urls.slice(1)).toEqual([
`https://graph.microsoft.com/v1.0/groups/${group}/events`,
`https://graph.microsoft.com/v1.0/groups/${group}/events/${event}`,
`https://graph.microsoft.com/v1.0/groups/${group}/events/${event}`,
]);
});
describe('deleteEvent', () => {
it('treats a 404 as success (idempotent delete)', async () => {
fetchMock