feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms

- Rule configs with typed parsers and registry; Berlin day helpers
- Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as
  ColonyCondition with legacy text mapping), split queen source,
  varroa sample size; migration harness for data migrations
- SuggestionsService: propose/refresh/resolve/obsolete/expire,
  accept/dismiss/reopen incl. bulk, lock order, concurrency e2e
- SeasonService: phenology-based segments, inspection/varroa due dates,
  queen age; TimeEventsStep with JobPartialFailure
- Frontend: per-rule settings forms with visible value sources

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 13:42:32 +02:00
1 parent 7097fcd97c
commit d99a697739
116 files changed
+11239 -801

No files matched your search

@@ -3,7 +3,11 @@ import { JobRun, Prisma } from '../../generated/prisma';
import { JOB_NAMES } from '../jobs/job-names';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { JOB_ABORTED_ERROR, JobRunService } from './job-run.service';
import {
JOB_ABORTED_ERROR,
JobPartialFailure,
JobRunService,
} from './job-run.service';
type RawCall = [readonly string[], ...unknown[]];
@@ -125,6 +129,26 @@ describe('JobRunService', () => {
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('Mustermann');
});
it('stores the counters of a partial failure with the FAILED run', async () => {
const run = await service.run('daily-evaluation', () =>
Promise.reject(
new JobPartialFailure({ WithdrawalEnded: 2, TaskDue: 1 }, [
'time-events',
]),
),
);
expect(run.status).toBe('FAILED');
const [finish] = prisma.$queryRaw.mock.calls;
expect(finish.slice(1)).toEqual([
'FAILED',
'{"WithdrawalEnded":2,"TaskDue":1}',
'Daily evaluation failed for: time-events',
'run-1',
]);
expect(notifications.recordForPermission).toHaveBeenCalledTimes(1);
});
// finishedAt of every run - finished, failed or aborted - comes from
// the database clock, like the comparisons of cleanup.
it.each([
+20 -2
View File
@@ -20,9 +20,26 @@ const JOB_RUN_RELATED_TYPE = 'JobRun';
const NEWEST_FIRST = [{ startedAt: 'desc' as const }, { id: 'desc' as const }];
/**
* A run of several steps in which some failed: `result` holds the counters
* of all steps, the finished ones included. JobRunService stores it with the
* FAILED run, so the System page shows what was done.
*/
export class JobPartialFailure extends Error {
constructor(
readonly result: JobResult,
readonly failedSteps: readonly string[],
/** What the stored error names, e.g. "time-events: QueenAged". */
details: readonly string[] = failedSteps,
) {
super(`Daily evaluation failed for: ${details.join(', ')}`);
this.name = 'JobPartialFailure';
}
}
type RunOutcome =
| { status: 'SUCCEEDED'; result: JobResult }
| { status: 'FAILED'; error: string };
| { status: 'FAILED'; error: string; result?: JobResult };
/**
* Bookkeeping of the CronJob runs: every run is one JobRun row (RUNNING,
@@ -116,6 +133,7 @@ export class JobRunService {
const failed = await this.finish(runId, {
status: 'FAILED',
error: message,
...(error instanceof JobPartialFailure ? { result: error.result } : {}),
});
await this.notifyFailure(job, runId);
return failed;
@@ -127,7 +145,7 @@ export class JobRunService {
*/
private async finish(runId: string, outcome: RunOutcome): Promise<JobRun> {
const result =
outcome.status === 'SUCCEEDED' ? JSON.stringify(outcome.result) : null;
outcome.result === undefined ? null : JSON.stringify(outcome.result);
const error = outcome.status === 'FAILED' ? outcome.error : null;
const [finished] = await this.prisma.$queryRaw<JobRun[]>`
UPDATE "JobRun"