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

+217
View File
@@ -0,0 +1,217 @@
import { Prisma } from '../generated/prisma';
import { PrismaService } from '../src/prisma/prisma.service';
import {
lockRowForUpdate,
lockRowsForShare,
lockRowsForUpdate,
} from '../src/prisma/row-lock';
import {
DEFAULT_RULES,
lockRuleIn,
readRule,
} from '../src/automation/rule-config';
/**
* Schema-level guarantees of the T4 suggestion migrations that the Prisma
* schema alone cannot express (nullable unique dedupe key, enum values the
* database really accepts) and the row locks against a real PostgreSQL.
*/
describe('T4 schema I (e2e)', () => {
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const createdSuggestionIds: string[] = [];
const createdTaskIds: string[] = [];
const suggestionData = (
overrides: Partial<Prisma.SuggestionCreateManyInput> = {},
): Prisma.SuggestionCreateManyInput => ({
ruleKey: 'INSPECTION_INTERVAL',
source: 'RULE',
mode: 'DO',
intent: 'INSPECT',
relatedType: 'COLONY',
reasonCode: 'T4_SCHEMA_TEST',
evidence: { colonies: 1 },
...overrides,
});
beforeAll(() => {
prisma = new PrismaService();
});
afterAll(async () => {
await prisma.task.deleteMany({ where: { id: { in: createdTaskIds } } });
await prisma.suggestion.deleteMany({
where: {
OR: [
{ id: { in: createdSuggestionIds } },
{ reasonCode: `T4_SCHEMA_TEST_${suffix}` },
],
},
});
await prisma.$disconnect();
});
it('keeps at most one suggestion per dedupe key', async () => {
const data = suggestionData({
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
dedupeKey: `INSPECTION_INTERVAL:none:t-${suffix}`,
});
const first = await prisma.suggestion.createMany({
data: [data],
skipDuplicates: true,
});
const second = await prisma.suggestion.createMany({
data: [data],
skipDuplicates: true,
});
expect(first.count).toBe(1);
expect(second.count).toBe(0);
});
it('lets any number of suggestions share a missing dedupe key', async () => {
const reasonCode = `T4_SCHEMA_TEST_${suffix}`;
const result = await prisma.suggestion.createMany({
data: [
suggestionData({ reasonCode, dedupeKey: null }),
suggestionData({ reasonCode, dedupeKey: null }),
],
skipDuplicates: true,
});
expect(result.count).toBe(2);
});
it('starts a suggestion OPEN with normal priority', async () => {
const created = await prisma.suggestion.create({
data: suggestionData({
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
}),
});
createdSuggestionIds.push(created.id);
expect(created.status).toBe('OPEN');
expect(created.priority).toBe('NORMAL');
expect(created.dedupeKey).toBeNull();
});
it('keeps a task when its source suggestion is deleted', async () => {
const suggestion = await prisma.suggestion.create({
data: suggestionData({
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
}),
});
const task = await prisma.task.create({
data: { title: `T4 task ${suffix}`, sourceSuggestionId: suggestion.id },
});
createdTaskIds.push(task.id);
await prisma.suggestion.delete({ where: { id: suggestion.id } });
const after = await prisma.task.findUniqueOrThrow({
where: { id: task.id },
});
expect(after.sourceSuggestionId).toBeNull();
});
it('accepts the new enum values', async () => {
const rows = await prisma.$queryRaw<
{ enum: string; values: string[] }[]
>`SELECT t.typname AS enum, array_agg(e.enumlabel::text) AS values
FROM pg_type t JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname IN ('AutomationTriggerType', 'BatchMovementType', 'CalendarSyncSourceType')
GROUP BY t.typname`;
const valuesOf = (name: string) =>
rows.find((row) => row.enum === name)?.values ?? [];
expect(valuesOf('AutomationTriggerType')).toEqual(
expect.arrayContaining([
'INSPECTION_INTERVAL',
'QUEEN_CELLS_SWARM_CHECK',
'QUEEN_ACCEPTANCE_CHECK',
'SPLIT_SCHEDULE',
'COLONY_ENDED_CLEANUP',
'WITHDRAWAL_ENDED',
'HONEY_WATER_CONTENT',
'SEASON_AUTOPILOT',
'PHENOLOGY',
]),
);
expect(valuesOf('BatchMovementType')).toContain('BOTTLING');
expect(valuesOf('CalendarSyncSourceType')).toContain('WITHDRAWAL');
});
describe('row locks on the new tables', () => {
it('locks a suggestion by id and reports a missing one', async () => {
const suggestion = await prisma.suggestion.create({
data: suggestionData({
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
}),
});
createdSuggestionIds.push(suggestion.id);
const result = await prisma.$transaction(async (tx) => {
const existing = await lockRowForUpdate(
tx,
'Suggestion',
suggestion.id,
);
await lockRowsForUpdate(tx, 'Suggestion', [suggestion.id, 'nope']);
await lockRowsForShare(tx, 'Suggestion', [suggestion.id]);
const missing = await lockRowForUpdate(tx, 'Suggestion', 'nope');
return { existing, missing };
});
expect(result).toEqual({ existing: true, missing: false });
});
it('creates a missing rule from the defaults, locks it and reads it parsed', async () => {
await prisma.automationRule.deleteMany({
where: { triggerType: 'COLONY_ENDED_CLEANUP' },
});
const before = await readRule(prisma, 'COLONY_ENDED_CLEANUP');
expect(before.id).toBeNull();
expect(before.active).toBe(true);
const locked = await prisma.$transaction((tx) =>
lockRuleIn(tx, 'COLONY_ENDED_CLEANUP'),
);
expect(locked.active).toBe(true);
expect(locked.config).toEqual({});
expect(locked.actionTemplate).toBe(
DEFAULT_RULES.COLONY_ENDED_CLEANUP.actionTemplate,
);
const after = await readRule(prisma, 'COLONY_ENDED_CLEANUP');
expect(after.id).toBe(locked.id);
});
it('leaves an existing rule as it is', async () => {
const rule = await prisma.automationRule.upsert({
where: { triggerType: 'WITHDRAWAL_ENDED' },
create: {
triggerType: 'WITHDRAWAL_ENDED',
active: false,
config: { notify: false },
actionTemplate: '',
},
update: { active: false, config: { notify: false } },
});
const locked = await prisma.$transaction((tx) =>
lockRuleIn(tx, 'WITHDRAWAL_ENDED'),
);
expect(locked.id).toBe(rule.id);
expect(locked.active).toBe(false);
expect(locked.config).toEqual({ notify: false });
await prisma.automationRule.update({
where: { id: rule.id },
data: { active: true, config: { notify: true } },
});
});
});
});