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:
1 parent
7097fcd97c
commit
d99a697739
116 files changed
+11239
-801
No files matched your search
@@ -29,6 +29,7 @@ const BUSINESS_ENTITY_KEYS = [
|
||||
'queenColonyHistory',
|
||||
'inspection',
|
||||
'taskTemplate',
|
||||
'suggestion',
|
||||
'task',
|
||||
'varroaMeasurement',
|
||||
'swarmControlCheck',
|
||||
@@ -237,6 +238,157 @@ describe('BackupService', () => {
|
||||
];
|
||||
expect(call.create.broodFrames).toBe(5);
|
||||
});
|
||||
|
||||
it('maps a legacy free-text feedStatus of an old bundle on import', async () => {
|
||||
const base = { colonyId: 'colony-1', userId: 'known-user' };
|
||||
const bundle = {
|
||||
version: 3,
|
||||
data: {
|
||||
inspection: [
|
||||
{ ...base, id: 'i-1', feedStatus: ' Niedrig ', notes: null },
|
||||
{ ...base, id: 'i-2', feedStatus: 'halb voll', notes: 'x' },
|
||||
{ ...base, id: 'i-3', feedStatus: 'CRITICAL', notes: null },
|
||||
{ ...base, id: 'i-4', feedStatus: '', notes: 'y' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await service.importAll(bundle as never, 'admin-1', TRUSTED);
|
||||
|
||||
const created = (
|
||||
tx.inspection.upsert.mock.calls as [
|
||||
[{ where: { id: string }; create: Record<string, unknown> }],
|
||||
]
|
||||
).map(([call]) => call.create);
|
||||
expect(created).toEqual([
|
||||
expect.objectContaining({ id: 'i-1', feedStatus: 'WEAK', notes: null }),
|
||||
expect.objectContaining({
|
||||
id: 'i-2',
|
||||
feedStatus: null,
|
||||
notes: 'x\nFutter (alt): halb voll',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'i-3',
|
||||
feedStatus: 'CRITICAL',
|
||||
notes: null,
|
||||
}),
|
||||
expect.objectContaining({ id: 'i-4', feedStatus: null, notes: 'y' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy feedStatus of unexpected type', () => {
|
||||
it('keeps a non-string feedStatus in the notes as JSON instead of dropping it', async () => {
|
||||
const base = { colonyId: 'colony-1', userId: 'known-user' };
|
||||
const bundle = {
|
||||
version: 3,
|
||||
data: {
|
||||
inspection: [
|
||||
{ ...base, id: 'i-1', feedStatus: 3, notes: null },
|
||||
{ ...base, id: 'i-2', feedStatus: { level: 'low' }, notes: 'x' },
|
||||
{ ...base, id: 'i-3', feedStatus: null, notes: 'y' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await service.importAll(bundle as never, 'admin-1', TRUSTED);
|
||||
|
||||
const created = (
|
||||
tx.inspection.upsert.mock.calls as [
|
||||
[{ create: Record<string, unknown> }],
|
||||
]
|
||||
).map(([call]) => call.create);
|
||||
expect(created).toEqual([
|
||||
expect.objectContaining({
|
||||
feedStatus: null,
|
||||
notes: 'Futter (alt): 3',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
feedStatus: null,
|
||||
notes: 'x\nFutter (alt): {"level":"low"}',
|
||||
}),
|
||||
expect.objectContaining({ feedStatus: null, notes: 'y' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestions', () => {
|
||||
it('exports and restores suggestions with their decision before tasks', async () => {
|
||||
const suggestion = {
|
||||
id: 'suggestion-1',
|
||||
colonyId: 'colony-1',
|
||||
status: 'ACCEPTED',
|
||||
dedupeKey: 'INSPECTION_INTERVAL:colony-1:insp-9',
|
||||
decidedAt: '2026-10-14T08:00:00.000Z',
|
||||
decidedByUserId: 'known-user',
|
||||
decisionNote: 'Passt',
|
||||
};
|
||||
const orphanDecision = {
|
||||
...suggestion,
|
||||
id: 'suggestion-2',
|
||||
dedupeKey: null,
|
||||
decidedByUserId: 'deleted-user',
|
||||
};
|
||||
const open = {
|
||||
...suggestion,
|
||||
id: 'suggestion-3',
|
||||
status: 'OPEN',
|
||||
decidedAt: null,
|
||||
decidedByUserId: null,
|
||||
decisionNote: null,
|
||||
};
|
||||
prisma.suggestion.findMany.mockResolvedValue([
|
||||
suggestion,
|
||||
orphanDecision,
|
||||
open,
|
||||
]);
|
||||
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
|
||||
prisma.task.findMany.mockResolvedValue([
|
||||
{ id: 'task-1', sourceSuggestionId: 'suggestion-1' },
|
||||
]);
|
||||
|
||||
const bundle = await service.exportAll();
|
||||
expect(bundle.data.suggestion).toEqual([
|
||||
suggestion,
|
||||
orphanDecision,
|
||||
open,
|
||||
]);
|
||||
|
||||
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||
|
||||
expect(summary.imported.suggestion).toBe(3);
|
||||
const creates = (
|
||||
tx.suggestion.upsert.mock.calls as [
|
||||
{ create: { id: string; decidedByUserId: string | null } },
|
||||
][]
|
||||
).map(([call]) => call.create);
|
||||
// A decider that is gone resolves like any other user id; no decision
|
||||
// stays without one.
|
||||
expect(creates.map((row) => row.decidedByUserId)).toEqual([
|
||||
'known-user',
|
||||
'admin-1',
|
||||
null,
|
||||
]);
|
||||
const suggestionOrder = tx.suggestion.upsert.mock.invocationCallOrder[0];
|
||||
const taskOrder = tx.task.upsert.mock.invocationCallOrder[0];
|
||||
expect(suggestionOrder).toBeLessThan(taskOrder);
|
||||
const colonyOrder = tx.colony.upsert.mock.invocationCallOrder[0];
|
||||
expect(colonyOrder).toBeLessThan(suggestionOrder);
|
||||
});
|
||||
|
||||
it('does not restore suggestions from an unsealed file', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-10-14T00:00:00.000Z',
|
||||
data: { suggestion: [{ id: 'suggestion-1' }] },
|
||||
},
|
||||
'admin-1',
|
||||
{ trusted: false },
|
||||
);
|
||||
|
||||
expect(tx.suggestion.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('importAll', () => {
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
isAllowedDocumentMimeType,
|
||||
} from '../documents/document-mime-types';
|
||||
import { matchesFileSignature } from '../common/file-signature';
|
||||
import {
|
||||
appendLegacyFeedNote,
|
||||
feedConditionFromInput,
|
||||
} from '../common/feed-vocabulary';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { DocumentEntityType, Prisma } from '../../generated/prisma';
|
||||
import {
|
||||
@@ -162,6 +166,28 @@ function asString(row: Row, key: string): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
// Bundles written before the feed vocabulary change carry free text in
|
||||
// Inspection.feedStatus: known words map to the enum, the rest goes to notes.
|
||||
function withCurrentFeedVocabulary(row: Row): Row {
|
||||
if (!('feedStatus' in row)) {
|
||||
return row;
|
||||
}
|
||||
const raw = row.feedStatus;
|
||||
// A value that is neither text nor empty cannot map; keep it in the notes.
|
||||
const { feedStatus, notesSuffix } =
|
||||
raw === null || raw === undefined || typeof raw === 'string'
|
||||
? feedConditionFromInput(raw)
|
||||
: {
|
||||
feedStatus: null,
|
||||
notesSuffix: `Futter (alt): ${JSON.stringify(raw)}`,
|
||||
};
|
||||
return {
|
||||
...row,
|
||||
feedStatus,
|
||||
notes: appendLegacyFeedNote(asString(row, 'notes'), notesSuffix),
|
||||
};
|
||||
}
|
||||
|
||||
function documentsStorageRoot(): string {
|
||||
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
|
||||
}
|
||||
@@ -233,6 +259,7 @@ export class BackupService {
|
||||
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
|
||||
data.inspection = await this.prisma.inspection.findMany();
|
||||
data.taskTemplate = await this.prisma.taskTemplate.findMany();
|
||||
data.suggestion = await this.prisma.suggestion.findMany();
|
||||
data.task = await this.prisma.task.findMany();
|
||||
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
|
||||
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
|
||||
@@ -538,16 +565,14 @@ export class BackupService {
|
||||
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
|
||||
|
||||
for (const row of rowsOf('inspection')) {
|
||||
const inspection = {
|
||||
...withCurrentFeedVocabulary(row),
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
};
|
||||
await tx.inspection.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
}) as never,
|
||||
create: inspection as never,
|
||||
update: updateWith(inspection) as never,
|
||||
});
|
||||
}
|
||||
imported.inspection = rowsOf('inspection').length;
|
||||
@@ -577,6 +602,23 @@ export class BackupService {
|
||||
}
|
||||
imported.taskTemplate = rowsOf('taskTemplate').length;
|
||||
|
||||
// Before the tasks: Task.sourceSuggestionId points at them. After the
|
||||
// colonies: a suggestion may hang on one.
|
||||
for (const row of rowsOf('suggestion')) {
|
||||
const data = {
|
||||
...row,
|
||||
decidedByUserId: resolveOptionalUserId(
|
||||
asString(row, 'decidedByUserId'),
|
||||
),
|
||||
};
|
||||
await tx.suggestion.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: data as never,
|
||||
update: updateWith(data) as never,
|
||||
});
|
||||
}
|
||||
imported.suggestion = rowsOf('suggestion').length;
|
||||
|
||||
for (const row of rowsOf('task')) {
|
||||
await tx.task.upsert({
|
||||
where: { id: row.id as string },
|
||||
|
||||
Reference in new issue
Block a user