fix: Paket 0 – data integrity, security hardening and field usability
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:
1 parent
f5b1964223
commit
10cbe9bf2d
310 files changed
+12177
-3746
No files matched your search
@@ -78,6 +78,20 @@ describe('ApiariesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('deactivate counts every living colony, weak and queenless ones included', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.apiary.update.mockResolvedValue(existingApiary);
|
||||
|
||||
await service.deactivate('apiary-1', 'user-1');
|
||||
|
||||
expect(prisma.colony.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
apiaryId: 'apiary-1',
|
||||
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('deactivate throws ConflictException when active colonies remain', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.colony.count.mockResolvedValue(2);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import { Apiary, ApiaryStatus } from '../../generated/prisma';
|
||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
||||
@@ -82,7 +83,7 @@ export class ApiariesService {
|
||||
// "location" has vanished from the active apiary list). Move the
|
||||
// colonies elsewhere or dissolve them first instead.
|
||||
const activeColonyCount = await this.prisma.colony.count({
|
||||
where: { apiaryId: id, status: 'ACTIVE' },
|
||||
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
|
||||
});
|
||||
if (activeColonyCount > 0) {
|
||||
throw new ConflictException(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateApiaryDto } from './create-apiary.dto';
|
||||
|
||||
export class UpdateApiaryDto extends PartialType(CreateApiaryDto) {}
|
||||
export class UpdateApiaryDto extends PartialType(CreateApiaryDto, {
|
||||
skipNullProperties: false,
|
||||
}) {}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Logger, NotFoundException } from '@nestjs/common';
|
||||
import { AutomationService } from './automation.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { AutomationTriggerType } from '../../generated/prisma';
|
||||
import { AutomationTriggerType, Prisma } from '../../generated/prisma';
|
||||
|
||||
describe('AutomationService', () => {
|
||||
let service: AutomationService;
|
||||
@@ -17,6 +17,8 @@ describe('AutomationService', () => {
|
||||
};
|
||||
automationSequence: { upsert: jest.Mock };
|
||||
task: { create: jest.Mock; findFirst: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
$queryRaw: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let notifications: { record: jest.Mock };
|
||||
@@ -57,6 +59,9 @@ describe('AutomationService', () => {
|
||||
create: jest.fn().mockResolvedValue({ id: 'task-1' }),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
// Interactive transactions run against the same mocks.
|
||||
$transaction: jest.fn((work: (tx: unknown) => unknown) => work(prisma)),
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
notifications = { record: jest.fn() };
|
||||
@@ -91,6 +96,122 @@ describe('AutomationService', () => {
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
// Rule evaluation runs after the triggering record is committed. A failure
|
||||
// here must not turn that request into an error - the client would retry
|
||||
// and store the inspection or treatment a second time.
|
||||
describe('post-commit evaluation', () => {
|
||||
let errorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
prisma.automationRule.upsert.mockRejectedValue(new Error('db down'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
const colony = { id: 'colony-1', healthStatus: 'GOOD' };
|
||||
const evaluations: [string, string, (s: AutomationService) => unknown][] = [
|
||||
[
|
||||
'evaluateInspection',
|
||||
'Inspection inspection-1',
|
||||
(s) =>
|
||||
s.evaluateInspection({
|
||||
id: 'inspection-1',
|
||||
colonyId: 'colony-1',
|
||||
} as never),
|
||||
],
|
||||
[
|
||||
'evaluateVarroaMeasurement',
|
||||
'VarroaMeasurement measurement-1',
|
||||
(s) =>
|
||||
s.evaluateVarroaMeasurement({
|
||||
id: 'measurement-1',
|
||||
colonyId: 'colony-1',
|
||||
} as never),
|
||||
],
|
||||
[
|
||||
'evaluateSwarmControlCheck',
|
||||
'SwarmControlCheck check-1',
|
||||
(s) =>
|
||||
s.evaluateSwarmControlCheck({
|
||||
id: 'check-1',
|
||||
colonyId: 'colony-1',
|
||||
} as never),
|
||||
],
|
||||
[
|
||||
'evaluateColonyHealth',
|
||||
'Colony colony-1',
|
||||
(s) =>
|
||||
s.evaluateColonyHealth(
|
||||
colony as never,
|
||||
{ ...colony, healthStatus: 'CRITICAL' } as never,
|
||||
),
|
||||
],
|
||||
[
|
||||
'evaluateStockItem',
|
||||
'StockItem item-1',
|
||||
(s) =>
|
||||
s.evaluateStockItem({
|
||||
id: 'item-1',
|
||||
quantity: 1,
|
||||
minThreshold: 5,
|
||||
} as never),
|
||||
],
|
||||
[
|
||||
'evaluateRawMaterialLow',
|
||||
'RawMaterialKind BEESWAX',
|
||||
(s) => s.evaluateRawMaterialLow('BEESWAX', 100),
|
||||
],
|
||||
[
|
||||
'evaluateTreatment',
|
||||
'Treatment treatment-1',
|
||||
(s) =>
|
||||
s.evaluateTreatment({
|
||||
id: 'treatment-1',
|
||||
colonyId: 'colony-1',
|
||||
date: new Date('2026-08-01'),
|
||||
endDate: null,
|
||||
product: 'Ameisensäure',
|
||||
} as never),
|
||||
],
|
||||
[
|
||||
'evaluateFeeding',
|
||||
'Feeding feeding-1',
|
||||
(s) =>
|
||||
s.evaluateFeeding({
|
||||
id: 'feeding-1',
|
||||
colonyId: 'colony-1',
|
||||
date: new Date('2026-09-01'),
|
||||
amount: 5,
|
||||
unit: 'l',
|
||||
} as never),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(evaluations)(
|
||||
'%s never throws and logs the failure with the record',
|
||||
async (_name, subject, evaluate) => {
|
||||
await expect(evaluate(service)).resolves.toBeUndefined();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain(subject);
|
||||
expect(message).toContain('db down');
|
||||
},
|
||||
);
|
||||
|
||||
it('still throws from generateNumber, which runs before the write', async () => {
|
||||
await expect(
|
||||
service.generateNumber('HONEY_BATCH_NUMBER_FORMAT'),
|
||||
).rejects.toThrow('db down');
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateInspection', () => {
|
||||
it('creates a task and notification when queen not seen and no eggs present', async () => {
|
||||
prisma.automationRule.findMany.mockResolvedValue([queenlessRule]);
|
||||
@@ -238,6 +359,37 @@ describe('AutomationService', () => {
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('checks for an open task and creates one under a lock on the rule row', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
|
||||
await service.evaluateVarroaMeasurement({
|
||||
id: 'measurement-6',
|
||||
colonyId: 'colony-1',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: 25,
|
||||
miteCount: 25,
|
||||
} as never);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
const [sql, ...values] = prisma.$queryRaw.mock.calls[0] as [
|
||||
TemplateStringsArray,
|
||||
...unknown[],
|
||||
];
|
||||
expect(sql.join('?')).toMatch(
|
||||
/SELECT id FROM "AutomationRule" WHERE id = \? FOR UPDATE/,
|
||||
);
|
||||
expect(values).toEqual(['rule-3']);
|
||||
// Lock first, then check, then create - all in that one transaction.
|
||||
const [lockOrder] = prisma.$queryRaw.mock.invocationCallOrder;
|
||||
const [checkOrder] = prisma.task.findFirst.mock.invocationCallOrder;
|
||||
const [createOrder] = prisma.task.create.mock.invocationCallOrder;
|
||||
expect(lockOrder).toBeLessThan(checkOrder);
|
||||
expect(checkOrder).toBeLessThan(createOrder);
|
||||
// Webhook and warning follow the commit, outside the lock.
|
||||
const [dispatchOrder] = webhooks.dispatch.mock.invocationCallOrder;
|
||||
expect(dispatchOrder).toBeGreaterThan(createOrder);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateSwarmControlCheck', () => {
|
||||
@@ -412,6 +564,34 @@ describe('AutomationService', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the config on null so the rule falls back to its defaults', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue({
|
||||
...varroaRule,
|
||||
config: null,
|
||||
});
|
||||
|
||||
await service.update('rule-3', { config: null as never }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
|
||||
[{ data: { config?: unknown } }],
|
||||
];
|
||||
// A Json column is cleared with DbNull - a plain null fails the write.
|
||||
expect(updateArg.data.config).toBe(Prisma.DbNull);
|
||||
});
|
||||
|
||||
it('leaves the config untouched when it is omitted', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue(varroaRule);
|
||||
|
||||
await service.update('rule-3', { active: true }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
|
||||
[{ data: { config?: unknown } }],
|
||||
];
|
||||
expect(updateArg.data.config).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateNumber', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { runAfterCommit } from '../common/after-commit';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
@@ -164,6 +165,8 @@ function dueDateFromConfig(
|
||||
|
||||
@Injectable()
|
||||
export class AutomationService {
|
||||
private readonly logger = new Logger(AutomationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
@@ -222,7 +225,12 @@ export class AutomationService {
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
config: dto.config as Prisma.InputJsonValue | undefined,
|
||||
// null resets the rule to its defaults; a Json column is cleared
|
||||
// with DbNull, a plain null would fail the write.
|
||||
config:
|
||||
dto.config === null
|
||||
? Prisma.DbNull
|
||||
: (dto.config as Prisma.InputJsonValue | undefined),
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
@@ -236,109 +244,141 @@ export class AutomationService {
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every evaluate*() runs after the triggering record is committed and is
|
||||
* called by the domain services without a guard - so it never throws (see
|
||||
* runAfterCommit). A failed evaluation is logged, the request succeeds.
|
||||
*/
|
||||
private evaluateAfterCommit(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
evaluate: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
return runAfterCommit(
|
||||
this.logger,
|
||||
{ action: 'Automation rule evaluation', entityType, entityId },
|
||||
evaluate,
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateInspection(inspection: Inspection): Promise<void> {
|
||||
await this.ensureDefaults();
|
||||
const rules = await this.prisma.automationRule.findMany({
|
||||
where: {
|
||||
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
|
||||
active: true,
|
||||
},
|
||||
await this.evaluateAfterCommit('Inspection', inspection.id, async () => {
|
||||
await this.ensureDefaults();
|
||||
const rules = await this.prisma.automationRule.findMany({
|
||||
where: {
|
||||
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule]));
|
||||
|
||||
const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS');
|
||||
if (
|
||||
queenlessRule &&
|
||||
inspection.queenSeen === false &&
|
||||
inspection.eggsPresent === false
|
||||
) {
|
||||
await this.triggerAction(
|
||||
queenlessRule,
|
||||
'COLONY',
|
||||
inspection.colonyId,
|
||||
inspection.id,
|
||||
);
|
||||
}
|
||||
|
||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
||||
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
|
||||
await this.triggerAction(
|
||||
feedRule,
|
||||
'COLONY',
|
||||
inspection.colonyId,
|
||||
inspection.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule]));
|
||||
|
||||
const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS');
|
||||
if (
|
||||
queenlessRule &&
|
||||
inspection.queenSeen === false &&
|
||||
inspection.eggsPresent === false
|
||||
) {
|
||||
await this.triggerAction(
|
||||
queenlessRule,
|
||||
'COLONY',
|
||||
inspection.colonyId,
|
||||
inspection.id,
|
||||
);
|
||||
}
|
||||
|
||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
||||
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
|
||||
await this.triggerAction(
|
||||
feedRule,
|
||||
'COLONY',
|
||||
inspection.colonyId,
|
||||
inspection.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async evaluateVarroaMeasurement(
|
||||
measurement: VarroaMeasurement,
|
||||
): Promise<void> {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
// The threshold is a natural mite fall rate (mites per day). Only that
|
||||
// method yields a value in this unit - a mite count from a sugar roll or
|
||||
// wash is per bee sample and would be compared against the wrong scale,
|
||||
// as would a raw board count without the number of days it covered.
|
||||
if (measurement.method !== 'NATURAL_MITE_FALL') {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as { thresholdMitesPerDay?: number } | null;
|
||||
const threshold = config?.thresholdMitesPerDay ?? 10;
|
||||
const value = measurement.calculatedValue;
|
||||
if (value !== null && value >= threshold) {
|
||||
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
|
||||
}
|
||||
await this.evaluateAfterCommit(
|
||||
'VarroaMeasurement',
|
||||
measurement.id,
|
||||
async () => {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
// The threshold is a natural mite fall rate (mites per day). Only
|
||||
// that method yields a value in this unit - a mite count from a
|
||||
// sugar roll or wash is per bee sample and would be compared against
|
||||
// the wrong scale, as would a raw board count without the number of
|
||||
// days it covered.
|
||||
if (measurement.method !== 'NATURAL_MITE_FALL') {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as { thresholdMitesPerDay?: number } | null;
|
||||
const threshold = config?.thresholdMitesPerDay ?? 10;
|
||||
const value = measurement.calculatedValue;
|
||||
if (value !== null && value >= threshold) {
|
||||
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
||||
await this.evaluateAfterCommit('SwarmControlCheck', check.id, async () => {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
if (check.swarmMood === 'HIGH') {
|
||||
await this.triggerAction(rule, 'COLONY', check.colonyId);
|
||||
}
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
if (check.swarmMood === 'HIGH') {
|
||||
await this.triggerAction(rule, 'COLONY', check.colonyId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
|
||||
async evaluateColonyHealth(before: Colony, after: Colony): Promise<void> {
|
||||
await this.ensureDefaults();
|
||||
if (
|
||||
after.healthStatus !== 'CRITICAL' ||
|
||||
before.healthStatus === 'CRITICAL'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
|
||||
await this.evaluateAfterCommit('Colony', after.id, async () => {
|
||||
await this.ensureDefaults();
|
||||
if (
|
||||
after.healthStatus !== 'CRITICAL' ||
|
||||
before.healthStatus === 'CRITICAL'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'COLONY', after.id);
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'COLONY', after.id);
|
||||
}
|
||||
|
||||
async evaluateStockItem(item: StockItem): Promise<void> {
|
||||
await this.ensureDefaults();
|
||||
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
||||
return;
|
||||
}
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'STOCK_ITEM_LOW' },
|
||||
await this.evaluateAfterCommit('StockItem', item.id, async () => {
|
||||
await this.ensureDefaults();
|
||||
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
||||
return;
|
||||
}
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'STOCK_ITEM_LOW' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'STORAGE', item.id);
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'STORAGE', item.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,40 +392,46 @@ export class AutomationService {
|
||||
kind: RawMaterialKind,
|
||||
remainingGrams: number,
|
||||
): Promise<void> {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as {
|
||||
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
|
||||
} | null;
|
||||
const minGrams = config?.minGrams?.[kind];
|
||||
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'GENERAL', kind, undefined, {
|
||||
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
|
||||
await this.evaluateAfterCommit('RawMaterialKind', kind, async () => {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as {
|
||||
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
|
||||
} | null;
|
||||
const minGrams = config?.minGrams?.[kind];
|
||||
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'GENERAL', kind, undefined, {
|
||||
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async evaluateTreatment(treatment: Treatment): Promise<void> {
|
||||
await this.evaluateFollowUp(
|
||||
'TREATMENT_FOLLOW_UP',
|
||||
treatment.colonyId,
|
||||
treatment.endDate ?? treatment.date,
|
||||
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`,
|
||||
await this.evaluateAfterCommit('Treatment', treatment.id, () =>
|
||||
this.evaluateFollowUp(
|
||||
'TREATMENT_FOLLOW_UP',
|
||||
treatment.colonyId,
|
||||
treatment.endDate ?? treatment.date,
|
||||
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateFeeding(feeding: Feeding): Promise<void> {
|
||||
await this.evaluateFollowUp(
|
||||
'FEEDING_FOLLOW_UP',
|
||||
feeding.colonyId,
|
||||
feeding.date,
|
||||
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
|
||||
await this.evaluateAfterCommit('Feeding', feeding.id, () =>
|
||||
this.evaluateFollowUp(
|
||||
'FEEDING_FOLLOW_UP',
|
||||
feeding.colonyId,
|
||||
feeding.date,
|
||||
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -507,6 +553,12 @@ export class AutomationService {
|
||||
* stock item or the check task for a colony is still open, further triggers
|
||||
* (the next removal below minimum, a second high varroa count) add nothing.
|
||||
* Repeating the same alarm would only bury the one task that matters.
|
||||
*
|
||||
* Check and create run in one transaction under a lock on the rule's row:
|
||||
* two evaluations at the same moment (parallel requests, several pods)
|
||||
* would otherwise both see "no open task" and both create one. The lock
|
||||
* is per rule and held for two short statements; webhook, warning and
|
||||
* audit entry follow after the commit.
|
||||
*/
|
||||
private async triggerAction(
|
||||
rule: AutomationRule,
|
||||
@@ -515,33 +567,38 @@ export class AutomationService {
|
||||
sourceInspectionId?: string,
|
||||
options: { baseDate?: Date; description?: string } = {},
|
||||
): Promise<void> {
|
||||
const alreadyOpen = await this.prisma.task.findFirst({
|
||||
where: {
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
relatedType,
|
||||
relatedId,
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (alreadyOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
||||
const config = rule.config as { dueInDays?: unknown } | null;
|
||||
const task = await this.prisma.task.create({
|
||||
data: {
|
||||
title,
|
||||
priority: 'HIGH',
|
||||
relatedType,
|
||||
relatedId,
|
||||
sourceInspectionId,
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
description: options.description,
|
||||
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
|
||||
},
|
||||
const task = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
|
||||
const alreadyOpen = await tx.task.findFirst({
|
||||
where: {
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
relatedType,
|
||||
relatedId,
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (alreadyOpen) {
|
||||
return null;
|
||||
}
|
||||
return tx.task.create({
|
||||
data: {
|
||||
title,
|
||||
priority: 'HIGH',
|
||||
relatedType,
|
||||
relatedId,
|
||||
sourceInspectionId,
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
description: options.description,
|
||||
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
|
||||
},
|
||||
});
|
||||
});
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
await this.webhooks.dispatch('task.created', task);
|
||||
await this.notifications.record({
|
||||
priority: 'WARNUNG',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class UpdateAutomationRuleDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
DEFAULT_BACKUP_MAX_IMPORT_BYTES,
|
||||
backupMaxImportBytes,
|
||||
exceedsBackupImportLimit,
|
||||
} from './backup-import-limit';
|
||||
|
||||
describe('backup-import-limit', () => {
|
||||
const original = process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
let warn: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warn.mockRestore();
|
||||
if (original === undefined) {
|
||||
delete process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
} else {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('allows 50 MB when BACKUP_MAX_IMPORT_BYTES is not set', () => {
|
||||
delete process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
|
||||
expect(DEFAULT_BACKUP_MAX_IMPORT_BYTES).toBe(50 * 1024 * 1024);
|
||||
expect(backupMaxImportBytes()).toBe(DEFAULT_BACKUP_MAX_IMPORT_BYTES);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes a positive byte count from BACKUP_MAX_IMPORT_BYTES', () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = '104857600';
|
||||
|
||||
expect(backupMaxImportBytes()).toBe(104857600);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['abc', '0', '-5', '1.5', '50MB'])(
|
||||
'falls back to the default with a logged warning for "%s"',
|
||||
(value) => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = value;
|
||||
|
||||
expect(backupMaxImportBytes()).toBe(DEFAULT_BACKUP_MAX_IMPORT_BYTES);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('BACKUP_MAX_IMPORT_BYTES'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('caps a value beyond 256 MB, since the whole upload is held in memory', () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = String(8 * 1024 * 1024 * 1024);
|
||||
|
||||
expect(backupMaxImportBytes()).toBe(256 * 1024 * 1024);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('warns only once for the same invalid value', () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = 'not-a-number';
|
||||
|
||||
backupMaxImportBytes();
|
||||
backupMaxImportBytes();
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('flags only a file larger than the limit', () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
||||
|
||||
expect(exceedsBackupImportLimit(1000)).toBe(false);
|
||||
expect(exceedsBackupImportLimit(1001)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Largest backup file POST /backup/import accepts - and the size every
|
||||
* export is checked against, so a backup that could no longer be restored
|
||||
* is noticed when it is written, not on the day it is needed. A full bundle
|
||||
* is far larger than the 5mb CSV import cap elsewhere (dozens of tables,
|
||||
* years of records, document contents as base64), hence its own limit.
|
||||
*
|
||||
* BACKUP_MAX_IMPORT_BYTES overrides the default. Like the other BACKUP_*
|
||||
* variables, an unusable value falls back instead of stopping the server,
|
||||
* here with a logged warning.
|
||||
*/
|
||||
export const DEFAULT_BACKUP_MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
||||
// The whole upload is held in memory, and parsed and decoded from there
|
||||
// (a string near V8's ~512 MB limit, a 512Mi pod) - no setting goes beyond.
|
||||
const BACKUP_MAX_IMPORT_CEILING_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
const logger = new Logger('BackupImportLimit');
|
||||
// Parsed once per distinct env value, so an invalid one warns only once.
|
||||
let resolved: { raw: string | undefined; bytes: number } | undefined;
|
||||
|
||||
function parseLimit(raw: string | undefined): number {
|
||||
if (raw === undefined || raw.trim() === '') {
|
||||
return DEFAULT_BACKUP_MAX_IMPORT_BYTES;
|
||||
}
|
||||
const bytes = Number(raw);
|
||||
if (!Number.isInteger(bytes) || bytes <= 0) {
|
||||
logger.warn(
|
||||
`BACKUP_MAX_IMPORT_BYTES="${raw}" ist keine positive ganze Zahl (Bytes) - es gilt der Standard von ${DEFAULT_BACKUP_MAX_IMPORT_BYTES} Bytes`,
|
||||
);
|
||||
return DEFAULT_BACKUP_MAX_IMPORT_BYTES;
|
||||
}
|
||||
if (bytes > BACKUP_MAX_IMPORT_CEILING_BYTES) {
|
||||
logger.warn(
|
||||
`BACKUP_MAX_IMPORT_BYTES=${bytes} liegt über der Obergrenze - es gelten ${BACKUP_MAX_IMPORT_CEILING_BYTES} Bytes`,
|
||||
);
|
||||
return BACKUP_MAX_IMPORT_CEILING_BYTES;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function backupMaxImportBytes(): number {
|
||||
const raw = process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
if (!resolved || resolved.raw !== raw) {
|
||||
resolved = { raw, bytes: parseLimit(raw) };
|
||||
}
|
||||
return resolved.bytes;
|
||||
}
|
||||
|
||||
/** True when a backup file of this size would be refused on import. */
|
||||
export function exceedsBackupImportLimit(sizeBytes: number): boolean {
|
||||
return sizeBytes > backupMaxImportBytes();
|
||||
}
|
||||
|
||||
/** A byte count as "52.4 MB" for log lines. */
|
||||
export function formatMegabytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { CronJob } from 'cron';
|
||||
import * as path from 'node:path';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { BackupSchedulerService } from './backup-scheduler.service';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
|
||||
jest.mock('node:fs/promises', () => ({
|
||||
mkdir: jest.fn(),
|
||||
writeFile: jest.fn(),
|
||||
readdir: jest.fn(),
|
||||
stat: jest.fn(),
|
||||
unlink: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
describe('BackupSchedulerService', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const directory = path.resolve('/data/backups');
|
||||
let service: BackupSchedulerService;
|
||||
let backupService: { exportSealed: jest.Mock };
|
||||
let error: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.BACKUP_STORAGE_PATH = directory;
|
||||
delete process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
delete process.env.BACKUP_RETENTION_COUNT;
|
||||
delete process.env.BACKUP_CRON_SCHEDULE;
|
||||
error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
||||
|
||||
backupService = {
|
||||
exportSealed: jest.fn().mockResolvedValue({
|
||||
format: 'apicore-backup',
|
||||
exportedAt: '2026-10-12T03:00:00.000Z',
|
||||
ciphertext: 'x'.repeat(2000),
|
||||
}),
|
||||
};
|
||||
const settings = {
|
||||
get: jest.fn().mockResolvedValue({
|
||||
storagePath: null,
|
||||
cronSchedule: null,
|
||||
retentionCount: null,
|
||||
}),
|
||||
};
|
||||
service = new BackupSchedulerService(
|
||||
backupService as unknown as BackupService,
|
||||
settings as unknown as BackupSettingsService,
|
||||
{} as SchedulerRegistry,
|
||||
);
|
||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.readdir as jest.Mock).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
error.mockRestore();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
function onDisk(files: { name: string; size: number; mtime: string }[]) {
|
||||
(fs.readdir as jest.Mock).mockResolvedValue(files.map((f) => f.name));
|
||||
(fs.stat as jest.Mock).mockImplementation((filePath: string) => {
|
||||
const file = files.find((f) => filePath.endsWith(f.name));
|
||||
return Promise.resolve({
|
||||
size: file?.size,
|
||||
mtime: new Date(file!.mtime),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('reports the import limit and the size of the newest backup', async () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
||||
onDisk([
|
||||
{
|
||||
name: 'apicore-backup-old.json',
|
||||
size: 5000,
|
||||
mtime: '2026-10-10T03:00:00.000Z',
|
||||
},
|
||||
{
|
||||
name: 'apicore-backup-new.json',
|
||||
size: 900,
|
||||
mtime: '2026-10-11T03:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status).toEqual(
|
||||
expect.objectContaining({
|
||||
importLimitBytes: 1000,
|
||||
lastBackupSizeBytes: 900,
|
||||
lastBackupExceedsImportLimit: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when the newest backup could no longer be imported', async () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
||||
onDisk([
|
||||
{
|
||||
name: 'apicore-backup-new.json',
|
||||
size: 1001,
|
||||
mtime: '2026-10-11T03:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.lastBackupSizeBytes).toBe(1001);
|
||||
expect(status.lastBackupExceedsImportLimit).toBe(true);
|
||||
});
|
||||
|
||||
it('still reports the import limit while no schedule is configured', async () => {
|
||||
delete process.env.BACKUP_STORAGE_PATH;
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status).toEqual(
|
||||
expect.objectContaining({
|
||||
enabled: false,
|
||||
importLimitBytes: 50 * 1024 * 1024,
|
||||
lastBackupSizeBytes: null,
|
||||
lastBackupExceedsImportLimit: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runNow', () => {
|
||||
it('keeps a backup larger than the import limit but logs an error', async () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
||||
|
||||
const result = await service.runNow();
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(result.fileName).toMatch(/^apicore-backup-.*\.json$/);
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Import-Limit'),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs nothing for a backup within the import limit', async () => {
|
||||
await service.runNow();
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// The export reads every table through Prisma; a Prisma message quotes
|
||||
// query values, so a failed scheduled run logs name, code and model only.
|
||||
describe('scheduled run', () => {
|
||||
it('logs a failed scheduled backup without the Prisma message', async () => {
|
||||
const registry = {
|
||||
doesExist: jest.fn().mockReturnValue(false),
|
||||
addCronJob: jest.fn(),
|
||||
deleteCronJob: jest.fn(),
|
||||
};
|
||||
const scheduled = new BackupSchedulerService(
|
||||
backupService as unknown as BackupService,
|
||||
{
|
||||
get: jest.fn().mockResolvedValue({
|
||||
storagePath: null,
|
||||
cronSchedule: null,
|
||||
retentionCount: null,
|
||||
}),
|
||||
} as unknown as BackupSettingsService,
|
||||
registry as unknown as SchedulerRegistry,
|
||||
);
|
||||
backupService.exportSealed.mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
|
||||
code: 'P2010',
|
||||
clientVersion: '7.9.1',
|
||||
meta: { modelName: 'Task' },
|
||||
}),
|
||||
);
|
||||
await scheduled.reload();
|
||||
const [[, job]] = registry.addCronJob.mock.calls as [[string, CronJob]];
|
||||
await job.stop();
|
||||
|
||||
await job.fireOnTick();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const failure = error.mock.calls.find(([message]) =>
|
||||
String(message).startsWith('Scheduled backup failed'),
|
||||
) as [string, unknown];
|
||||
expect(failure[0]).toContain('P2010');
|
||||
expect(failure[0]).not.toContain('Mustermann');
|
||||
expect(failure[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,16 @@ import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import {
|
||||
assertBackupCron,
|
||||
resolveGuiBackupPath,
|
||||
} from './backup-schedule-rules';
|
||||
import {
|
||||
backupMaxImportBytes,
|
||||
exceedsBackupImportLimit,
|
||||
formatMegabytes,
|
||||
} from './backup-import-limit';
|
||||
|
||||
const SCHEDULED_JOB_NAME = 'automatic-backup';
|
||||
const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
|
||||
@@ -28,6 +34,13 @@ export interface BackupScheduleStatus {
|
||||
cronSchedule: string | null;
|
||||
retentionCount: number | null;
|
||||
lastRunAt: string | null;
|
||||
// Largest file POST /backup/import accepts (BACKUP_MAX_IMPORT_BYTES).
|
||||
importLimitBytes: number;
|
||||
// Size of the newest backup file - taken from the file itself, so the
|
||||
// warning survives restarts and is the same on every replica.
|
||||
lastBackupSizeBytes: number | null;
|
||||
// True when that file could not be restored without a raised limit.
|
||||
lastBackupExceedsImportLimit: boolean;
|
||||
// Which fields are currently pinned by an env var (and therefore not
|
||||
// editable via the GUI form) - env always wins over the GUI-stored value.
|
||||
envOverrides: {
|
||||
@@ -125,9 +138,11 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
const job = CronJob.from({
|
||||
cronTime: settings.cronSchedule,
|
||||
onTick: () => {
|
||||
this.runScheduled(directory).catch((error) =>
|
||||
this.logger.error('Scheduled backup failed', error),
|
||||
);
|
||||
// Prisma errors by name/code/model only (see loggableError).
|
||||
this.runScheduled(directory).catch((error: unknown) => {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(`Scheduled backup failed: ${message}`, stack);
|
||||
});
|
||||
},
|
||||
start: true,
|
||||
});
|
||||
@@ -139,6 +154,7 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
|
||||
async getStatus(): Promise<BackupScheduleStatus> {
|
||||
const settings = await this.effectiveSettings();
|
||||
const importLimitBytes = backupMaxImportBytes();
|
||||
if (!settings.path) {
|
||||
return {
|
||||
enabled: false,
|
||||
@@ -146,12 +162,16 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
cronSchedule: null,
|
||||
retentionCount: null,
|
||||
lastRunAt: null,
|
||||
importLimitBytes,
|
||||
lastBackupSizeBytes: null,
|
||||
lastBackupExceedsImportLimit: false,
|
||||
envOverrides: settings.envOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
const files = await this.listBackupFiles(settings.path);
|
||||
const lastRunAt = files[0]?.mtime.toISOString() ?? null;
|
||||
const lastBackupSizeBytes = files[0]?.size ?? null;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -159,6 +179,11 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
cronSchedule: settings.cronSchedule,
|
||||
retentionCount: settings.retentionCount,
|
||||
lastRunAt,
|
||||
importLimitBytes,
|
||||
lastBackupSizeBytes,
|
||||
lastBackupExceedsImportLimit:
|
||||
lastBackupSizeBytes !== null &&
|
||||
exceedsBackupImportLimit(lastBackupSizeBytes),
|
||||
envOverrides: settings.envOverrides,
|
||||
};
|
||||
}
|
||||
@@ -205,8 +230,17 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
directory,
|
||||
`${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`,
|
||||
);
|
||||
const content = JSON.stringify(sealed);
|
||||
// Owner-only: the file holds every account's credentials (encrypted).
|
||||
await fs.writeFile(filePath, JSON.stringify(sealed), { mode: 0o600 });
|
||||
await fs.writeFile(filePath, content, { mode: 0o600 });
|
||||
const sizeBytes = Buffer.byteLength(content);
|
||||
if (exceedsBackupImportLimit(sizeBytes)) {
|
||||
// Kept anyway (a raised BACKUP_MAX_IMPORT_BYTES restores it); the
|
||||
// status reads the size from the file and warns in the UI.
|
||||
this.logger.error(
|
||||
`Backup ${path.basename(filePath)} ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern (BACKUP_MAX_IMPORT_BYTES erhöhen)`,
|
||||
);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
@@ -231,7 +265,7 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
|
||||
private async listBackupFiles(
|
||||
directory: string,
|
||||
): Promise<{ filePath: string; mtime: Date }[]> {
|
||||
): Promise<{ filePath: string; mtime: Date; size: number }[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(directory);
|
||||
@@ -249,7 +283,7 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
backupFiles.map(async (name) => {
|
||||
const filePath = path.join(directory, name);
|
||||
const stat = await fs.stat(filePath);
|
||||
return { filePath, mtime: stat.mtime };
|
||||
return { filePath, mtime: stat.mtime, size: stat.size };
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { BackupController } from './backup.controller';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSchedulerService } from './backup-scheduler.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
import { ReauthService } from '../auth/reauth/reauth.service';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
|
||||
describe('BackupController', () => {
|
||||
const originalLimit = process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
let controller: BackupController;
|
||||
let backupService: { importFile: jest.Mock; exportSealed: jest.Mock };
|
||||
let scheduler: { reload: jest.Mock };
|
||||
let reauth: { verify: jest.Mock };
|
||||
const actor = { id: 'admin-1' } as AuthenticatedUser;
|
||||
const file = {
|
||||
buffer: Buffer.from(JSON.stringify({ format: 'apicore-backup' })),
|
||||
} as Express.Multer.File;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
backupService = {
|
||||
importFile: jest.fn().mockResolvedValue({ imported: {}, skipped: [] }),
|
||||
exportSealed: jest.fn().mockResolvedValue({
|
||||
format: 'apicore-backup',
|
||||
exportedAt: '2026-10-12T03:00:00.000Z',
|
||||
ciphertext: 'x'.repeat(2000),
|
||||
}),
|
||||
};
|
||||
scheduler = { reload: jest.fn().mockResolvedValue(undefined) };
|
||||
reauth = { verify: jest.fn().mockResolvedValue(undefined) };
|
||||
controller = new BackupController(
|
||||
backupService as unknown as BackupService,
|
||||
scheduler as unknown as BackupSchedulerService,
|
||||
{} as BackupSettingsService,
|
||||
reauth as unknown as ReauthService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLimit === undefined) {
|
||||
delete process.env.BACKUP_MAX_IMPORT_BYTES;
|
||||
} else {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = originalLimit;
|
||||
}
|
||||
});
|
||||
|
||||
it('re-registers the backup schedule after an import, since it may restore other backup settings', async () => {
|
||||
await controller.import(file, { password: 'secret' }, actor);
|
||||
|
||||
expect(backupService.importFile).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.reload).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.reload.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
backupService.importFile.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the schedule alone when the import fails', async () => {
|
||||
backupService.importFile.mockRejectedValue(new Error('broken file'));
|
||||
|
||||
await expect(
|
||||
controller.import(file, { password: 'secret' }, actor),
|
||||
).rejects.toThrow('broken file');
|
||||
expect(scheduler.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('export', () => {
|
||||
let warn: jest.SpyInstance;
|
||||
let res: { setHeader: jest.Mock; send: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
res = { setHeader: jest.fn(), send: jest.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('still delivers a backup larger than the import limit, but logs a warning', async () => {
|
||||
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
|
||||
|
||||
await controller.export(res as unknown as Response);
|
||||
|
||||
expect(res.send).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Import-Limit'),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs nothing for a backup within the import limit', async () => {
|
||||
await controller.export(res as unknown as Response);
|
||||
|
||||
expect(res.send).toHaveBeenCalledTimes(1);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Logger,
|
||||
Patch,
|
||||
Post,
|
||||
Res,
|
||||
@@ -25,17 +26,18 @@ import {
|
||||
} from './backup-scheduler.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto';
|
||||
|
||||
// A full backup bundle can be much larger than the 5mb CSV import cap
|
||||
// elsewhere (dozens of tables, potentially years of records, plus embedded
|
||||
// label-template images) - 50mb comfortably covers a real dataset without
|
||||
// raising the app-wide JSON body limit for every other route.
|
||||
const MAX_BACKUP_FILE_SIZE_BYTES = 50 * 1024 * 1024;
|
||||
import {
|
||||
backupMaxImportBytes,
|
||||
exceedsBackupImportLimit,
|
||||
formatMegabytes,
|
||||
} from './backup-import-limit';
|
||||
|
||||
@ApiTags('backup')
|
||||
@ApiBearerAuth()
|
||||
@Controller('backup')
|
||||
export class BackupController {
|
||||
private readonly logger = new Logger(BackupController.name);
|
||||
|
||||
constructor(
|
||||
private readonly backupService: BackupService,
|
||||
private readonly backupSchedulerService: BackupSchedulerService,
|
||||
@@ -76,19 +78,29 @@ export class BackupController {
|
||||
@Get('export')
|
||||
async export(@Res() res: Response): Promise<void> {
|
||||
const sealed = await this.backupService.exportSealed();
|
||||
const body = JSON.stringify(sealed);
|
||||
const sizeBytes = Buffer.byteLength(body);
|
||||
if (exceedsBackupImportLimit(sizeBytes)) {
|
||||
// Still delivered: losing the data is worse than needing a raised
|
||||
// BACKUP_MAX_IMPORT_BYTES to restore it. The UI compares the file
|
||||
// against the limit from GET /backup/schedule and warns there.
|
||||
this.logger.warn(
|
||||
`Exportiertes Backup ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern`,
|
||||
);
|
||||
}
|
||||
const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(JSON.stringify(sealed));
|
||||
res.send(body);
|
||||
}
|
||||
|
||||
@RequirePermission('backup:import')
|
||||
@Post('import')
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(
|
||||
// Size limit from MulterModule in BackupModule (BACKUP_MAX_IMPORT_BYTES).
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_BACKUP_FILE_SIZE_BYTES },
|
||||
fileFilter: (_req, file, callback) => {
|
||||
if (!file.originalname.toLowerCase().endsWith('.json')) {
|
||||
callback(
|
||||
@@ -117,6 +129,9 @@ export class BackupController {
|
||||
} catch {
|
||||
throw new BadRequestException('Datei ist kein gültiges JSON');
|
||||
}
|
||||
return this.backupService.importFile(parsed, actor.id);
|
||||
const summary = await this.backupService.importFile(parsed, actor.id);
|
||||
// The backup may have restored the schedule's settings too.
|
||||
await this.backupSchedulerService.reload();
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { ReauthModule } from '../auth/reauth/reauth.module';
|
||||
import { BackupController } from './backup.controller';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSchedulerService } from './backup-scheduler.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
import { backupMaxImportBytes } from './backup-import-limit';
|
||||
|
||||
@Module({
|
||||
imports: [ReauthModule],
|
||||
imports: [
|
||||
ReauthModule,
|
||||
// Upload limit of POST /backup/import. A factory rather than a constant
|
||||
// in the decorator: it runs once the app starts, after ConfigModule has
|
||||
// loaded .env, so the limit is the same one exports are checked against.
|
||||
MulterModule.registerAsync({
|
||||
useFactory: () => ({ limits: { fileSize: backupMaxImportBytes() } }),
|
||||
}),
|
||||
],
|
||||
controllers: [BackupController],
|
||||
providers: [BackupService, BackupSchedulerService, BackupSettingsService],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BadRequestException, Logger } from '@nestjs/common';
|
||||
import * as path from 'node:path';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import {
|
||||
BackupService,
|
||||
LEGACY_UNSEALED_TABLES,
|
||||
MODELS_EXCLUDED_FROM_BACKUP,
|
||||
} from './backup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
@@ -8,6 +14,7 @@ jest.mock('node:fs/promises', () => ({
|
||||
readFile: jest.fn(),
|
||||
writeFile: jest.fn(),
|
||||
mkdir: jest.fn(),
|
||||
unlink: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
@@ -36,15 +43,25 @@ const BUSINESS_ENTITY_KEYS = [
|
||||
'honeyBatch',
|
||||
'honeyMeasurement',
|
||||
'honeyBatchRelocation',
|
||||
'honeyBatchContainerAllocation',
|
||||
'stockItem',
|
||||
'stockMovement',
|
||||
'product',
|
||||
'labelTemplate',
|
||||
'bottlingBatch',
|
||||
'rawMaterialLot',
|
||||
'productionBatch',
|
||||
'productionInput',
|
||||
'shopStockRequest',
|
||||
'batchMovement',
|
||||
'equipment',
|
||||
'webhookSubscription',
|
||||
'automationRule',
|
||||
'automationSequence',
|
||||
'siteSettings',
|
||||
'backupSettings',
|
||||
'calendarSyncSettings',
|
||||
'calendarSyncOutboxEntry',
|
||||
] as const;
|
||||
|
||||
const ALL_ENTITY_KEYS = [
|
||||
@@ -57,17 +74,30 @@ const ALL_ENTITY_KEYS = [
|
||||
'auditLog',
|
||||
] as const;
|
||||
|
||||
// Prisma delegate name of a model: `productionBatch` for ProductionBatch.
|
||||
function modelKey(model: string): string {
|
||||
return model.charAt(0).toLowerCase() + model.slice(1);
|
||||
}
|
||||
|
||||
// Every model the schema has - so a table added later is mocked too and the
|
||||
// coverage guard below reports it instead of crashing on a missing mock.
|
||||
const MODEL_KEYS = Object.values(Prisma.ModelName).map(modelKey);
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
describe('BackupService', () => {
|
||||
let service: BackupService;
|
||||
let prisma: Record<string, { findMany: jest.Mock }> & {
|
||||
user: { findMany: jest.Mock };
|
||||
rolePermission: { findMany: jest.Mock };
|
||||
document: { findMany: jest.Mock };
|
||||
document: { findMany: jest.Mock; count: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: Record<string, { upsert: jest.Mock; update?: jest.Mock }> & {
|
||||
permission: { findUnique: jest.Mock };
|
||||
user: { upsert: jest.Mock; findMany: jest.Mock };
|
||||
queen: { upsert: jest.Mock; update: jest.Mock; findMany: jest.Mock };
|
||||
document: { upsert: jest.Mock; findUnique: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let permissionResolver: { invalidateAll: jest.Mock };
|
||||
@@ -77,20 +107,14 @@ describe('BackupService', () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
tx = {} as never;
|
||||
for (const key of [
|
||||
...BUSINESS_ENTITY_KEYS,
|
||||
'role',
|
||||
'user',
|
||||
'rolePermission',
|
||||
'document',
|
||||
'notification',
|
||||
'auditLog',
|
||||
]) {
|
||||
for (const key of MODEL_KEYS) {
|
||||
tx[key] = {
|
||||
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }),
|
||||
};
|
||||
}
|
||||
tx.queen.update = jest.fn().mockResolvedValue({});
|
||||
tx.queen.findMany = jest.fn().mockResolvedValue([]);
|
||||
tx.document.findUnique = jest.fn().mockResolvedValue(null);
|
||||
tx.permission = {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }),
|
||||
};
|
||||
@@ -100,23 +124,14 @@ describe('BackupService', () => {
|
||||
.mockResolvedValue([]);
|
||||
|
||||
prisma = {
|
||||
user: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
rolePermission: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
document: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
} as never;
|
||||
for (const key of [
|
||||
'role',
|
||||
...BUSINESS_ENTITY_KEYS,
|
||||
'notification',
|
||||
'auditLog',
|
||||
]) {
|
||||
for (const key of MODEL_KEYS) {
|
||||
prisma[key] = { findMany: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
prisma.document.count = jest.fn().mockResolvedValue(0);
|
||||
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
@@ -129,10 +144,10 @@ describe('BackupService', () => {
|
||||
});
|
||||
|
||||
describe('exportAll', () => {
|
||||
it('returns a version 2 bundle with every entity key, including role/user/document', async () => {
|
||||
it('returns a version 3 bundle with every entity key, including role/user/document', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
expect(bundle.version).toBe(2);
|
||||
expect(bundle.version).toBe(3);
|
||||
expect(typeof bundle.exportedAt).toBe('string');
|
||||
for (const key of ALL_ENTITY_KEYS) {
|
||||
expect(bundle.data).toHaveProperty(key);
|
||||
@@ -589,5 +604,567 @@ describe('BackupService', () => {
|
||||
expect.arrayContaining(['role', 'user', 'webhookSubscription']),
|
||||
);
|
||||
});
|
||||
|
||||
it('restores production, raw material and stock ledger rows by id, remapping their optional userId', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
rawMaterialLot: [{ id: 'lot-1', userId: 'ghost-user' }],
|
||||
productionBatch: [
|
||||
{ id: 'batch-1', productId: 'product-1', userId: 'known-user' },
|
||||
],
|
||||
productionInput: [
|
||||
{
|
||||
id: 'input-1',
|
||||
productionBatchId: 'batch-1',
|
||||
rawMaterialLotId: 'lot-1',
|
||||
},
|
||||
],
|
||||
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
|
||||
batchMovement: [
|
||||
{
|
||||
id: 'movement-1',
|
||||
productionBatchId: 'batch-1',
|
||||
shopRequestId: 'request-1',
|
||||
userId: null,
|
||||
},
|
||||
],
|
||||
honeyBatchContainerAllocation: [
|
||||
{
|
||||
id: 'allocation-1',
|
||||
honeyBatchId: 'honey-1',
|
||||
storageContainerId: 'container-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const firstCall = (key: string) =>
|
||||
(
|
||||
tx[key].upsert.mock.calls as [
|
||||
{
|
||||
where: Record<string, unknown>;
|
||||
create: Record<string, unknown>;
|
||||
},
|
||||
][]
|
||||
)[0][0];
|
||||
expect(firstCall('rawMaterialLot').where).toEqual({ id: 'lot-1' });
|
||||
expect(firstCall('rawMaterialLot').create.userId).toBe('admin-1');
|
||||
expect(firstCall('productionBatch').create.userId).toBe('known-user');
|
||||
expect(firstCall('batchMovement').create.userId).toBeNull();
|
||||
expect(firstCall('productionInput').where).toEqual({ id: 'input-1' });
|
||||
expect(firstCall('shopStockRequest').where).toEqual({ id: 'request-1' });
|
||||
expect(firstCall('honeyBatchContainerAllocation').where).toEqual({
|
||||
id: 'allocation-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes every parent row before the rows that reference it', async () => {
|
||||
const one = (id: string) => [{ id }];
|
||||
await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
apiary: one('apiary-1'),
|
||||
harvest: one('harvest-1'),
|
||||
storageContainer: one('container-1'),
|
||||
honeyBatch: one('honey-1'),
|
||||
honeyBatchContainerAllocation: one('allocation-1'),
|
||||
product: one('product-1'),
|
||||
bottlingBatch: one('bottling-1'),
|
||||
rawMaterialLot: one('lot-1'),
|
||||
productionBatch: one('batch-1'),
|
||||
productionInput: one('input-1'),
|
||||
shopStockRequest: one('request-1'),
|
||||
batchMovement: one('movement-1'),
|
||||
automationRule: [{ triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT' }],
|
||||
automationSequence: [
|
||||
{
|
||||
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
scope: 'BEESWAX',
|
||||
value: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const order = (key: string) => tx[key].upsert.mock.invocationCallOrder[0];
|
||||
const dependencies: [parent: string, child: string][] = [
|
||||
['storageContainer', 'honeyBatchContainerAllocation'],
|
||||
['honeyBatch', 'honeyBatchContainerAllocation'],
|
||||
['apiary', 'rawMaterialLot'],
|
||||
['harvest', 'rawMaterialLot'],
|
||||
['product', 'productionBatch'],
|
||||
['productionBatch', 'productionInput'],
|
||||
['rawMaterialLot', 'productionInput'],
|
||||
['bottlingBatch', 'batchMovement'],
|
||||
['productionBatch', 'batchMovement'],
|
||||
['rawMaterialLot', 'batchMovement'],
|
||||
['shopStockRequest', 'batchMovement'],
|
||||
['automationRule', 'automationSequence'],
|
||||
];
|
||||
for (const [parent, child] of dependencies) {
|
||||
expect({ parent, child, before: order(parent) < order(child) }).toEqual(
|
||||
{ parent, child, before: true },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('upserts automationSequence by triggerType and scope', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
automationSequence: [
|
||||
{
|
||||
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
scope: 'BEESWAX',
|
||||
value: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.automationSequence.upsert.mock.calls as [
|
||||
[{ where: unknown; create: Record<string, unknown> }],
|
||||
];
|
||||
expect(call.where).toEqual({
|
||||
triggerType_scope: {
|
||||
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
scope: 'BEESWAX',
|
||||
},
|
||||
});
|
||||
expect(call.create.value).toBe(7);
|
||||
});
|
||||
|
||||
it('upserts a calendar outbox entry by its source and keeps the target row id', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
calendarSyncOutboxEntry: [
|
||||
{
|
||||
id: 'entry-source-id',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: 'graph-event-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.calendarSyncOutboxEntry.upsert.mock.calls as [
|
||||
[{ where: unknown; update: Record<string, unknown> }],
|
||||
];
|
||||
expect(call.where).toEqual({
|
||||
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
|
||||
});
|
||||
expect(call.update).not.toHaveProperty('id');
|
||||
expect(call.update.graphEventId).toBe('graph-event-1');
|
||||
});
|
||||
|
||||
it('upserts the backup and calendar settings singletons by id without rewriting it', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
backupSettings: [{ id: 'singleton', retentionCount: 30 }],
|
||||
calendarSyncSettings: [{ id: 'singleton', enabled: true }],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
for (const key of ['backupSettings', 'calendarSyncSettings']) {
|
||||
const [[call]] = tx[key].upsert.mock.calls as [
|
||||
[{ where: unknown; update: Record<string, unknown> }],
|
||||
];
|
||||
expect(call.where).toEqual({ id: 'singleton' });
|
||||
expect(call.update).not.toHaveProperty('id');
|
||||
}
|
||||
});
|
||||
|
||||
it('takes no calendar sync or backup schedule from an unsealed file', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
backupSettings: [{ id: 'singleton', retentionCount: 1 }],
|
||||
calendarSyncSettings: [{ id: 'singleton', groupId: 'evil' }],
|
||||
calendarSyncOutboxEntry: [
|
||||
{ id: 'entry-1', sourceType: 'TASK', sourceId: 'task-1' },
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
{ trusted: false },
|
||||
);
|
||||
|
||||
expect(tx.backupSettings.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.calendarSyncSettings.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
||||
expect(summary.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
'backupSettings',
|
||||
'calendarSyncSettings',
|
||||
'calendarSyncOutboxEntry',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('restores an older bundle that lacks the newer tables as if they were empty', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(summary.imported).toEqual(
|
||||
expect.objectContaining({
|
||||
apiary: 1,
|
||||
productionBatch: 0,
|
||||
rawMaterialLot: 0,
|
||||
batchMovement: 0,
|
||||
automationSequence: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A file without the seal cannot be proven to come from ApiCore: it may
|
||||
// only add plain master and field records, nothing else.
|
||||
describe('unsealed file', () => {
|
||||
const UNSEALED = { trusted: false };
|
||||
const SEALED_ONLY_SAMPLE: Record<string, Row[]> = {
|
||||
automationRule: [{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT' }],
|
||||
automationSequence: [
|
||||
{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT', scope: 'x', value: 0 },
|
||||
],
|
||||
stockItem: [{ id: 'stock-1' }],
|
||||
stockMovement: [{ id: 'movement-1' }],
|
||||
batchMovement: [{ id: 'ledger-1' }],
|
||||
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
|
||||
rawMaterialLot: [{ id: 'lot-1' }],
|
||||
productionBatch: [{ id: 'batch-1' }],
|
||||
productionInput: [{ id: 'input-1' }],
|
||||
honeyBatchContainerAllocation: [{ id: 'allocation-1' }],
|
||||
siteSettings: [{ id: 'singleton', holderName: 'Fake' }],
|
||||
document: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
|
||||
},
|
||||
],
|
||||
notification: [{ id: 'note-1', message: 'Bitte hier anmelden' }],
|
||||
};
|
||||
|
||||
it('takes no counters, ledgers, shop requests, settings, documents or notifications from it', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
...SEALED_ONLY_SAMPLE,
|
||||
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
UNSEALED,
|
||||
);
|
||||
|
||||
for (const key of Object.keys(SEALED_ONLY_SAMPLE)) {
|
||||
expect({ key, calls: tx[key].upsert.mock.calls.length }).toEqual({
|
||||
key,
|
||||
calls: 0,
|
||||
});
|
||||
}
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
|
||||
expect([...summary.skipped].sort()).toEqual(
|
||||
Object.keys(SEALED_ONLY_SAMPLE).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows only plain master and field records - no table added later', () => {
|
||||
expect([...LEGACY_UNSEALED_TABLES].sort()).toEqual(
|
||||
[
|
||||
'apiary',
|
||||
'queen',
|
||||
'colony',
|
||||
'colonyApiaryHistory',
|
||||
'colonyLineage',
|
||||
'queenEvaluation',
|
||||
'queenColonyHistory',
|
||||
'inspection',
|
||||
'taskTemplate',
|
||||
'task',
|
||||
'varroaMeasurement',
|
||||
'swarmControlCheck',
|
||||
'winterCheck',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'harvest',
|
||||
'harvestColony',
|
||||
'extraction',
|
||||
'extractionHarvest',
|
||||
'storageContainer',
|
||||
'honeyBatch',
|
||||
'honeyMeasurement',
|
||||
'honeyBatchRelocation',
|
||||
'product',
|
||||
'labelTemplate',
|
||||
'bottlingBatch',
|
||||
'equipment',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the records it carries but never rewrites ones that already exist', async () => {
|
||||
const data = Object.fromEntries(
|
||||
LEGACY_UNSEALED_TABLES.map((key) => [
|
||||
key,
|
||||
[{ id: `${key}-1`, notes: 'aus der Datei' }],
|
||||
]),
|
||||
);
|
||||
|
||||
await service.importAll(
|
||||
{ version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data },
|
||||
'admin-1',
|
||||
UNSEALED,
|
||||
);
|
||||
|
||||
for (const key of LEGACY_UNSEALED_TABLES) {
|
||||
const [[call]] = tx[key].upsert.mock.calls as [
|
||||
[{ create: Row; update: Row }],
|
||||
];
|
||||
expect({ key, update: call.update }).toEqual({ key, update: {} });
|
||||
expect(call.create.notes).toBe('aus der Datei');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not re-link the mother line of a queen that was already there', async () => {
|
||||
tx.queen.findMany.mockResolvedValue([{ id: 'queen-existing' }]);
|
||||
|
||||
await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
queen: [
|
||||
{ id: 'queen-existing', motherQueenId: 'queen-mother' },
|
||||
{ id: 'queen-new', motherQueenId: 'queen-mother' },
|
||||
{ id: 'queen-mother', motherQueenId: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
UNSEALED,
|
||||
);
|
||||
|
||||
expect(tx.queen.update).toHaveBeenCalledTimes(1);
|
||||
expect(tx.queen.update).toHaveBeenCalledWith({
|
||||
where: { id: 'queen-new' },
|
||||
data: { motherQueenId: 'queen-mother' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the import as unsealed and lists no unknown sections as skipped', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: { notATable: [{ id: 'x' }], apiary: [{ id: 'apiary-1' }] },
|
||||
},
|
||||
'admin-1',
|
||||
UNSEALED,
|
||||
);
|
||||
|
||||
expect(summary.sealed).toBe(false);
|
||||
expect(summary.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('still restores everything, overwriting existing rows, from a sealed file', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
|
||||
stockMovement: [{ id: 'movement-1', userId: null }],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(summary.sealed).toBe(true);
|
||||
expect(summary.skipped).toEqual([]);
|
||||
const [[call]] = tx.apiary.upsert.mock.calls as [[{ update: Row }]];
|
||||
expect(call.update).toEqual({ id: 'apiary-1', name: 'Stand Wald' });
|
||||
expect(tx.stockMovement.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Restoring a document writes its content to a new file; the file the row
|
||||
// pointed at before must not be left behind on disk.
|
||||
describe('replaced document files', () => {
|
||||
const storageRoot = path.resolve(
|
||||
process.env.DOCUMENTS_STORAGE_PATH ?? './uploads',
|
||||
);
|
||||
const oldFile = path.join(storageRoot, 'colony', 'old.pdf');
|
||||
const bundle = {
|
||||
version: 3,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('%PDF-1.4 new').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
let warn: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('deletes the file a restored row replaced, only after the import committed', async () => {
|
||||
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
|
||||
|
||||
await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledWith(oldFile);
|
||||
expect(
|
||||
(fs.unlink as jest.Mock).mock.invocationCallOrder[0],
|
||||
).toBeGreaterThan(tx.document.upsert.mock.invocationCallOrder[0]);
|
||||
expect(prisma.document.count).toHaveBeenCalledWith({
|
||||
where: { storagePath: oldFile },
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes nothing when the import fails', async () => {
|
||||
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
|
||||
tx.notification.upsert.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
service.importAll(
|
||||
{
|
||||
...bundle,
|
||||
data: { ...bundle.data, notification: [{ id: 'note-1' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toThrow('db down');
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a replaced file outside the documents root and logs it', async () => {
|
||||
tx.document.findUnique.mockResolvedValue({ storagePath: '/etc/passwd' });
|
||||
|
||||
await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps a replaced file that another document row still uses', async () => {
|
||||
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
|
||||
prisma.document.count.mockResolvedValue(1);
|
||||
|
||||
await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes the import when a replaced file cannot be deleted, and logs it', async () => {
|
||||
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
|
||||
(fs.unlink as jest.Mock).mockRejectedValue(new Error('EBUSY'));
|
||||
|
||||
await expect(
|
||||
service.importAll(bundle, 'admin-1', TRUSTED),
|
||||
).resolves.toEqual(expect.objectContaining({ sealed: true }));
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deletes nothing for a document that is new to this database', async () => {
|
||||
await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Drift guard: a model added to the schema has to be backed up or listed
|
||||
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
|
||||
describe('model coverage', () => {
|
||||
it('backs up every Prisma model or names why it is left out', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
const uncovered = Object.values(Prisma.ModelName).filter(
|
||||
(model) =>
|
||||
!(modelKey(model) in bundle.data) &&
|
||||
!(model in MODELS_EXCLUDED_FROM_BACKUP),
|
||||
);
|
||||
|
||||
expect(uncovered).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives every excluded model a reason and never exports it anyway', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
for (const [model, reason] of Object.entries(
|
||||
MODELS_EXCLUDED_FROM_BACKUP,
|
||||
)) {
|
||||
expect(bundle.data).not.toHaveProperty(modelKey(model));
|
||||
expect(reason?.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('restores every table it exports', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||
|
||||
expect(Object.keys(summary.imported).sort()).toEqual(
|
||||
Object.keys(bundle.data).sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../documents/document-mime-types';
|
||||
import { matchesFileSignature } from '../common/file-signature';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { DocumentEntityType } from '../../generated/prisma';
|
||||
import { DocumentEntityType, Prisma } from '../../generated/prisma';
|
||||
import {
|
||||
SealedBackup,
|
||||
isSealedBackup,
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
sealBackup,
|
||||
} from './backup-crypto';
|
||||
|
||||
const BACKUP_VERSION = 2;
|
||||
// 3: production, raw material, stock ledger, number sequence, calendar sync
|
||||
// and backup schedule tables. Older bundles simply lack those sections and
|
||||
// restore them as empty.
|
||||
const BACKUP_VERSION = 3;
|
||||
const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface BackupBundle {
|
||||
@@ -30,34 +33,107 @@ export interface BackupBundle {
|
||||
export interface BackupImportSummary {
|
||||
imported: Record<string, number>;
|
||||
/**
|
||||
* Tables left out because the file was an old, unsealed (v2) backup: its
|
||||
* contents cannot be proven to come from ApiCore, so nothing that grants
|
||||
* access (users, roles, webhooks) or rewrites history (audit log) is
|
||||
* taken from it.
|
||||
* Tables the file carried but an unsealed backup may not write - every
|
||||
* table outside LEGACY_UNSEALED_TABLES.
|
||||
*/
|
||||
skipped: string[];
|
||||
/** False for an old, unsealed file: restricted and add-only. */
|
||||
sealed: boolean;
|
||||
}
|
||||
|
||||
// What an unsealed file may never write - see BackupImportSummary.skipped.
|
||||
const TRUSTED_ONLY_TABLES = [
|
||||
'role',
|
||||
'user',
|
||||
'rolePermission',
|
||||
'webhookSubscription',
|
||||
'auditLog',
|
||||
/**
|
||||
* All an unsealed file may write. Its contents cannot be proven to come from
|
||||
* ApiCore (it may be hand-built), so it is limited to the plain beekeeping
|
||||
* master and field records of the old unsealed (v2) format - records an
|
||||
* admin could just as well type in again - and it may only add missing
|
||||
* rows, never rewrite existing ones.
|
||||
*
|
||||
* Sealed-only, and why (any table not listed here is sealed-only too, so
|
||||
* tables added later are safe by default):
|
||||
* - role, user, rolePermission: grant access.
|
||||
* - webhookSubscription, calendarSyncSettings, calendarSyncOutboxEntry:
|
||||
* send data out, or update/delete events in an M365 calendar.
|
||||
* - backupSettings: retention 1 would prune every older backup.
|
||||
* - auditLog: history must not be rewritten.
|
||||
* - automationRule, automationSequence: hold the lot-number counters (a
|
||||
* reset hands out duplicate batch numbers) and the rule thresholds.
|
||||
* - stockItem, stockMovement, batchMovement: running stock and the stock
|
||||
* ledgers - never forged, only booked.
|
||||
* - shopStockRequest: the shop's idempotency keys - a planted one would
|
||||
* swallow a real sale.
|
||||
* - rawMaterialLot, productionBatch, productionInput,
|
||||
* honeyBatchContainerAllocation: newer than the unsealed format, so a
|
||||
* genuine old file never carries them.
|
||||
* - siteSettings: keeper name/address printed on the Bestandsbuch and the
|
||||
* public links.
|
||||
* - document: writes files to the server's disk.
|
||||
* - notification: messages shown to users as coming from ApiCore.
|
||||
*/
|
||||
export const LEGACY_UNSEALED_TABLES = [
|
||||
'apiary',
|
||||
'queen',
|
||||
'colony',
|
||||
'colonyApiaryHistory',
|
||||
'colonyLineage',
|
||||
'queenEvaluation',
|
||||
'queenColonyHistory',
|
||||
'inspection',
|
||||
'taskTemplate',
|
||||
'task',
|
||||
'varroaMeasurement',
|
||||
'swarmControlCheck',
|
||||
'winterCheck',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'harvest',
|
||||
'harvestColony',
|
||||
'extraction',
|
||||
'extractionHarvest',
|
||||
'storageContainer',
|
||||
'honeyBatch',
|
||||
'honeyMeasurement',
|
||||
'honeyBatchRelocation',
|
||||
'product',
|
||||
'labelTemplate',
|
||||
'bottlingBatch',
|
||||
'equipment',
|
||||
] as const;
|
||||
|
||||
// Bundle sections that name a real table - a file's other keys are ignored
|
||||
// for every file and so never reported as skipped.
|
||||
const MODEL_SECTIONS = new Set<string>(
|
||||
Object.values(Prisma.ModelName).map(
|
||||
(model) => model.charAt(0).toLowerCase() + model.slice(1),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Models deliberately left out of the backup, each with its reason. Every
|
||||
* other Prisma model is a section of the bundle, named like its client
|
||||
* delegate (`productionBatch`). The model coverage test in
|
||||
* backup.service.spec.ts fails for a model that is neither, so a new table
|
||||
* cannot silently drop out of the backup.
|
||||
*/
|
||||
export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
|
||||
Partial<Record<Prisma.ModelName, string>>
|
||||
> = {
|
||||
Permission:
|
||||
'Code-derived catalog, re-synced from the code on every boot. RolePermission rows reference permissions by resource+action instead, resolved against the target database.',
|
||||
RefreshToken:
|
||||
'Live session tokens - a session must never be revivable from a backup file.',
|
||||
ConsumedToken:
|
||||
'Replay guard for one-time tokens that expire within minutes, so always stale by the time a backup is restored.',
|
||||
ApiKey:
|
||||
'Machine credentials stay out on purpose (ARCHITECTURE.md assumption 14): a new key is issued after a restore. BatchMovement/ShopStockRequest keep apiKeyId only as a plain historical reference.',
|
||||
IdempotencyRecord:
|
||||
'Volatile cache of first responses to retried create requests (ARCHITECTURE.md assumption 11) - a restored entry could only replay a stale response.',
|
||||
};
|
||||
|
||||
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
||||
Object.values(DocumentEntityType),
|
||||
);
|
||||
|
||||
// Excluded on purpose:
|
||||
// - Permission: code-derived, re-synced from the catalog on every boot -
|
||||
// including it would just fight that sync. RolePermission references
|
||||
// permissions by resource+action (not id) instead, resolved against
|
||||
// whatever the target database's own sync already produced.
|
||||
// - RefreshToken: live session tokens, meaningless once restored (expired,
|
||||
// or a stale session that should never be revivable from a backup file).
|
||||
// Excluded on purpose: see MODELS_EXCLUDED_FROM_BACKUP.
|
||||
//
|
||||
// Role/User/RolePermission ARE included (v2) - Role is matched by its `key`
|
||||
// (not id, since a fresh deployment's seeded system roles get a new
|
||||
@@ -84,6 +160,8 @@ function documentsStorageRoot(): string {
|
||||
|
||||
@Injectable()
|
||||
export class BackupService {
|
||||
private readonly logger = new Logger(BackupService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
@@ -98,7 +176,7 @@ export class BackupService {
|
||||
|
||||
/**
|
||||
* Restores an uploaded file. A sealed backup is restored completely; an
|
||||
* old unsealed one only with its business data (see TRUSTED_ONLY_TABLES).
|
||||
* old unsealed one only add-only and limited to LEGACY_UNSEALED_TABLES.
|
||||
*/
|
||||
async importFile(
|
||||
parsed: unknown,
|
||||
@@ -162,15 +240,28 @@ export class BackupService {
|
||||
data.honeyMeasurement = await this.prisma.honeyMeasurement.findMany();
|
||||
data.honeyBatchRelocation =
|
||||
await this.prisma.honeyBatchRelocation.findMany();
|
||||
data.honeyBatchContainerAllocation =
|
||||
await this.prisma.honeyBatchContainerAllocation.findMany();
|
||||
data.stockItem = await this.prisma.stockItem.findMany();
|
||||
data.stockMovement = await this.prisma.stockMovement.findMany();
|
||||
data.product = await this.prisma.product.findMany();
|
||||
data.labelTemplate = await this.prisma.labelTemplate.findMany();
|
||||
data.bottlingBatch = await this.prisma.bottlingBatch.findMany();
|
||||
data.rawMaterialLot = await this.prisma.rawMaterialLot.findMany();
|
||||
data.productionBatch = await this.prisma.productionBatch.findMany();
|
||||
data.productionInput = await this.prisma.productionInput.findMany();
|
||||
data.shopStockRequest = await this.prisma.shopStockRequest.findMany();
|
||||
data.batchMovement = await this.prisma.batchMovement.findMany();
|
||||
data.equipment = await this.prisma.equipment.findMany();
|
||||
data.webhookSubscription = await this.prisma.webhookSubscription.findMany();
|
||||
data.automationRule = await this.prisma.automationRule.findMany();
|
||||
data.automationSequence = await this.prisma.automationSequence.findMany();
|
||||
data.siteSettings = await this.prisma.siteSettings.findMany();
|
||||
data.backupSettings = await this.prisma.backupSettings.findMany();
|
||||
data.calendarSyncSettings =
|
||||
await this.prisma.calendarSyncSettings.findMany();
|
||||
data.calendarSyncOutboxEntry =
|
||||
await this.prisma.calendarSyncOutboxEntry.findMany();
|
||||
|
||||
const documents = await this.prisma.document.findMany();
|
||||
data.document = await Promise.all(
|
||||
@@ -213,21 +304,32 @@ export class BackupService {
|
||||
throw new BadRequestException('Invalid backup file');
|
||||
}
|
||||
|
||||
const isTrustedOnly = (key: string): boolean =>
|
||||
(TRUSTED_ONLY_TABLES as readonly string[]).includes(key);
|
||||
const isAllowedUnsealed = (key: string): boolean =>
|
||||
(LEGACY_UNSEALED_TABLES as readonly string[]).includes(key);
|
||||
const skipped = trusted
|
||||
? []
|
||||
: TRUSTED_ONLY_TABLES.filter((key) => {
|
||||
: Object.keys(bundle.data).filter((key) => {
|
||||
const value = bundle.data[key];
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
return (
|
||||
MODEL_SECTIONS.has(key) &&
|
||||
!isAllowedUnsealed(key) &&
|
||||
Array.isArray(value) &&
|
||||
value.length > 0
|
||||
);
|
||||
});
|
||||
const rowsOf = (key: string): Row[] => {
|
||||
if (!trusted && isTrustedOnly(key)) {
|
||||
if (!trusted && !isAllowedUnsealed(key)) {
|
||||
return [];
|
||||
}
|
||||
const value = bundle.data[key];
|
||||
return Array.isArray(value) ? (value as Row[]) : [];
|
||||
};
|
||||
// An unsealed file may add the rows it carries, never rewrite or roll
|
||||
// back ones that already exist here.
|
||||
const updateWith = (data: Row): Row => (trusted ? data : {});
|
||||
// Document files the restored rows no longer point at - deleted only
|
||||
// once the transaction has committed.
|
||||
const replacedDocumentFiles: string[] = [];
|
||||
|
||||
const imported: Record<string, number> = {};
|
||||
|
||||
@@ -324,11 +426,26 @@ export class BackupService {
|
||||
await tx.apiary.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.apiary = rowsOf('apiary').length;
|
||||
|
||||
// Queens already here before this import keep their mother line when
|
||||
// the file is unsealed (add-only, see updateWith).
|
||||
const preexistingQueenIds = new Set<string>(
|
||||
trusted
|
||||
? []
|
||||
: (
|
||||
await tx.queen.findMany({
|
||||
where: {
|
||||
id: { in: rowsOf('queen').map((row) => row.id as string) },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
).map((queen) => queen.id),
|
||||
);
|
||||
|
||||
// Inserted without motherQueenId first (a daughter queen can appear
|
||||
// before her mother in export order) - patched in a second pass
|
||||
// below once every queen row exists.
|
||||
@@ -337,7 +454,7 @@ export class BackupService {
|
||||
await tx.queen.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: withoutMother as never,
|
||||
update: withoutMother as never,
|
||||
update: updateWith(withoutMother) as never,
|
||||
});
|
||||
}
|
||||
imported.queen = rowsOf('queen').length;
|
||||
@@ -346,13 +463,13 @@ export class BackupService {
|
||||
await tx.colony.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.colony = rowsOf('colony').length;
|
||||
|
||||
for (const row of rowsOf('queen')) {
|
||||
if (row.motherQueenId) {
|
||||
if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) {
|
||||
await tx.queen.update({
|
||||
where: { id: row.id as string },
|
||||
data: { motherQueenId: row.motherQueenId as string },
|
||||
@@ -369,12 +486,12 @@ export class BackupService {
|
||||
asString(row, 'movedByUserId'),
|
||||
),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
movedByUserId: resolveOptionalUserId(
|
||||
asString(row, 'movedByUserId'),
|
||||
),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.colonyApiaryHistory = rowsOf('colonyApiaryHistory').length;
|
||||
@@ -383,7 +500,7 @@ export class BackupService {
|
||||
await tx.colonyLineage.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.colonyLineage = rowsOf('colonyLineage').length;
|
||||
@@ -395,10 +512,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.queenEvaluation = rowsOf('queenEvaluation').length;
|
||||
@@ -407,7 +524,7 @@ export class BackupService {
|
||||
await tx.queenColonyHistory.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
|
||||
@@ -419,10 +536,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.inspection = rowsOf('inspection').length;
|
||||
@@ -439,7 +556,7 @@ export class BackupService {
|
||||
asString(row, 'createdByUserId'),
|
||||
),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
assignedUserId: resolveOptionalUserId(
|
||||
asString(row, 'assignedUserId'),
|
||||
@@ -447,7 +564,7 @@ export class BackupService {
|
||||
createdByUserId: resolveOptionalUserId(
|
||||
asString(row, 'createdByUserId'),
|
||||
),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.taskTemplate = rowsOf('taskTemplate').length;
|
||||
@@ -464,7 +581,7 @@ export class BackupService {
|
||||
asString(row, 'createdByUserId'),
|
||||
),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
assignedUserId: resolveOptionalUserId(
|
||||
asString(row, 'assignedUserId'),
|
||||
@@ -472,7 +589,7 @@ export class BackupService {
|
||||
createdByUserId: resolveOptionalUserId(
|
||||
asString(row, 'createdByUserId'),
|
||||
),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.task = rowsOf('task').length;
|
||||
@@ -484,10 +601,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.varroaMeasurement = rowsOf('varroaMeasurement').length;
|
||||
@@ -499,10 +616,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.swarmControlCheck = rowsOf('swarmControlCheck').length;
|
||||
@@ -514,10 +631,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.winterCheck = rowsOf('winterCheck').length;
|
||||
@@ -529,10 +646,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.treatment = rowsOf('treatment').length;
|
||||
@@ -544,10 +661,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.feeding = rowsOf('feeding').length;
|
||||
@@ -559,10 +676,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.harvest = rowsOf('harvest').length;
|
||||
@@ -571,7 +688,7 @@ export class BackupService {
|
||||
await tx.harvestColony.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.harvestColony = rowsOf('harvestColony').length;
|
||||
@@ -583,10 +700,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.extraction = rowsOf('extraction').length;
|
||||
@@ -595,7 +712,7 @@ export class BackupService {
|
||||
await tx.extractionHarvest.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.extractionHarvest = rowsOf('extractionHarvest').length;
|
||||
@@ -604,7 +721,7 @@ export class BackupService {
|
||||
await tx.storageContainer.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.storageContainer = rowsOf('storageContainer').length;
|
||||
@@ -613,7 +730,7 @@ export class BackupService {
|
||||
await tx.honeyBatch.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.honeyBatch = rowsOf('honeyBatch').length;
|
||||
@@ -625,10 +742,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.honeyMeasurement = rowsOf('honeyMeasurement').length;
|
||||
@@ -640,14 +757,25 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.honeyBatchRelocation = rowsOf('honeyBatchRelocation').length;
|
||||
|
||||
for (const row of rowsOf('honeyBatchContainerAllocation')) {
|
||||
await tx.honeyBatchContainerAllocation.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
}
|
||||
imported.honeyBatchContainerAllocation = rowsOf(
|
||||
'honeyBatchContainerAllocation',
|
||||
).length;
|
||||
|
||||
for (const row of rowsOf('stockItem')) {
|
||||
await tx.stockItem.upsert({
|
||||
where: { id: row.id as string },
|
||||
@@ -676,7 +804,7 @@ export class BackupService {
|
||||
await tx.product.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.product = rowsOf('product').length;
|
||||
@@ -685,13 +813,28 @@ export class BackupService {
|
||||
await tx.labelTemplate.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.labelTemplate = rowsOf('labelTemplate').length;
|
||||
|
||||
for (const row of rowsOf('bottlingBatch')) {
|
||||
await tx.bottlingBatch.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.bottlingBatch = rowsOf('bottlingBatch').length;
|
||||
|
||||
for (const row of rowsOf('rawMaterialLot')) {
|
||||
await tx.rawMaterialLot.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
@@ -703,13 +846,62 @@ export class BackupService {
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
imported.bottlingBatch = rowsOf('bottlingBatch').length;
|
||||
imported.rawMaterialLot = rowsOf('rawMaterialLot').length;
|
||||
|
||||
for (const row of rowsOf('productionBatch')) {
|
||||
await tx.productionBatch.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
imported.productionBatch = rowsOf('productionBatch').length;
|
||||
|
||||
for (const row of rowsOf('productionInput')) {
|
||||
await tx.productionInput.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
}
|
||||
imported.productionInput = rowsOf('productionInput').length;
|
||||
|
||||
for (const row of rowsOf('shopStockRequest')) {
|
||||
await tx.shopStockRequest.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
}
|
||||
imported.shopStockRequest = rowsOf('shopStockRequest').length;
|
||||
|
||||
// After every kind of batch and the shop requests it may reference.
|
||||
for (const row of rowsOf('batchMovement')) {
|
||||
await tx.batchMovement.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
imported.batchMovement = rowsOf('batchMovement').length;
|
||||
|
||||
for (const row of rowsOf('equipment')) {
|
||||
await tx.equipment.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.equipment = rowsOf('equipment').length;
|
||||
@@ -748,6 +940,23 @@ export class BackupService {
|
||||
}
|
||||
imported.automationRule = rowsOf('automationRule').length;
|
||||
|
||||
// Keyed by triggerType+scope, its primary key (the table has no id).
|
||||
// Restoring the counters keeps the next lot number from colliding
|
||||
// with a restored one.
|
||||
for (const row of rowsOf('automationSequence')) {
|
||||
await tx.automationSequence.upsert({
|
||||
where: {
|
||||
triggerType_scope: {
|
||||
triggerType: row.triggerType as never,
|
||||
scope: row.scope as string,
|
||||
},
|
||||
},
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
}
|
||||
imported.automationSequence = rowsOf('automationSequence').length;
|
||||
|
||||
for (const row of rowsOf('siteSettings')) {
|
||||
const { id, ...withoutId } = row;
|
||||
await tx.siteSettings.upsert({
|
||||
@@ -758,6 +967,48 @@ export class BackupService {
|
||||
}
|
||||
imported.siteSettings = rowsOf('siteSettings').length;
|
||||
|
||||
for (const row of rowsOf('backupSettings')) {
|
||||
const { id, ...withoutId } = row;
|
||||
await tx.backupSettings.upsert({
|
||||
where: { id: (id as string) ?? 'singleton' },
|
||||
create: row as never,
|
||||
update: withoutId as never,
|
||||
});
|
||||
}
|
||||
imported.backupSettings = rowsOf('backupSettings').length;
|
||||
|
||||
for (const row of rowsOf('calendarSyncSettings')) {
|
||||
const { id, ...withoutId } = row;
|
||||
await tx.calendarSyncSettings.upsert({
|
||||
where: { id: (id as string) ?? 'singleton' },
|
||||
create: row as never,
|
||||
update: withoutId as never,
|
||||
});
|
||||
}
|
||||
imported.calendarSyncSettings = rowsOf('calendarSyncSettings').length;
|
||||
|
||||
// Keyed by sourceType+sourceId, like CalendarSyncService.enqueue -
|
||||
// the target database may already track the same source under
|
||||
// another id. graphEventId comes along, so a restored entry keeps
|
||||
// updating its existing M365 event instead of creating a duplicate.
|
||||
for (const row of rowsOf('calendarSyncOutboxEntry')) {
|
||||
const withoutId: Row = { ...row };
|
||||
delete withoutId.id;
|
||||
await tx.calendarSyncOutboxEntry.upsert({
|
||||
where: {
|
||||
sourceType_sourceId: {
|
||||
sourceType: row.sourceType as never,
|
||||
sourceId: row.sourceId as string,
|
||||
},
|
||||
},
|
||||
create: row as never,
|
||||
update: withoutId as never,
|
||||
});
|
||||
}
|
||||
imported.calendarSyncOutboxEntry = rowsOf(
|
||||
'calendarSyncOutboxEntry',
|
||||
).length;
|
||||
|
||||
// Writes the file to disk as part of this loop, before the
|
||||
// surrounding transaction is known to commit - if a later entity in
|
||||
// this same import fails and the transaction rolls back, a file
|
||||
@@ -799,6 +1050,13 @@ export class BackupService {
|
||||
`${crypto.randomUUID()}${extension}`,
|
||||
);
|
||||
await fs.writeFile(storagePath, buffer);
|
||||
const previous = await tx.document.findUnique({
|
||||
where: { id: row.id as string },
|
||||
select: { storagePath: true },
|
||||
});
|
||||
if (previous) {
|
||||
replacedDocumentFiles.push(previous.storagePath);
|
||||
}
|
||||
const withoutContent: Row = { ...row };
|
||||
delete withoutContent.contentBase64;
|
||||
const data = {
|
||||
@@ -859,6 +1117,39 @@ export class BackupService {
|
||||
afterData: { imported, skipped, sealed: trusted },
|
||||
});
|
||||
|
||||
return { imported, skipped };
|
||||
await this.removeReplacedDocumentFiles(replacedDocumentFiles);
|
||||
|
||||
return { imported, skipped, sealed: trusted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the files restored document rows no longer point at - only now
|
||||
* that the import has committed, only inside the documents root and only
|
||||
* while no row still uses them. Best effort: a file left behind wastes
|
||||
* space, it never breaks a document.
|
||||
*/
|
||||
private async removeReplacedDocumentFiles(
|
||||
storagePaths: string[],
|
||||
): Promise<void> {
|
||||
const storageRoot = path.resolve(documentsStorageRoot());
|
||||
for (const storagePath of storagePaths) {
|
||||
const resolved = path.resolve(storagePath);
|
||||
if (!resolved.startsWith(storageRoot + path.sep)) {
|
||||
this.logger.warn(
|
||||
`Ersetzte Dokumentdatei liegt außerhalb von ${storageRoot} und bleibt erhalten: ${resolved}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if ((await this.prisma.document.count({ where: { storagePath } })) > 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await fs.unlink(resolved);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(
|
||||
`Ersetzte Dokumentdatei ${resolved} konnte nicht gelöscht werden: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IsString,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
/**
|
||||
* Every field of a bottling batch stays correctable after the fact - a
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
*/
|
||||
export class UpdateBottlingBatchDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
honeyBatchId?: string;
|
||||
|
||||
@@ -35,29 +36,29 @@ export class UpdateBottlingBatchDto {
|
||||
productId?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsDateString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Glasgröße in Gramm' })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
jarSize?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
jarCount?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsDateString()
|
||||
bestBeforeDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
lotNumber?: string;
|
||||
}
|
||||
@@ -115,6 +115,23 @@ describe('CalendarSyncSettingsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// PATCH null rule: null on the nullable groupId clears it, like "".
|
||||
it('clears groupId when given null', async () => {
|
||||
prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.calendarSyncSettings.update.mockResolvedValue({
|
||||
...existing,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
await service.update({ groupId: null }, 'user-1');
|
||||
|
||||
expect(prisma.calendarSyncSettings.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ groupId: null }) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves fields untouched when omitted from the dto', async () => {
|
||||
prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.calendarSyncSettings.update.mockResolvedValue(existing);
|
||||
|
||||
@@ -6,7 +6,8 @@ import { UpdateCalendarSyncSettingsDto } from './dto/update-calendar-sync-settin
|
||||
|
||||
const SINGLETON_ID = 'singleton';
|
||||
|
||||
function emptyToNull(value: string | undefined): string | null {
|
||||
// "" (the GUI emptying the field) and null both clear the stored value.
|
||||
function emptyToNull(value: string | null | undefined): string | null {
|
||||
return value === '' ? null : (value ?? null);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Body, Controller, Get, Patch, Post } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
@@ -37,10 +45,13 @@ export class CalendarSyncController {
|
||||
return this.calendarSyncService.getStatus();
|
||||
}
|
||||
|
||||
// Starts a pass and answers at once (202) - the pass itself can take
|
||||
// minutes. The returned status says running: true; GET status follows it.
|
||||
@RequirePermission('calendar:configure')
|
||||
@Post('run-now')
|
||||
async runNow() {
|
||||
await this.calendarSyncService.runNow();
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
runNow() {
|
||||
this.calendarSyncService.startRun();
|
||||
return this.calendarSyncService.getStatus();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { CalendarSyncService } from './calendar-sync.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
|
||||
import { GraphClientService } from './graph-client.service';
|
||||
import { GraphClientService, GraphRequestError } from './graph-client.service';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
|
||||
describe('CalendarSyncService', () => {
|
||||
let service: CalendarSyncService;
|
||||
@@ -10,6 +12,7 @@ describe('CalendarSyncService', () => {
|
||||
upsert: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
updateMany: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
count: jest.Mock;
|
||||
};
|
||||
@@ -41,13 +44,47 @@ describe('CalendarSyncService', () => {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const pendingEntry = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
status: 'PENDING',
|
||||
graphEventId: null,
|
||||
attempts: 0,
|
||||
revision: 3,
|
||||
claimedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Derived from the outbox entry, shaped like a GUID. */
|
||||
const TRANSACTION_ID = expect.stringMatching(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
) as string;
|
||||
|
||||
/** Free, or held by a worker that gave up long ago (cut-off time). */
|
||||
const CLAIMABLE = {
|
||||
OR: [{ claimedAt: null }, { claimedAt: { lt: expect.any(Date) as Date } }],
|
||||
};
|
||||
|
||||
type UpdateManyArgs = {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
const updateManyCalls = () =>
|
||||
prisma.calendarSyncOutboxEntry.updateMany.mock.calls.map(
|
||||
([args]) => args as UpdateManyArgs,
|
||||
);
|
||||
/** The claim taken by this pass - every later write is conditional on it. */
|
||||
const claimedAt = () => updateManyCalls()[0].data.claimedAt as Date;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
calendarSyncOutboxEntry: {
|
||||
upsert: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
task: { findUnique: jest.fn() },
|
||||
@@ -94,7 +131,7 @@ describe('CalendarSyncService', () => {
|
||||
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upserts a PENDING row, resetting attempts and lastError', async () => {
|
||||
it('upserts a PENDING row, resetting attempts and lastError and counting the revision up', async () => {
|
||||
await service.enqueue('TASK', 'task-1');
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalledWith({
|
||||
@@ -102,11 +139,58 @@ describe('CalendarSyncService', () => {
|
||||
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
|
||||
},
|
||||
create: { sourceType: 'TASK', sourceId: 'task-1' },
|
||||
update: { status: 'PENDING', attempts: 0, lastError: null },
|
||||
update: {
|
||||
status: 'PENDING',
|
||||
attempts: 0,
|
||||
lastError: null,
|
||||
revision: { increment: 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// enqueue()/cancel() run after the source record is committed: a failure
|
||||
// must not fail that request (the client would retry and store it twice).
|
||||
describe('post-commit calls', () => {
|
||||
let errorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('enqueue never throws and logs the failure with the record', async () => {
|
||||
settingsService.get.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
service.enqueue('INSPECTION', 'inspection-1'),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain('INSPECTION inspection-1');
|
||||
expect(message).toContain('db down');
|
||||
});
|
||||
|
||||
it('cancel never throws and logs the failure with the record', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findUnique.mockRejectedValue(
|
||||
new Error('db down'),
|
||||
);
|
||||
|
||||
await expect(service.cancel('TASK', 'task-1')).resolves.toBeUndefined();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain('TASK task-1');
|
||||
expect(message).toContain('db down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('does nothing when no outbox row exists', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null);
|
||||
@@ -151,6 +235,25 @@ describe('CalendarSyncService', () => {
|
||||
});
|
||||
|
||||
describe('processOutbox', () => {
|
||||
const dueTask = {
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: new Date('2026-03-07'),
|
||||
};
|
||||
|
||||
/** The outcome write: only lands if the row is unchanged since the claim. */
|
||||
const expectSettled = (data: Record<string, unknown>) => {
|
||||
expect(updateManyCalls()[1]).toEqual({
|
||||
where: {
|
||||
id: 'entry-1',
|
||||
status: 'PENDING',
|
||||
revision: 3,
|
||||
claimedAt: claimedAt(),
|
||||
},
|
||||
data: { ...data, claimedAt: null },
|
||||
});
|
||||
};
|
||||
|
||||
it('does nothing when sync is disabled, unconfigured, or no groupId', async () => {
|
||||
settingsService.get.mockResolvedValue({
|
||||
...enabledSettings,
|
||||
@@ -160,76 +263,170 @@ describe('CalendarSyncService', () => {
|
||||
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a Graph event for a new TASK entry and marks it SYNCED', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: null,
|
||||
attempts: 0,
|
||||
},
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: new Date('2026-03-07'),
|
||||
it('only picks PENDING rows that nobody holds, or whose holder gave up long ago', async () => {
|
||||
await service.processOutbox();
|
||||
const after = Date.now();
|
||||
|
||||
const [[query]] = prisma.calendarSyncOutboxEntry.findMany.mock.calls as [
|
||||
[{ where: Record<string, unknown> }],
|
||||
];
|
||||
expect(query.where).toEqual({
|
||||
status: 'PENDING',
|
||||
...CLAIMABLE,
|
||||
});
|
||||
const staleBefore = (
|
||||
query.where.OR as [unknown, { claimedAt: { lt: Date } }]
|
||||
)[1].claimedAt.lt;
|
||||
// A claim counts as abandoned only after several minutes - far longer
|
||||
// than one entry can take with bounded Graph requests.
|
||||
expect(after - staleBefore.getTime()).toBeGreaterThanOrEqual(
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it('claims a row with a conditional update before working on it', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).toHaveBeenCalledWith('group-1', {
|
||||
title: 'Beute prüfen',
|
||||
date: new Date('2026-03-07'),
|
||||
});
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: {
|
||||
status: 'SYNCED',
|
||||
graphEventId: 'graph-event-1',
|
||||
lastError: null,
|
||||
expect(updateManyCalls()[0]).toEqual({
|
||||
where: {
|
||||
id: 'entry-1',
|
||||
status: 'PENDING',
|
||||
revision: 3,
|
||||
...CLAIMABLE,
|
||||
},
|
||||
data: { claimedAt: expect.any(Date) as Date },
|
||||
});
|
||||
const [claimOrder] =
|
||||
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
|
||||
const [createOrder] = graphClient.createEvent.mock.invocationCallOrder;
|
||||
expect(claimOrder).toBeLessThan(createOrder);
|
||||
});
|
||||
|
||||
it('marks the entry SKIPPED when the Task no longer has a dueDate', async () => {
|
||||
it('leaves a row alone when another pod or run claimed it first', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: null,
|
||||
attempts: 0,
|
||||
},
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: null,
|
||||
prisma.calendarSyncOutboxEntry.updateMany.mockResolvedValueOnce({
|
||||
count: 0,
|
||||
});
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(prisma.task.findUnique).not.toHaveBeenCalled();
|
||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'SKIPPED' },
|
||||
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a Graph event for a new TASK entry and marks it SYNCED', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).toHaveBeenCalledWith(
|
||||
'group-1',
|
||||
{ title: 'Beute prüfen', date: new Date('2026-03-07') },
|
||||
TRANSACTION_ID,
|
||||
);
|
||||
expectSettled({
|
||||
status: 'SYNCED',
|
||||
graphEventId: 'graph-event-1',
|
||||
lastError: null,
|
||||
});
|
||||
expect(prisma.calendarSyncOutboxEntry.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the new event id but stays PENDING when the source changed during the sync', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
||||
prisma.calendarSyncOutboxEntry.updateMany
|
||||
.mockResolvedValueOnce({ count: 1 }) // claim
|
||||
.mockResolvedValueOnce({ count: 0 }); // re-enqueued meanwhile
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
// Not SYNCED: the newer state still has to reach M365, and the next
|
||||
// pass must update this event rather than create a second one.
|
||||
expect(updateManyCalls()[2]).toEqual({
|
||||
where: { id: 'entry-1', status: 'PENDING', claimedAt: claimedAt() },
|
||||
data: { graphEventId: 'graph-event-1', claimedAt: null },
|
||||
});
|
||||
expect(graphClient.deleteEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the event again when the source left the calendar during the sync', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
||||
prisma.calendarSyncOutboxEntry.updateMany
|
||||
.mockResolvedValueOnce({ count: 1 }) // claim
|
||||
.mockResolvedValueOnce({ count: 0 }) // not unchanged
|
||||
.mockResolvedValueOnce({ count: 0 }) // not PENDING: cancelled
|
||||
.mockResolvedValueOnce({ count: 1 }); // claim released
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(updateManyCalls()[3]).toEqual({
|
||||
where: { id: 'entry-1', claimedAt: claimedAt() },
|
||||
data: { claimedAt: null },
|
||||
});
|
||||
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
|
||||
'group-1',
|
||||
'graph-event-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the entry SKIPPED when the Task no longer has a dueDate', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({ ...dueTask, dueDate: null });
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||
expectSettled({ status: 'SKIPPED' });
|
||||
});
|
||||
|
||||
it('only lets go of the claim when the row changed before SKIPPED could be written', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({ ...dueTask, dueDate: null });
|
||||
prisma.calendarSyncOutboxEntry.updateMany
|
||||
.mockResolvedValueOnce({ count: 1 })
|
||||
.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(updateManyCalls()[2]).toEqual({
|
||||
where: { id: 'entry-1', claimedAt: claimedAt() },
|
||||
data: { claimedAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates an existing Graph event when graphEventId is already set', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: 'graph-event-1',
|
||||
attempts: 0,
|
||||
},
|
||||
pendingEntry({ graphEventId: 'graph-event-1' }),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
...dueTask,
|
||||
title: 'Beute prüfen (geändert)',
|
||||
dueDate: new Date('2026-03-10'),
|
||||
});
|
||||
@@ -243,96 +440,108 @@ describe('CalendarSyncService', () => {
|
||||
{ title: 'Beute prüfen (geändert)', date: new Date('2026-03-10') },
|
||||
);
|
||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: {
|
||||
status: 'SYNCED',
|
||||
graphEventId: 'graph-event-1',
|
||||
lastError: null,
|
||||
},
|
||||
expectSettled({
|
||||
status: 'SYNCED',
|
||||
graphEventId: 'graph-event-1',
|
||||
lastError: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to creating a fresh event when updating a stale graphEventId 404s', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: 'deleted-on-m365',
|
||||
attempts: 0,
|
||||
},
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: new Date('2026-03-07'),
|
||||
});
|
||||
graphClient.updateEvent.mockRejectedValue(new Error('404 Not Found'));
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-new');
|
||||
it.each([404, 410])(
|
||||
'falls back to creating a fresh event when the stored event is gone (%i)',
|
||||
async (status) => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry({ graphEventId: 'deleted-on-m365' }),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||
graphClient.updateEvent.mockRejectedValue(
|
||||
new GraphRequestError('updateEvent', status, 'gone'),
|
||||
);
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-new');
|
||||
|
||||
await service.processOutbox();
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).toHaveBeenCalled();
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: {
|
||||
expect(graphClient.createEvent).toHaveBeenCalled();
|
||||
expectSettled({
|
||||
status: 'SYNCED',
|
||||
graphEventId: 'graph-event-new',
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['a Graph server error', new GraphRequestError('updateEvent', 503, '')],
|
||||
['a throttled request', new GraphRequestError('updateEvent', 429, '')],
|
||||
['a network failure', new Error('fetch failed')],
|
||||
])(
|
||||
'does not create a second event after %s, but retries the update later',
|
||||
async (_case, failure) => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry({ graphEventId: 'graph-event-1', attempts: 1 }),
|
||||
]);
|
||||
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||
graphClient.updateEvent.mockRejectedValue(failure);
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||
expectSettled({
|
||||
status: 'PENDING',
|
||||
attempts: 2,
|
||||
lastError: failure.message,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('retries (stays PENDING) on failure below the max attempt count', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: null,
|
||||
attempts: 1,
|
||||
},
|
||||
pendingEntry({ attempts: 1 }),
|
||||
]);
|
||||
prisma.task.findUnique.mockRejectedValue(new Error('db error'));
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'PENDING', attempts: 2, lastError: 'db error' },
|
||||
expectSettled({ status: 'PENDING', attempts: 2, lastError: 'db error' });
|
||||
});
|
||||
|
||||
// lastError is stored and shown to admins - a Prisma message would carry
|
||||
// query values (titles, names) into it.
|
||||
it('stores a Prisma failure in lastError by name, code and model only', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry({ attempts: 1 }),
|
||||
]);
|
||||
prisma.task.findUnique.mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
|
||||
code: 'P2024',
|
||||
clientVersion: '7.9.1',
|
||||
meta: { modelName: 'Task' },
|
||||
}),
|
||||
);
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expectSettled({
|
||||
status: 'PENDING',
|
||||
attempts: 2,
|
||||
lastError: 'PrismaClientKnownRequestError P2024 (Task)',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks FAILED once the max attempt count is reached', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'entry-1',
|
||||
sourceType: 'TASK',
|
||||
sourceId: 'task-1',
|
||||
graphEventId: null,
|
||||
attempts: 4,
|
||||
},
|
||||
pendingEntry({ attempts: 4 }),
|
||||
]);
|
||||
prisma.task.findUnique.mockRejectedValue(new Error('db error'));
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'FAILED', attempts: 5, lastError: 'db error' },
|
||||
});
|
||||
expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' });
|
||||
});
|
||||
|
||||
it('builds titles per source type matching the read-only calendar view', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'e1',
|
||||
sourceType: 'INSPECTION',
|
||||
sourceId: 'i1',
|
||||
graphEventId: null,
|
||||
attempts: 0,
|
||||
},
|
||||
pendingEntry({ sourceType: 'INSPECTION', sourceId: 'i1' }),
|
||||
]);
|
||||
prisma.inspection.findUnique.mockResolvedValue({
|
||||
id: 'i1',
|
||||
@@ -343,10 +552,224 @@ describe('CalendarSyncService', () => {
|
||||
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).toHaveBeenCalledWith('group-1', {
|
||||
title: 'Durchsicht V-001',
|
||||
date: new Date('2026-03-07'),
|
||||
expect(graphClient.createEvent).toHaveBeenCalledWith(
|
||||
'group-1',
|
||||
{ title: 'Durchsicht V-001', date: new Date('2026-03-07') },
|
||||
TRANSACTION_ID,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A create that timed out may still have reached Graph. The retry - by
|
||||
// this or another pod - sends the same transactionId, so Graph can drop
|
||||
// it instead of adding a second event.
|
||||
describe('transactionId on create', () => {
|
||||
/** Runs one pass over the entry and returns the transactionId sent. */
|
||||
const transactionIdFor = async (entry: Record<string, unknown>) => {
|
||||
graphClient.createEvent.mockClear();
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry(entry),
|
||||
]);
|
||||
await service.processOutbox();
|
||||
const [[, , transactionId]] = graphClient.createEvent.mock.calls as [
|
||||
[string, unknown, string],
|
||||
];
|
||||
return transactionId;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.task.findUnique.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
title: 'Beute prüfen',
|
||||
dueDate: new Date('2026-03-07'),
|
||||
});
|
||||
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
||||
});
|
||||
|
||||
it('is the same for every retry of the same create', async () => {
|
||||
graphClient.createEvent.mockRejectedValueOnce(new Error('timeout'));
|
||||
const first = await transactionIdFor({ attempts: 0 });
|
||||
const retry = await transactionIdFor({ attempts: 1 });
|
||||
|
||||
expect(first).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(retry).toBe(first);
|
||||
});
|
||||
|
||||
it('differs per outbox entry', async () => {
|
||||
const one = await transactionIdFor({ id: 'entry-1' });
|
||||
const other = await transactionIdFor({ id: 'entry-2' });
|
||||
|
||||
expect(other).not.toBe(one);
|
||||
});
|
||||
|
||||
// A cancelled source deleted its event; enqueued again it needs a new
|
||||
// one, which an old transactionId might be matched against.
|
||||
it('is new once the entry was enqueued again', async () => {
|
||||
const before = await transactionIdFor({ revision: 3 });
|
||||
const after = await transactionIdFor({ revision: 4 });
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it('is new when replacing an event that was deleted in M365', async () => {
|
||||
const initial = await transactionIdFor({ graphEventId: null });
|
||||
graphClient.updateEvent.mockRejectedValue(
|
||||
new GraphRequestError('updateEvent', 404, 'gone'),
|
||||
);
|
||||
const replacement = await transactionIdFor({
|
||||
graphEventId: 'deleted-on-m365',
|
||||
});
|
||||
|
||||
expect(replacement).not.toBe(initial);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runNow', () => {
|
||||
it('gives FAILED entries a fresh set of attempts before processing', async () => {
|
||||
await service.runNow();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
|
||||
where: { status: 'FAILED' },
|
||||
data: { status: 'PENDING', attempts: 0, lastError: null },
|
||||
});
|
||||
const [resetOrder] =
|
||||
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
|
||||
const [readOrder] =
|
||||
prisma.calendarSyncOutboxEntry.findMany.mock.invocationCallOrder;
|
||||
expect(resetOrder).toBeLessThan(readOrder);
|
||||
});
|
||||
|
||||
it('leaves FAILED entries alone on a regular cron tick', async () => {
|
||||
await service.processOutbox();
|
||||
|
||||
expect(
|
||||
prisma.calendarSyncOutboxEntry.updateMany,
|
||||
).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { status: 'FAILED' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// "Jetzt synchronisieren" must not hold the HTTP request for a whole pass
|
||||
// (up to 20 entries with several Graph calls each): it only starts it.
|
||||
describe('startRun', () => {
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
/** Lets the pass hang on reading the outbox until released. */
|
||||
const holdOutbox = () => {
|
||||
let release: () => void = () => undefined;
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
release = () => resolve([]);
|
||||
}),
|
||||
);
|
||||
return () => release();
|
||||
};
|
||||
|
||||
it('returns while the pass is still running and reports it as running', async () => {
|
||||
const release = holdOutbox();
|
||||
|
||||
service.startRun();
|
||||
await flush();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
|
||||
where: { status: 'FAILED' },
|
||||
data: { status: 'PENDING', attempts: 0, lastError: null },
|
||||
});
|
||||
expect((await service.getStatus()).running).toBe(true);
|
||||
|
||||
release();
|
||||
await flush();
|
||||
|
||||
expect((await service.getStatus()).running).toBe(false);
|
||||
});
|
||||
|
||||
it('starts only one pass per pod at a time', async () => {
|
||||
const release = holdOutbox();
|
||||
|
||||
service.startRun();
|
||||
service.startRun();
|
||||
await flush();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
|
||||
release();
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('logs a failed pass without query values and clears the indicator', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
|
||||
code: 'P2010',
|
||||
clientVersion: '7.9.1',
|
||||
meta: { modelName: 'CalendarSyncOutboxEntry' },
|
||||
}),
|
||||
);
|
||||
|
||||
service.startRun();
|
||||
await flush();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
|
||||
expect(message).toContain('P2010');
|
||||
expect(message).not.toContain('Mustermann');
|
||||
expect(stack).toBeUndefined();
|
||||
expect((await service.getStatus()).running).toBe(false);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('lets shutdown wait for a running pass', async () => {
|
||||
const release = holdOutbox();
|
||||
service.startRun();
|
||||
let destroyed = false;
|
||||
|
||||
const shutdown = service.onModuleDestroy().then(() => {
|
||||
destroyed = true;
|
||||
});
|
||||
await flush();
|
||||
expect(destroyed).toBe(false);
|
||||
|
||||
release();
|
||||
await shutdown;
|
||||
expect(destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The cron tick's own errors would otherwise reach the scheduler's logger
|
||||
// with the full Prisma message and stack.
|
||||
describe('scheduledRun (cron tick)', () => {
|
||||
it('runs the regular pass', async () => {
|
||||
await service.scheduledRun();
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never throws and logs a failed tick without the Prisma message', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
|
||||
code: 'P2010',
|
||||
clientVersion: '7.9.1',
|
||||
meta: { modelName: 'CalendarSyncOutboxEntry' },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.scheduledRun()).resolves.toBeUndefined();
|
||||
|
||||
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
|
||||
expect(message).toContain('P2010');
|
||||
expect(message).not.toContain('Mustermann');
|
||||
expect(stack).toBeUndefined();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -355,7 +778,8 @@ describe('CalendarSyncService', () => {
|
||||
prisma.calendarSyncOutboxEntry.count
|
||||
.mockResolvedValueOnce(2) // PENDING
|
||||
.mockResolvedValueOnce(1) // FAILED
|
||||
.mockResolvedValueOnce(5); // SYNCED
|
||||
.mockResolvedValueOnce(5) // SYNCED
|
||||
.mockResolvedValueOnce(0); // held by a worker right now
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
@@ -366,6 +790,24 @@ describe('CalendarSyncService', () => {
|
||||
pendingCount: 2,
|
||||
failedCount: 1,
|
||||
syncedCount: 5,
|
||||
running: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Another pod (its cron tick or its "Jetzt synchronisieren") may be the
|
||||
// one working - its live claims show that across replicas.
|
||||
it('reports running while any worker holds an entry', async () => {
|
||||
prisma.calendarSyncOutboxEntry.count
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(1);
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.running).toBe(true);
|
||||
expect(prisma.calendarSyncOutboxEntry.count).toHaveBeenLastCalledWith({
|
||||
where: { claimedAt: { gte: expect.any(Date) as Date } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { runAfterCommit } from '../common/after-commit';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
|
||||
import {
|
||||
GraphClientService,
|
||||
GraphCalendarEventInput,
|
||||
GraphRequestError,
|
||||
} from './graph-client.service';
|
||||
import {
|
||||
CalendarSyncOutboxEntry,
|
||||
CalendarSyncSettings,
|
||||
CalendarSyncSourceType,
|
||||
Prisma,
|
||||
} from '../../generated/prisma';
|
||||
|
||||
// Bounded per tick so a large backlog can't turn one cron run into a
|
||||
@@ -19,6 +24,47 @@ const BATCH_SIZE = 20;
|
||||
// (status becomes FAILED) - "Jetzt synchronisieren" or a fresh enqueue
|
||||
// (e.g. editing the source record) resets it back to PENDING.
|
||||
const MAX_ATTEMPTS = 5;
|
||||
// A claim this old belongs to a worker that died mid-entry (pod restart);
|
||||
// the entry is taken over. Far above the longest a live worker can hold one
|
||||
// entry (three Graph requests, each bounded by GRAPH_REQUEST_TIMEOUT_MS).
|
||||
const STALE_CLAIM_MS = 5 * 60 * 1000;
|
||||
// Graph answers these when the stored event no longer exists (deleted on
|
||||
// the M365 side) - only then is a fresh event the right fix.
|
||||
const EVENT_GONE_STATUSES = [404, 410];
|
||||
|
||||
/** An outbox row this worker holds; every write back is conditional on it. */
|
||||
type ClaimedEntry = CalendarSyncOutboxEntry & { claimedAt: Date };
|
||||
|
||||
/**
|
||||
* Graph's transactionId for creating this entry's event, GUID-shaped.
|
||||
* The same for every retry of one create - after a timeout, or by another
|
||||
* pod after a stale claim - so Graph can drop the repeat. A new one for
|
||||
* every deliberate create: per entry, per enqueue (revision) and per
|
||||
* replaced event, so a fresh create is never matched against an event that
|
||||
* was cancelled or deleted in M365 before.
|
||||
*/
|
||||
function eventTransactionId(entry: CalendarSyncOutboxEntry): string {
|
||||
const hex = createHash('sha256')
|
||||
.update(`${entry.id}:${entry.revision}:${entry.graphEventId ?? ''}`)
|
||||
.digest('hex');
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20, 32),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
/** Free, or held by a worker that gave up long ago. */
|
||||
function claimableNow(): Prisma.CalendarSyncOutboxEntryWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{ claimedAt: null },
|
||||
{ claimedAt: { lt: new Date(Date.now() - STALE_CLAIM_MS) } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export interface CalendarSyncStatusReport {
|
||||
configured: boolean;
|
||||
@@ -27,6 +73,8 @@ export interface CalendarSyncStatusReport {
|
||||
pendingCount: number;
|
||||
failedCount: number;
|
||||
syncedCount: number;
|
||||
/** A pass is under way - on this pod, or any pod holding an entry. */
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
@@ -34,8 +82,10 @@ function getErrorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CalendarSyncService {
|
||||
export class CalendarSyncService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(CalendarSyncService.name);
|
||||
// The pass started by "Jetzt synchronisieren" on this pod, if one runs.
|
||||
private activeRun: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -58,27 +108,56 @@ export class CalendarSyncService {
|
||||
return flagBySourceType[sourceType];
|
||||
}
|
||||
|
||||
// enqueue()/cancel() follow a committed write in the domain services and
|
||||
// are called without a guard - so they never throw (see runAfterCommit).
|
||||
private afterCommit(
|
||||
action: string,
|
||||
sourceType: CalendarSyncSourceType,
|
||||
sourceId: string,
|
||||
work: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
return runAfterCommit(
|
||||
this.logger,
|
||||
{ action, entityType: sourceType, entityId: sourceId },
|
||||
work,
|
||||
);
|
||||
}
|
||||
|
||||
// Called from the ~6 domain services right after a create/update that
|
||||
// should appear on the M365 calendar. Upserts rather than always
|
||||
// inserting: re-enqueuing an already-synced row (e.g. a Task's due date
|
||||
// changed) resets it to PENDING while keeping its graphEventId, so the
|
||||
// worker updates the existing Graph event instead of creating a duplicate.
|
||||
// The revision counts up so a worker syncing the row right now notices the
|
||||
// newer state instead of marking it SYNCED.
|
||||
async enqueue(
|
||||
sourceType: CalendarSyncSourceType,
|
||||
sourceId: string,
|
||||
): Promise<void> {
|
||||
const settings = await this.settingsService.get();
|
||||
if (!settings.enabled) {
|
||||
return;
|
||||
}
|
||||
if (!this.isSourceTypeEnabled(settings, sourceType)) {
|
||||
return;
|
||||
}
|
||||
await this.prisma.calendarSyncOutboxEntry.upsert({
|
||||
where: { sourceType_sourceId: { sourceType, sourceId } },
|
||||
create: { sourceType, sourceId },
|
||||
update: { status: 'PENDING', attempts: 0, lastError: null },
|
||||
});
|
||||
await this.afterCommit(
|
||||
'Calendar sync enqueue',
|
||||
sourceType,
|
||||
sourceId,
|
||||
async () => {
|
||||
const settings = await this.settingsService.get();
|
||||
if (!settings.enabled) {
|
||||
return;
|
||||
}
|
||||
if (!this.isSourceTypeEnabled(settings, sourceType)) {
|
||||
return;
|
||||
}
|
||||
await this.prisma.calendarSyncOutboxEntry.upsert({
|
||||
where: { sourceType_sourceId: { sourceType, sourceId } },
|
||||
create: { sourceType, sourceId },
|
||||
update: {
|
||||
status: 'PENDING',
|
||||
attempts: 0,
|
||||
lastError: null,
|
||||
revision: { increment: 1 },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Called when a source record no longer belongs on the calendar (e.g. a
|
||||
@@ -89,38 +168,60 @@ export class CalendarSyncService {
|
||||
sourceType: CalendarSyncSourceType,
|
||||
sourceId: string,
|
||||
): Promise<void> {
|
||||
const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({
|
||||
where: { sourceType_sourceId: { sourceType, sourceId } },
|
||||
});
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
if (entry.graphEventId) {
|
||||
const settings = await this.settingsService.get();
|
||||
if (
|
||||
settings.enabled &&
|
||||
settings.groupId &&
|
||||
this.graphClient.isConfigured()
|
||||
) {
|
||||
try {
|
||||
await this.graphClient.deleteEvent(
|
||||
settings.groupId,
|
||||
entry.graphEventId,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(
|
||||
`Could not delete Graph event ${entry.graphEventId}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
await this.afterCommit(
|
||||
'Calendar sync cancel',
|
||||
sourceType,
|
||||
sourceId,
|
||||
async () => {
|
||||
const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({
|
||||
where: { sourceType_sourceId: { sourceType, sourceId } },
|
||||
});
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.prisma.calendarSyncOutboxEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'SKIPPED', graphEventId: null },
|
||||
});
|
||||
if (entry.graphEventId) {
|
||||
await this.deleteGraphEvent(entry.graphEventId);
|
||||
}
|
||||
await this.prisma.calendarSyncOutboxEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'SKIPPED', graphEventId: null },
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Best-effort removal from M365: logged, never thrown.
|
||||
private async deleteGraphEvent(graphEventId: string): Promise<void> {
|
||||
const settings = await this.settingsService.get();
|
||||
if (
|
||||
!settings.enabled ||
|
||||
!settings.groupId ||
|
||||
!this.graphClient.isConfigured()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.graphClient.deleteEvent(settings.groupId, graphEventId);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(
|
||||
`Could not delete Graph event ${graphEventId}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The cron tick. An error is logged here, Prisma errors by name/code/model
|
||||
// only (see loggableError) - left uncaught, the scheduler would log the
|
||||
// full Prisma message and stack.
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async scheduledRun(): Promise<void> {
|
||||
try {
|
||||
await this.processOutbox();
|
||||
} catch (error: unknown) {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(`Calendar sync tick failed: ${message}`, stack);
|
||||
}
|
||||
}
|
||||
|
||||
async processOutbox(): Promise<void> {
|
||||
const settings = await this.settingsService.get();
|
||||
if (
|
||||
@@ -131,22 +232,80 @@ export class CalendarSyncService {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await this.prisma.calendarSyncOutboxEntry.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
// Several workers may run at once - one cron tick per pod, plus "Jetzt
|
||||
// synchronisieren". Each entry is claimed right before it is written,
|
||||
// so exactly one of them talks to Graph about it.
|
||||
const candidates = await this.prisma.calendarSyncOutboxEntry.findMany({
|
||||
where: { status: 'PENDING', ...claimableNow() },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: BATCH_SIZE,
|
||||
});
|
||||
for (const entry of entries) {
|
||||
await this.processEntry(entry, settings.groupId);
|
||||
for (const candidate of candidates) {
|
||||
const claimed = await this.claim(candidate);
|
||||
if (claimed) {
|
||||
await this.processEntry(claimed, settings.groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Jetzt synchronisieren" also gives FAILED entries a fresh set of
|
||||
// attempts - besides editing the source record the documented way out of
|
||||
// FAILED. A FAILED row is never claimed (settle() clears the claim with
|
||||
// it), so this cannot pull a row from under a running worker. The regular
|
||||
// cron tick leaves FAILED alone.
|
||||
async runNow(): Promise<void> {
|
||||
await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||
where: { status: 'FAILED' },
|
||||
data: { status: 'PENDING', attempts: 0, lastError: null },
|
||||
});
|
||||
await this.processOutbox();
|
||||
}
|
||||
|
||||
private async processEntry(
|
||||
// What the API's "Jetzt synchronisieren" calls: starts runNow() in the
|
||||
// background and returns at once - a pass over up to BATCH_SIZE entries
|
||||
// with several Graph calls each must not hold the HTTP request. One such
|
||||
// pass per pod at a time; passes on other pods (and the cron tick) stay
|
||||
// safe through the per-entry claims. getStatus().running follows it.
|
||||
startRun(): void {
|
||||
if (!this.activeRun) {
|
||||
this.activeRun = this.runNow()
|
||||
.catch((error: unknown) => {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(`Calendar sync run failed: ${message}`, stack);
|
||||
})
|
||||
.finally(() => {
|
||||
this.activeRun = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A pass cut off mid-entry would only be retaken after STALE_CLAIM_MS -
|
||||
// shutdown lets it finish (each Graph request is bounded by its timeout).
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.activeRun;
|
||||
}
|
||||
|
||||
// Compare-and-set on the row as it was read: the update only matches while
|
||||
// the row is still PENDING, unchanged and unclaimed, so of two workers
|
||||
// reading the same candidate exactly one gets it (the other gets count 0).
|
||||
private async claim(
|
||||
entry: CalendarSyncOutboxEntry,
|
||||
): Promise<ClaimedEntry | null> {
|
||||
const claimedAt = new Date();
|
||||
const claim = await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||
where: {
|
||||
id: entry.id,
|
||||
status: 'PENDING',
|
||||
revision: entry.revision,
|
||||
...claimableNow(),
|
||||
},
|
||||
data: { claimedAt },
|
||||
});
|
||||
return claim.count === 1 ? { ...entry, claimedAt } : null;
|
||||
}
|
||||
|
||||
private async processEntry(
|
||||
entry: ClaimedEntry,
|
||||
groupId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -155,47 +314,132 @@ export class CalendarSyncService {
|
||||
entry.sourceId,
|
||||
);
|
||||
if (!input) {
|
||||
await this.prisma.calendarSyncOutboxEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'SKIPPED' },
|
||||
});
|
||||
await this.settle(entry, { status: 'SKIPPED' });
|
||||
return;
|
||||
}
|
||||
|
||||
const graphEventId = entry.graphEventId
|
||||
? await this.updateExisting(groupId, entry.graphEventId, input)
|
||||
: await this.graphClient.createEvent(groupId, input);
|
||||
? await this.updateExisting(groupId, entry, entry.graphEventId, input)
|
||||
: await this.graphClient.createEvent(
|
||||
groupId,
|
||||
input,
|
||||
eventTransactionId(entry),
|
||||
);
|
||||
|
||||
await this.prisma.calendarSyncOutboxEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'SYNCED', graphEventId, lastError: null },
|
||||
});
|
||||
await this.recordSynced(entry, graphEventId);
|
||||
} catch (error: unknown) {
|
||||
const attempts = entry.attempts + 1;
|
||||
await this.prisma.calendarSyncOutboxEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
status: attempts >= MAX_ATTEMPTS ? 'FAILED' : 'PENDING',
|
||||
attempts,
|
||||
lastError: getErrorMessage(error),
|
||||
},
|
||||
await this.settle(entry, {
|
||||
status: attempts >= MAX_ATTEMPTS ? 'FAILED' : 'PENDING',
|
||||
attempts,
|
||||
// Stored and shown to admins: Prisma errors by name/code/model only.
|
||||
lastError: loggableError(error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The row exactly as claimed: still ours, PENDING, nothing enqueued since. */
|
||||
private unchangedSinceClaim(
|
||||
entry: ClaimedEntry,
|
||||
): Prisma.CalendarSyncOutboxEntryWhereInput {
|
||||
return {
|
||||
id: entry.id,
|
||||
status: 'PENDING',
|
||||
revision: entry.revision,
|
||||
claimedAt: entry.claimedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Writes this pass's outcome only if the row is still as claimed. If it
|
||||
// was enqueued or cancelled meanwhile, the outcome is stale: the claim is
|
||||
// just released, and the next pass works from the row's current state.
|
||||
private async settle(
|
||||
entry: ClaimedEntry,
|
||||
outcome: Prisma.CalendarSyncOutboxEntryUpdateManyMutationInput,
|
||||
): Promise<void> {
|
||||
const settled = await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||
where: this.unchangedSinceClaim(entry),
|
||||
data: { ...outcome, claimedAt: null },
|
||||
});
|
||||
if (settled.count === 0) {
|
||||
await this.release(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private async release(entry: ClaimedEntry): Promise<number> {
|
||||
const released = await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||
where: { id: entry.id, claimedAt: entry.claimedAt },
|
||||
data: { claimedAt: null },
|
||||
});
|
||||
return released.count;
|
||||
}
|
||||
|
||||
// The event now exists in M365. Unless the row changed meanwhile it is
|
||||
// SYNCED. Otherwise the event id must still be kept - a lost id means a
|
||||
// duplicate event on the next pass.
|
||||
private async recordSynced(
|
||||
entry: ClaimedEntry,
|
||||
graphEventId: string,
|
||||
): Promise<void> {
|
||||
const synced = await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||
where: this.unchangedSinceClaim(entry),
|
||||
data: {
|
||||
status: 'SYNCED',
|
||||
graphEventId,
|
||||
lastError: null,
|
||||
claimedAt: null,
|
||||
},
|
||||
});
|
||||
if (synced.count === 1) {
|
||||
return;
|
||||
}
|
||||
// Re-enqueued during the sync (the source was edited): stays PENDING,
|
||||
// and the next pass updates this event with the newer state.
|
||||
const kept = await this.prisma.calendarSyncOutboxEntry.updateMany({
|
||||
where: { id: entry.id, status: 'PENDING', claimedAt: entry.claimedAt },
|
||||
data: { graphEventId, claimedAt: null },
|
||||
});
|
||||
if (kept.count === 1) {
|
||||
return;
|
||||
}
|
||||
// Cancelled during the sync (the source left the calendar): the event
|
||||
// just written must not stay behind.
|
||||
if ((await this.release(entry)) === 1) {
|
||||
await this.deleteGraphEvent(graphEventId);
|
||||
return;
|
||||
}
|
||||
this.logger.warn(
|
||||
`Calendar sync claim on ${entry.sourceType} ${entry.sourceId} expired before Graph event ${graphEventId} was recorded`,
|
||||
);
|
||||
}
|
||||
|
||||
// PATCH on an existing event id that was deleted on the M365 side (e.g. an
|
||||
// admin removed it manually) would 404 forever - fall back to creating a
|
||||
// fresh event instead of leaving the row permanently FAILED.
|
||||
// fresh event instead of leaving the row permanently FAILED. Only then:
|
||||
// after any other failure (5xx, throttling, timeout) the event may well
|
||||
// still exist, and a second one would be a duplicate - that attempt fails
|
||||
// and is retried as an update.
|
||||
private async updateExisting(
|
||||
groupId: string,
|
||||
entry: ClaimedEntry,
|
||||
graphEventId: string,
|
||||
input: GraphCalendarEventInput,
|
||||
): Promise<string> {
|
||||
try {
|
||||
await this.graphClient.updateEvent(groupId, graphEventId, input);
|
||||
return graphEventId;
|
||||
} catch {
|
||||
return this.graphClient.createEvent(groupId, input);
|
||||
} catch (error: unknown) {
|
||||
const eventGone =
|
||||
error instanceof GraphRequestError &&
|
||||
EVENT_GONE_STATUSES.includes(error.status);
|
||||
if (!eventGone) {
|
||||
throw error;
|
||||
}
|
||||
return this.graphClient.createEvent(
|
||||
groupId,
|
||||
input,
|
||||
eventTransactionId(entry),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,17 +523,22 @@ export class CalendarSyncService {
|
||||
|
||||
async getStatus(): Promise<CalendarSyncStatusReport> {
|
||||
const settings = await this.settingsService.get();
|
||||
const [pendingCount, failedCount, syncedCount] = await Promise.all([
|
||||
this.prisma.calendarSyncOutboxEntry.count({
|
||||
where: { status: 'PENDING' },
|
||||
}),
|
||||
this.prisma.calendarSyncOutboxEntry.count({
|
||||
where: { status: 'FAILED' },
|
||||
}),
|
||||
this.prisma.calendarSyncOutboxEntry.count({
|
||||
where: { status: 'SYNCED' },
|
||||
}),
|
||||
]);
|
||||
const [pendingCount, failedCount, syncedCount, heldCount] =
|
||||
await Promise.all([
|
||||
this.prisma.calendarSyncOutboxEntry.count({
|
||||
where: { status: 'PENDING' },
|
||||
}),
|
||||
this.prisma.calendarSyncOutboxEntry.count({
|
||||
where: { status: 'FAILED' },
|
||||
}),
|
||||
this.prisma.calendarSyncOutboxEntry.count({
|
||||
where: { status: 'SYNCED' },
|
||||
}),
|
||||
// Live claims: some worker is writing an entry right now.
|
||||
this.prisma.calendarSyncOutboxEntry.count({
|
||||
where: { claimedAt: { gte: new Date(Date.now() - STALE_CLAIM_MS) } },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
configured: this.graphClient.isConfigured(),
|
||||
enabled: settings.enabled,
|
||||
@@ -297,6 +546,7 @@ export class CalendarSyncService {
|
||||
pendingCount,
|
||||
failedCount,
|
||||
syncedCount,
|
||||
running: this.activeRun !== null || heldCount > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { UpdateCalendarSyncSettingsDto } from './update-calendar-sync-settings.dto';
|
||||
|
||||
async function errorsFor(groupId: unknown): Promise<string[]> {
|
||||
const dto = plainToInstance(UpdateCalendarSyncSettingsDto, { groupId });
|
||||
const errors = await validate(dto);
|
||||
return errors.flatMap((error) => Object.values(error.constraints ?? {}));
|
||||
}
|
||||
|
||||
// The group id goes into the Graph URL path - only an Entra object id
|
||||
// (a GUID) is accepted, or an empty string to clear the value.
|
||||
describe('UpdateCalendarSyncSettingsDto groupId', () => {
|
||||
it.each([
|
||||
'3fa85f64-5717-4562-b3fc-2c963f66afa6',
|
||||
'3FA85F64-5717-4562-B3FC-2C963F66AFA6',
|
||||
'',
|
||||
])('accepts %p', async (groupId) => {
|
||||
expect(await errorsFor(groupId)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'meine-gruppe',
|
||||
'3fa85f64-5717-4562-b3fc-2c963f66afa6/events',
|
||||
'../users/[email protected]',
|
||||
'3fa85f64571745 62b3fc2c963f66afa6',
|
||||
' 3fa85f64-5717-4562-b3fc-2c963f66afa6',
|
||||
])('rejects %p with a German message', async (groupId) => {
|
||||
expect(await errorsFor(groupId)).toEqual([
|
||||
'Gruppen-ID muss die Objekt-ID der Microsoft-365-Gruppe sein (Format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +1,69 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
const GROUP_ID_PATTERN =
|
||||
/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$/i;
|
||||
|
||||
// PATCH null rule: an omitted field stays unchanged. The switches are NOT
|
||||
// NULL columns, so null on them is a 400. groupId is nullable: null clears
|
||||
// it, and so does "" - the GUI's way of emptying the field.
|
||||
export class UpdateCalendarSyncSettingsDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description:
|
||||
'Objekt-ID der Microsoft-365-Gruppe. Leerer String löscht den GUI-Wert.',
|
||||
'Objekt-ID der Microsoft-365-Gruppe. Leerer String oder null löscht den GUI-Wert.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
groupId?: string;
|
||||
// An Entra object id (GUID) or "" - the value goes into the Graph URL
|
||||
// path, so nothing else may get through.
|
||||
@Matches(GROUP_ID_PATTERN, {
|
||||
message:
|
||||
'Gruppen-ID muss die Objekt-ID der Microsoft-365-Gruppe sein (Format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)',
|
||||
})
|
||||
groupId?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
syncTask?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
syncInspection?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
syncTreatment?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
syncFeeding?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
syncHarvest?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
syncQueenAssignment?: boolean;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,16 @@ interface CachedToken {
|
||||
}
|
||||
|
||||
const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0';
|
||||
|
||||
// Both ids are encoded: a group id from the settings or an event id from
|
||||
// Graph must never be able to point the request at another resource
|
||||
// ("/", "?", "#" or "../" in the value).
|
||||
function eventsUrl(groupId: string, eventId?: string): string {
|
||||
const events = `${GRAPH_BASE_URL}/groups/${encodeURIComponent(groupId)}/events`;
|
||||
return eventId === undefined
|
||||
? events
|
||||
: `${events}/${encodeURIComponent(eventId)}`;
|
||||
}
|
||||
// All-day events render on the correct date regardless of viewer timezone,
|
||||
// but Graph still requires a timeZone on the start/end payload - fixed to
|
||||
// the org's own zone since this app has no per-user timezone setting.
|
||||
@@ -18,6 +28,27 @@ const EVENT_TIME_ZONE = 'Europe/Berlin';
|
||||
// Refresh a little before actual expiry so a request never races an
|
||||
// about-to-expire cached token.
|
||||
const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
|
||||
// Every request to Microsoft is bounded. One outbox entry takes at most a
|
||||
// token request plus two event requests, so it always finishes well within
|
||||
// the outbox claim (CalendarSyncService) - a hanging call must not outlive
|
||||
// the claim and let a second pod write the same entry.
|
||||
export const GRAPH_REQUEST_TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
/** A non-2xx answer from Graph; callers decide by its HTTP status. */
|
||||
export class GraphRequestError extends Error {
|
||||
constructor(
|
||||
operation: string,
|
||||
readonly status: number,
|
||||
body: string,
|
||||
) {
|
||||
super(`Graph ${operation} fehlgeschlagen (${status}): ${body}`);
|
||||
this.name = 'GraphRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
function timeoutSignal(): AbortSignal {
|
||||
return AbortSignal.timeout(GRAPH_REQUEST_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function tokenEndpoint(tenantId: string): string {
|
||||
return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
|
||||
@@ -84,6 +115,7 @@ export class GraphClientService {
|
||||
client_secret: clientSecret,
|
||||
scope: 'https://graph.microsoft.com/.default',
|
||||
}),
|
||||
signal: timeoutSignal(),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
throw new Error(
|
||||
@@ -128,22 +160,30 @@ export class GraphClientService {
|
||||
};
|
||||
}
|
||||
|
||||
// transactionId is Graph's guard against a repeated create: a retry after
|
||||
// a timed-out request carries the same value, so Graph can drop it instead
|
||||
// of adding a second event. Graph does not let an update change it, so
|
||||
// updateEvent() never sends one.
|
||||
async createEvent(
|
||||
groupId: string,
|
||||
input: GraphCalendarEventInput,
|
||||
transactionId: string,
|
||||
): Promise<string> {
|
||||
const token = await this.getAccessToken();
|
||||
const response = await fetch(`${GRAPH_BASE_URL}/groups/${groupId}/events`, {
|
||||
const response = await fetch(eventsUrl(groupId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(this.allDayEventBody(input)),
|
||||
body: JSON.stringify({ ...this.allDayEventBody(input), transactionId }),
|
||||
signal: timeoutSignal(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Graph createEvent fehlgeschlagen (${response.status}): ${await response.text()}`,
|
||||
throw new GraphRequestError(
|
||||
'createEvent',
|
||||
response.status,
|
||||
await response.text(),
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as { id: string };
|
||||
@@ -156,38 +196,38 @@ export class GraphClientService {
|
||||
input: GraphCalendarEventInput,
|
||||
): Promise<void> {
|
||||
const token = await this.getAccessToken();
|
||||
const response = await fetch(
|
||||
`${GRAPH_BASE_URL}/groups/${groupId}/events/${eventId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(this.allDayEventBody(input)),
|
||||
const response = await fetch(eventsUrl(groupId, eventId), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
);
|
||||
body: JSON.stringify(this.allDayEventBody(input)),
|
||||
signal: timeoutSignal(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Graph updateEvent fehlgeschlagen (${response.status}): ${await response.text()}`,
|
||||
throw new GraphRequestError(
|
||||
'updateEvent',
|
||||
response.status,
|
||||
await response.text(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteEvent(groupId: string, eventId: string): Promise<void> {
|
||||
const token = await this.getAccessToken();
|
||||
const response = await fetch(
|
||||
`${GRAPH_BASE_URL}/groups/${groupId}/events/${eventId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
const response = await fetch(eventsUrl(groupId, eventId), {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: timeoutSignal(),
|
||||
});
|
||||
// A 404 means the event is already gone - treat as success so a retry
|
||||
// after a partially-applied delete doesn't throw.
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(
|
||||
`Graph deleteEvent fehlgeschlagen (${response.status}): ${await response.text()}`,
|
||||
throw new GraphRequestError(
|
||||
'deleteEvent',
|
||||
response.status,
|
||||
await response.text(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +313,17 @@ describe('ColoniesService', () => {
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it.each(['WEAK', 'CONSPICUOUS', 'QUEENLESS'])(
|
||||
'reactivate rejects a %s colony that was never ended',
|
||||
async (status) => {
|
||||
prisma.colony.findUnique.mockResolvedValue({ ...parentColony, status });
|
||||
await expect(
|
||||
service.reactivate('colony-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(prisma.colony.update).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('reactivate sets status ACTIVE and clears deactivatedAt', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
|
||||
@@ -317,7 +317,9 @@ export class ColoniesService {
|
||||
|
||||
async reactivate(id: string, actingUserId: string): Promise<Colony> {
|
||||
const before = await this.findById(id);
|
||||
if (before.status === 'ACTIVE') {
|
||||
// Only an ended colony can be brought back. A weak or queenless one
|
||||
// still exists - reactivating it would just overwrite its condition.
|
||||
if (isLivingColonyStatus(before.status)) {
|
||||
throw new ConflictException('Colony is already active');
|
||||
}
|
||||
const colony = await this.prisma.colony.update({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ColonyStatus } from '../../generated/prisma';
|
||||
import { ColonyStatus, Prisma } from '../../generated/prisma';
|
||||
|
||||
/**
|
||||
* Statuses after which a colony no longer exists as a living unit. The other
|
||||
@@ -16,3 +16,8 @@ export const TERMINAL_COLONY_STATUSES: readonly ColonyStatus[] = [
|
||||
export function isLivingColonyStatus(status: ColonyStatus): boolean {
|
||||
return !TERMINAL_COLONY_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
/** The same rule as a query filter: colonies that still exist. */
|
||||
export const LIVING_COLONY_WHERE = {
|
||||
status: { notIn: [...TERMINAL_COLONY_STATUSES] },
|
||||
} satisfies Prisma.ColonyWhereInput;
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { ColonyStatus } from '../../../generated/prisma';
|
||||
import { CreateColonyDto } from './create-colony.dto';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
/**
|
||||
* `apiaryId` is editable here too, not only through POST /colonies/:id/move:
|
||||
@@ -22,11 +23,12 @@ class UpdatableColonyFieldsDto extends PartialType(
|
||||
'healthStatus',
|
||||
'notes',
|
||||
] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
|
||||
export class UpdateColonyDto extends UpdatableColonyFieldsDto {
|
||||
@ApiPropertyOptional({ enum: ColonyStatus })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(ColonyStatus)
|
||||
status?: ColonyStatus;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { runAfterCommit } from './after-commit';
|
||||
|
||||
describe('runAfterCommit', () => {
|
||||
let logger: Logger;
|
||||
let errorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = new Logger('AfterCommitSpec');
|
||||
errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
const subject = {
|
||||
action: 'Automation rule evaluation',
|
||||
entityType: 'Inspection',
|
||||
entityId: 'inspection-1',
|
||||
};
|
||||
|
||||
it('runs the side effect and logs nothing when it succeeds', async () => {
|
||||
const work = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await runAfterCommit(logger, subject, work);
|
||||
|
||||
expect(work).toHaveBeenCalledTimes(1);
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never rejects when the side effect fails', async () => {
|
||||
const work = jest.fn().mockRejectedValue(new Error('database gone'));
|
||||
|
||||
await expect(
|
||||
runAfterCommit(logger, subject, work),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('logs the failure at error level with entity type, id, error and stack', async () => {
|
||||
const failure = new Error('database gone');
|
||||
|
||||
await runAfterCommit(logger, subject, () => Promise.reject(failure));
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message, stack] = errorSpy.mock.calls[0] as [string, string];
|
||||
expect(message).toContain('Automation rule evaluation');
|
||||
expect(message).toContain('Inspection inspection-1');
|
||||
expect(message).toContain('database gone');
|
||||
expect(stack).toBe(failure.stack);
|
||||
});
|
||||
|
||||
it('also contains a synchronous throw', async () => {
|
||||
await runAfterCommit(logger, subject, () => {
|
||||
throw new Error('thrown synchronously');
|
||||
});
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain('thrown synchronously');
|
||||
});
|
||||
|
||||
it('never logs the message or stack of a Prisma error (they quote query values)', async () => {
|
||||
const failure = new Prisma.PrismaClientKnownRequestError(
|
||||
'Unique constraint failed on title "Max Mustermann"',
|
||||
{ code: 'P2002', clientVersion: '7.9.1', meta: { modelName: 'Task' } },
|
||||
);
|
||||
|
||||
await runAfterCommit(logger, subject, () => Promise.reject(failure));
|
||||
|
||||
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
|
||||
expect(message).toContain('Inspection inspection-1');
|
||||
expect(message).toContain('P2002');
|
||||
expect(message).toContain('Task');
|
||||
expect(message).not.toContain('Mustermann');
|
||||
expect(stack).toBeUndefined();
|
||||
});
|
||||
|
||||
it('logs a rejection that is not an Error object', async () => {
|
||||
const work = jest.fn().mockRejectedValue('plain string');
|
||||
|
||||
await runAfterCommit(logger, subject, work);
|
||||
|
||||
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
|
||||
expect(message).toContain('plain string');
|
||||
expect(stack).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { loggableError } from './loggable-error';
|
||||
|
||||
/** What a post-commit side effect was about - named in the log line. */
|
||||
export interface AfterCommitSubject {
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a side effect of a write that is already committed: automation
|
||||
* rules, calendar sync, webhooks. It must never fail the request - the
|
||||
* client would get an error for a record that exists, the Idempotency-Key
|
||||
* would be released, and the offline queue would send the request again and
|
||||
* store the record twice (for a treatment: a second Bestandsbuch entry).
|
||||
*
|
||||
* A failure is logged at error level with the record it concerned, and the
|
||||
* caller carries on. Only for steps after the commit: anything that has to
|
||||
* stop the write (number generation, validation) keeps throwing. What of
|
||||
* the error is logged follows loggableError() - no query values.
|
||||
*/
|
||||
export async function runAfterCommit(
|
||||
logger: Logger,
|
||||
subject: AfterCommitSubject,
|
||||
work: () => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await work();
|
||||
} catch (error: unknown) {
|
||||
const { message, stack } = loggableError(error);
|
||||
logger.error(
|
||||
`${subject.action} failed for ${subject.entityType} ${subject.entityId}: ${message}`,
|
||||
stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -176,4 +176,127 @@ describe('AllExceptionsFilter', () => {
|
||||
message: 'Internal server error',
|
||||
});
|
||||
});
|
||||
|
||||
// Prisma messages quote the query (titles, names, notes) - the log gets
|
||||
// name, code and model only, the client the same bodies as before.
|
||||
describe('Prisma errors in the log', () => {
|
||||
it('logs an unmapped Prisma error without its message or stack', () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const exception = new Prisma.PrismaClientValidationError(
|
||||
'Invalid `prisma.task.create()` invocation: { data: { title: "Max Mustermann" } }',
|
||||
{ clientVersion: '7.9.1' },
|
||||
);
|
||||
|
||||
filter.catch(exception, host);
|
||||
|
||||
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
|
||||
expect(message).toContain('PrismaClientValidationError');
|
||||
expect(message).not.toContain('Mustermann');
|
||||
expect(stack).toBeUndefined();
|
||||
expect(jsonMock).toHaveBeenCalledWith({
|
||||
statusCode: 500,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a translated Prisma error at warn level by code and model only', () => {
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||
'Unique constraint failed on the fields: (`code`) for "Mustermann"',
|
||||
{
|
||||
code: 'P2002',
|
||||
clientVersion: '7.9.1',
|
||||
meta: { modelName: 'Colony', target: ['code'] },
|
||||
},
|
||||
);
|
||||
|
||||
filter.catch(exception, host);
|
||||
|
||||
const [message] = warnSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain('P2002');
|
||||
expect(message).toContain('Colony');
|
||||
expect(message).not.toContain('Mustermann');
|
||||
expect(jsonMock).toHaveBeenCalledWith({
|
||||
statusCode: 409,
|
||||
message:
|
||||
'Code wird bereits verwendet - bitte einen anderen Wert wählen',
|
||||
});
|
||||
});
|
||||
|
||||
it('still logs message and stack of other server errors', () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const exception = new Error('Graph timeout');
|
||||
|
||||
filter.catch(exception, host);
|
||||
|
||||
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
|
||||
expect(message).toContain('Graph timeout');
|
||||
expect(stack).toBe(exception.stack);
|
||||
});
|
||||
});
|
||||
|
||||
// body-parser rejects a request before Nest sees it. Its message (and the
|
||||
// `body` property) can quote the request body - names, notes, passwords -
|
||||
// so the log gets status and type only, the client a fixed German text.
|
||||
describe('body-parser errors', () => {
|
||||
/** Shaped like body-parser's http-errors. */
|
||||
const bodyParserError = (
|
||||
status: number,
|
||||
type: string,
|
||||
message: string,
|
||||
): Error =>
|
||||
Object.assign(new Error(message), {
|
||||
status,
|
||||
statusCode: status,
|
||||
type,
|
||||
expose: true,
|
||||
body: '{"name":"Max Mustermann","password":"geheim"',
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
400,
|
||||
'entity.parse.failed',
|
||||
'Unexpected token, "{"name":"Max Mustermann"..." is not valid JSON',
|
||||
'Ungültige Anfrage',
|
||||
],
|
||||
[
|
||||
413,
|
||||
'entity.too.large',
|
||||
'request entity too large',
|
||||
'Anfrage ist zu groß',
|
||||
],
|
||||
[
|
||||
415,
|
||||
'charset.unsupported',
|
||||
'unsupported charset "MUSTERMANN-8"',
|
||||
'Format der Anfrage wird nicht unterstützt',
|
||||
],
|
||||
])(
|
||||
'logs a %i (%s) by status and type only and answers with a fixed text',
|
||||
(status, type, message, clientMessage) => {
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
filter.catch(bodyParserError(status, type, message), host);
|
||||
|
||||
const [logged] = warnSpy.mock.calls[0] as [string];
|
||||
expect(logged).toContain(String(status));
|
||||
expect(logged).toContain(type);
|
||||
expect(logged).not.toMatch(/Mustermann|MUSTERMANN|geheim/);
|
||||
expect(statusMock).toHaveBeenCalledWith(status);
|
||||
expect(jsonMock).toHaveBeenCalledWith({
|
||||
statusCode: status,
|
||||
message: clientMessage,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
import { loggableError } from '../loggable-error';
|
||||
|
||||
// Unique-constraint fields a beekeeper might plausibly retype by mistake
|
||||
// (a hive code, a batch number) - translated so the P2002 branch below can
|
||||
@@ -73,6 +74,31 @@ function middlewareErrorResponse(
|
||||
return { status, message: messages[status] ?? 'Ungültige Anfrage' };
|
||||
}
|
||||
|
||||
/**
|
||||
* body-parser's errors (http-errors): a numeric status plus a `type` such as
|
||||
* "entity.parse.failed", "entity.too.large" or "charset.unsupported". Their
|
||||
* message - and the `body` property - can quote the request body (names,
|
||||
* notes, passwords), so only status and type may be logged.
|
||||
*/
|
||||
function bodyParserErrorType(exception: unknown): string | null {
|
||||
if (
|
||||
exception instanceof HttpException ||
|
||||
typeof exception !== 'object' ||
|
||||
exception === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const candidate = exception as {
|
||||
type?: unknown;
|
||||
status?: unknown;
|
||||
statusCode?: unknown;
|
||||
};
|
||||
const status = candidate.status ?? candidate.statusCode;
|
||||
return typeof candidate.type === 'string' && typeof status === 'number'
|
||||
? candidate.type
|
||||
: null;
|
||||
}
|
||||
|
||||
function prismaErrorResponse(
|
||||
exception: unknown,
|
||||
): { status: number; message: string } | null {
|
||||
@@ -109,7 +135,8 @@ function prismaErrorResponse(
|
||||
* Client-facing behaviour is unchanged: HttpExceptions still return
|
||||
* exactly the body/status Nest would have produced. Non-HttpException
|
||||
* errors still collapse to a generic 500 body - the real message and
|
||||
* stack go to the log, never to the client. The exceptions are the Prisma
|
||||
* stack go to the log, never to the client (for Prisma errors only name,
|
||||
* code and model: their messages quote query values). The exceptions are the Prisma
|
||||
* errors listed in prismaErrorResponse: services write records straight
|
||||
* against DB constraints (`@unique` columns like colony/apiary/queen codes,
|
||||
* SKUs and batch numbers; foreign keys for every parent relation) with no
|
||||
@@ -140,8 +167,18 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
statusCode: status,
|
||||
message: prismaResponse?.message ?? 'Internal server error',
|
||||
};
|
||||
const message =
|
||||
exception instanceof Error ? exception.message : 'Unknown error';
|
||||
// A Prisma error is logged by name, code and model only - its message
|
||||
// and stack quote the query (see loggableError).
|
||||
const parserType = bodyParserErrorType(exception);
|
||||
const { message, stack } =
|
||||
parserType !== null
|
||||
? {
|
||||
message: `rejected by body parser (${parserType})`,
|
||||
stack: undefined,
|
||||
}
|
||||
: exception instanceof Error
|
||||
? loggableError(exception)
|
||||
: { message: 'Unknown error', stack: undefined };
|
||||
// Path only - query strings can carry codes and personal data.
|
||||
const context = `${request.method} ${request.baseUrl}${request.path} -> ${status}`;
|
||||
// Compared against the literal 500, not HttpStatus.INTERNAL_SERVER_ERROR:
|
||||
@@ -150,10 +187,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
const isServerError = status >= 500;
|
||||
|
||||
if (isServerError) {
|
||||
this.logger.error(
|
||||
`${context}: ${message}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
this.logger.error(`${context}: ${message}`, stack);
|
||||
} else {
|
||||
this.logger.warn(`${context}: ${message}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { IdempotencyCleanupService } from './idempotency-cleanup.service';
|
||||
|
||||
describe('IdempotencyCleanupService', () => {
|
||||
let service: IdempotencyCleanupService;
|
||||
let prisma: {
|
||||
idempotencyRecord: { deleteMany: jest.Mock };
|
||||
consumedToken: { deleteMany: jest.Mock };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
idempotencyRecord: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
consumedToken: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
||||
};
|
||||
service = new IdempotencyCleanupService(prisma as unknown as PrismaService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('removes records older than the retention period and expired tokens', async () => {
|
||||
prisma.idempotencyRecord.deleteMany.mockResolvedValue({ count: 3 });
|
||||
const now = new Date('2026-10-12T04:00:00.000Z');
|
||||
|
||||
await expect(service.removeExpired(now)).resolves.toBe(3);
|
||||
|
||||
expect(prisma.idempotencyRecord.deleteMany).toHaveBeenCalledWith({
|
||||
where: { createdAt: { lt: new Date('2026-07-14T04:00:00.000Z') } },
|
||||
});
|
||||
expect(prisma.consumedToken.deleteMany).toHaveBeenCalledWith({
|
||||
where: { expiresAt: { lt: now } },
|
||||
});
|
||||
});
|
||||
|
||||
// The cron tick's own errors would otherwise reach the scheduler's logger
|
||||
// with the full Prisma message and stack.
|
||||
it('the daily tick never throws and logs a failure without the Prisma message', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
prisma.idempotencyRecord.deleteMany.mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError('key "Max Mustermann"', {
|
||||
code: 'P2024',
|
||||
clientVersion: '7.9.1',
|
||||
meta: { modelName: 'IdempotencyRecord' },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.scheduledCleanup()).resolves.toBeUndefined();
|
||||
|
||||
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
|
||||
expect(message).toContain('P2024');
|
||||
expect(message).not.toContain('Mustermann');
|
||||
expect(stack).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { loggableError } from '../loggable-error';
|
||||
|
||||
// Longer than any realistic offline stretch at the apiary: an entry queued
|
||||
// on the phone must still find its key when it finally syncs, otherwise the
|
||||
@@ -13,7 +14,19 @@ export class IdempotencyCleanupService {
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// The daily tick. An error is logged here, Prisma errors by name/code/model
|
||||
// only (see loggableError) - left uncaught, the scheduler would log the
|
||||
// full Prisma message and stack.
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
||||
async scheduledCleanup(): Promise<void> {
|
||||
try {
|
||||
await this.removeExpired();
|
||||
} catch (error: unknown) {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(`Idempotency cleanup failed: ${message}`, stack);
|
||||
}
|
||||
}
|
||||
|
||||
async removeExpired(now: Date = new Date()): Promise<number> {
|
||||
const cutoff = new Date(
|
||||
now.getTime() - IDEMPOTENCY_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { loggableError } from './loggable-error';
|
||||
|
||||
// Prisma messages quote the query - titles, descriptions, names. None of
|
||||
// that may reach the log; what identifies the failure is name, code, model.
|
||||
describe('loggableError', () => {
|
||||
const clientVersion = '7.9.1';
|
||||
|
||||
it('keeps message and stack of an ordinary error', () => {
|
||||
const error = new Error('Graph createEvent fehlgeschlagen (503)');
|
||||
|
||||
expect(loggableError(error)).toEqual({
|
||||
message: 'Graph createEvent fehlgeschlagen (503)',
|
||||
stack: error.stack,
|
||||
});
|
||||
});
|
||||
|
||||
it('turns a thrown non-Error value into text without a stack', () => {
|
||||
expect(loggableError('plain string')).toEqual({
|
||||
message: 'plain string',
|
||||
stack: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a known Prisma error by name, code and model only', () => {
|
||||
const error = new Prisma.PrismaClientKnownRequestError(
|
||||
'Unique constraint failed on title "Max Mustermann"',
|
||||
{
|
||||
code: 'P2002',
|
||||
clientVersion,
|
||||
meta: { modelName: 'Task', target: ['title'] },
|
||||
},
|
||||
);
|
||||
|
||||
const logged = loggableError(error);
|
||||
|
||||
expect(logged).toEqual({
|
||||
message: 'PrismaClientKnownRequestError P2002 (Task)',
|
||||
stack: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves out the model when Prisma does not name one', () => {
|
||||
const error = new Prisma.PrismaClientKnownRequestError('Mustermann', {
|
||||
code: 'P2025',
|
||||
clientVersion,
|
||||
});
|
||||
|
||||
expect(loggableError(error).message).toBe(
|
||||
'PrismaClientKnownRequestError P2025',
|
||||
);
|
||||
});
|
||||
|
||||
it('never logs the query of a validation error', () => {
|
||||
const error = new Prisma.PrismaClientValidationError(
|
||||
'Invalid `prisma.task.create()` invocation: { data: { title: "Max Mustermann" } }',
|
||||
{ clientVersion },
|
||||
);
|
||||
|
||||
expect(loggableError(error)).toEqual({
|
||||
message: 'PrismaClientValidationError',
|
||||
stack: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
new Prisma.PrismaClientUnknownRequestError('Mustermann', { clientVersion }),
|
||||
new Prisma.PrismaClientRustPanicError('Mustermann', clientVersion),
|
||||
new Prisma.PrismaClientInitializationError('Mustermann', clientVersion),
|
||||
])('logs %s by name only', (error) => {
|
||||
const logged = loggableError(error);
|
||||
|
||||
expect(logged.message).toBe(error.name);
|
||||
expect(logged.stack).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
|
||||
/** What of an error may go into the log. */
|
||||
export interface LoggableError {
|
||||
message: string;
|
||||
stack: string | undefined;
|
||||
}
|
||||
|
||||
const PRISMA_ERRORS = [
|
||||
Prisma.PrismaClientKnownRequestError,
|
||||
Prisma.PrismaClientUnknownRequestError,
|
||||
Prisma.PrismaClientRustPanicError,
|
||||
Prisma.PrismaClientInitializationError,
|
||||
Prisma.PrismaClientValidationError,
|
||||
];
|
||||
|
||||
function isPrismaError(error: unknown): error is Error {
|
||||
return PRISMA_ERRORS.some((type) => error instanceof type);
|
||||
}
|
||||
|
||||
function modelName(meta: Record<string, unknown> | undefined): string | null {
|
||||
return typeof meta?.modelName === 'string' ? meta.modelName : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma error messages quote the failing query - titles, descriptions,
|
||||
* names, i.e. personal data - and so does the stack, whose first line is
|
||||
* the message. A Prisma error is therefore logged by name, error code and
|
||||
* model only. Any other error keeps message and stack for diagnosis.
|
||||
*/
|
||||
export function loggableError(error: unknown): LoggableError {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
const model = modelName(error.meta);
|
||||
return {
|
||||
message: `${error.name} ${error.code}${model ? ` (${model})` : ''}`,
|
||||
stack: undefined,
|
||||
};
|
||||
}
|
||||
if (isPrismaError(error)) {
|
||||
return { message: error.name, stack: undefined };
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return { message: error.message, stack: error.stack };
|
||||
}
|
||||
return { message: String(error), stack: undefined };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { optionalDate } from './patch-values';
|
||||
|
||||
describe('optionalDate', () => {
|
||||
it('leaves an omitted date untouched', () => {
|
||||
expect(optionalDate(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears the date on null', () => {
|
||||
expect(optionalDate(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('parses a given date', () => {
|
||||
expect(optionalDate('2026-07-15')).toEqual(new Date('2026-07-15'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
// PATCH semantics for a nullable date column: an omitted field (undefined)
|
||||
// stays as it is, null clears it. Null for a column that cannot hold it never
|
||||
// gets this far - the update DTOs reject it (see IsOptionalNotNull).
|
||||
export function optionalDate(
|
||||
value: string | null | undefined,
|
||||
): Date | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return value === null ? null : new Date(value);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ValidateIf } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Like @IsOptional, but only an omitted field skips validation: null still
|
||||
* runs the field's validators and is rejected. For fields whose column cannot
|
||||
* hold null (required, or filled with a default when omitted) - null is a 400
|
||||
* here instead of being silently ignored or failing the write with a 500.
|
||||
* Update DTOs built with PartialType(..., { skipNullProperties: false }) get
|
||||
* the same treatment for every field the create DTO requires.
|
||||
*/
|
||||
export function IsOptionalNotNull(): PropertyDecorator {
|
||||
return ValidateIf((_object, value) => value !== undefined);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { UpdateApiaryDto } from '../../apiaries/dto/update-apiary.dto';
|
||||
import { UpdateAutomationRuleDto } from '../../automation/dto/update-automation-rule.dto';
|
||||
import { UpdateBackupSettingsDto } from '../../backup/dto/update-backup-settings.dto';
|
||||
import { UpdateCalendarSyncSettingsDto } from '../../calendar-sync/dto/update-calendar-sync-settings.dto';
|
||||
import { UpdateBottlingBatchDto } from '../../bottling/dto/update-bottling-batch.dto';
|
||||
import { UpdateColonyDto } from '../../colonies/dto/update-colony.dto';
|
||||
import { UpdateDocumentDto } from '../../documents/dto/update-document.dto';
|
||||
import { UpdateEquipmentDto } from '../../equipment/dto/update-equipment.dto';
|
||||
import { UpdateExtractionDto } from '../../extractions/dto/update-extraction.dto';
|
||||
import { UpdateFeedingDto } from '../../feedings/dto/update-feeding.dto';
|
||||
import { UpdateHarvestDto } from '../../harvests/dto/update-harvest.dto';
|
||||
import { UpdateHoneyBatchDto } from '../../honey-batches/dto/update-honey-batch.dto';
|
||||
import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-measurement.dto';
|
||||
import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto';
|
||||
import { UpdateLabelTemplateDto } from '../../label-templates/dto/update-label-template.dto';
|
||||
import { UpdateProductionBatchDto } from '../../production-batches/dto/update-production-batch.dto';
|
||||
import { UpdateProductDto } from '../../products/dto/update-product.dto';
|
||||
import { UpdateProfileDto } from '../../profile/dto/update-profile.dto';
|
||||
import { UpdateQueenEvaluationDto } from '../../queens/dto/update-queen-evaluation.dto';
|
||||
import { UpdateQueenDto } from '../../queens/dto/update-queen.dto';
|
||||
import { UpdateRawMaterialLotDto } from '../../raw-materials/dto/update-raw-material-lot.dto';
|
||||
import { UpdateRoleDto } from '../../roles/dto/update-role.dto';
|
||||
import { UpdateStockItemDto } from '../../storage/dto/update-stock-item.dto';
|
||||
import { UpdateStorageContainerDto } from '../../storage/dto/update-storage-container.dto';
|
||||
import { UpdateSwarmControlCheckDto } from '../../swarm-control/dto/update-swarm-control-check.dto';
|
||||
import { UpdateTaskTemplateDto } from '../../task-templates/dto/update-task-template.dto';
|
||||
import { UpdateTaskDto } from '../../tasks/dto/update-task.dto';
|
||||
import { UpdateTreatmentDto } from '../../treatments/dto/update-treatment.dto';
|
||||
import { UpdateUserDto } from '../../users/dto/update-user.dto';
|
||||
import { UpdateVarroaMeasurementDto } from '../../varroa/dto/update-varroa-measurement.dto';
|
||||
import { UpdateWebhookDto } from '../../webhooks/dto/update-webhook.dto';
|
||||
import { UpdateWinterCheckDto } from '../../winter-checks/dto/update-winter-check.dto';
|
||||
|
||||
// PATCH contract of every update DTO: an omitted field stays unchanged, null
|
||||
// clears a field whose column is nullable, and null on a column that cannot
|
||||
// hold it is a 400 - never silently ignored, never a 500 from the write.
|
||||
interface NullContract {
|
||||
dto: new () => object;
|
||||
notNull: string[];
|
||||
clearable: string[];
|
||||
}
|
||||
|
||||
const contracts: Record<string, NullContract> = {
|
||||
UpdateApiaryDto: {
|
||||
dto: UpdateApiaryDto,
|
||||
notNull: ['name'],
|
||||
clearable: [
|
||||
'description',
|
||||
'location',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'address',
|
||||
'notes',
|
||||
],
|
||||
},
|
||||
UpdateAutomationRuleDto: {
|
||||
dto: UpdateAutomationRuleDto,
|
||||
notNull: ['active'],
|
||||
clearable: ['config', 'actionTemplate'],
|
||||
},
|
||||
UpdateBackupSettingsDto: {
|
||||
dto: UpdateBackupSettingsDto,
|
||||
notNull: [],
|
||||
clearable: ['storagePath', 'cronSchedule', 'retentionCount'],
|
||||
},
|
||||
// groupId: null clears it, the same as the empty string.
|
||||
UpdateCalendarSyncSettingsDto: {
|
||||
dto: UpdateCalendarSyncSettingsDto,
|
||||
notNull: [
|
||||
'enabled',
|
||||
'syncTask',
|
||||
'syncInspection',
|
||||
'syncTreatment',
|
||||
'syncFeeding',
|
||||
'syncHarvest',
|
||||
'syncQueenAssignment',
|
||||
],
|
||||
clearable: ['groupId'],
|
||||
},
|
||||
UpdateBottlingBatchDto: {
|
||||
dto: UpdateBottlingBatchDto,
|
||||
notNull: [
|
||||
'honeyBatchId',
|
||||
'date',
|
||||
'jarSize',
|
||||
'jarCount',
|
||||
'bestBeforeDate',
|
||||
'lotNumber',
|
||||
],
|
||||
clearable: ['productId'],
|
||||
},
|
||||
UpdateColonyDto: {
|
||||
dto: UpdateColonyDto,
|
||||
notNull: ['apiaryId', 'code', 'colonyType', 'status'],
|
||||
clearable: [
|
||||
'hiveType',
|
||||
'originText',
|
||||
'strength',
|
||||
'broodStatus',
|
||||
'feedStatus',
|
||||
'healthStatus',
|
||||
'notes',
|
||||
],
|
||||
},
|
||||
UpdateDocumentDto: {
|
||||
dto: UpdateDocumentDto,
|
||||
notNull: ['entityType', 'entityId', 'fileName'],
|
||||
clearable: [],
|
||||
},
|
||||
UpdateEquipmentDto: {
|
||||
dto: UpdateEquipmentDto,
|
||||
notNull: ['inventoryNumber', 'name', 'condition'],
|
||||
clearable: ['manufacturer', 'purchaseDate', 'location', 'maintenanceDate'],
|
||||
},
|
||||
UpdateExtractionDto: {
|
||||
dto: UpdateExtractionDto,
|
||||
notNull: ['date', 'harvestIds'],
|
||||
clearable: ['amount', 'room', 'waterContent', 'temperature', 'notes'],
|
||||
},
|
||||
UpdateFeedingDto: {
|
||||
dto: UpdateFeedingDto,
|
||||
notNull: ['colonyId', 'date', 'feedType', 'amount', 'unit'],
|
||||
clearable: ['product', 'batchNumber', 'notes'],
|
||||
},
|
||||
UpdateHarvestDto: {
|
||||
dto: UpdateHarvestDto,
|
||||
notNull: ['apiaryId', 'date', 'colonyIds'],
|
||||
clearable: [
|
||||
'honeyType',
|
||||
'honeySuperCount',
|
||||
'grossWeight',
|
||||
'netWeight',
|
||||
'notes',
|
||||
],
|
||||
},
|
||||
UpdateHoneyBatchDto: {
|
||||
dto: UpdateHoneyBatchDto,
|
||||
notNull: ['batchNumber', 'extractionId', 'status'],
|
||||
clearable: ['honeyType', 'amount', 'waterContent', 'storageContainerId'],
|
||||
},
|
||||
UpdateHoneyMeasurementDto: {
|
||||
dto: UpdateHoneyMeasurementDto,
|
||||
notNull: ['waterContent', 'measuredAt'],
|
||||
clearable: ['device', 'temperature', 'notes'],
|
||||
},
|
||||
UpdateInspectionDto: {
|
||||
dto: UpdateInspectionDto,
|
||||
notNull: ['colonyId', 'date'],
|
||||
clearable: [
|
||||
'weather',
|
||||
'temperature',
|
||||
'colonyStrength',
|
||||
'broodAmount',
|
||||
'broodPattern',
|
||||
'openBroodPresent',
|
||||
'cappedBroodPresent',
|
||||
'eggsPresent',
|
||||
'queenSeen',
|
||||
'queenCellsPresent',
|
||||
'swarmMood',
|
||||
'gentleness',
|
||||
'combBehavior',
|
||||
'feedStatus',
|
||||
'honeySuperStatus',
|
||||
'healthIssues',
|
||||
'varroaIssues',
|
||||
'measures',
|
||||
'notes',
|
||||
],
|
||||
},
|
||||
UpdateLabelTemplateDto: {
|
||||
dto: UpdateLabelTemplateDto,
|
||||
notNull: [
|
||||
'name',
|
||||
'elements',
|
||||
'widthMm',
|
||||
'heightMm',
|
||||
'marginTopMm',
|
||||
'marginLeftMm',
|
||||
'gapXMm',
|
||||
'gapYMm',
|
||||
'cropMarks',
|
||||
'isDefault',
|
||||
],
|
||||
clearable: ['columns', 'rows'],
|
||||
},
|
||||
UpdateProductDto: {
|
||||
dto: UpdateProductDto,
|
||||
notNull: ['name', 'status', 'kind', 'shopCategory'],
|
||||
clearable: ['description', 'sku', 'packagingSize', 'weight', 'salesUnit'],
|
||||
},
|
||||
UpdateProductionBatchDto: {
|
||||
dto: UpdateProductionBatchDto,
|
||||
notNull: ['lotNumber', 'date', 'quantity'],
|
||||
clearable: ['bestBeforeDate', 'notes'],
|
||||
},
|
||||
UpdateProfileDto: {
|
||||
dto: UpdateProfileDto,
|
||||
notNull: ['name', 'email'],
|
||||
clearable: [],
|
||||
},
|
||||
UpdateQueenDto: {
|
||||
dto: UpdateQueenDto,
|
||||
notNull: ['code', 'status'],
|
||||
clearable: [
|
||||
'hatchYear',
|
||||
'breed',
|
||||
'origin',
|
||||
'breeder',
|
||||
'markingColor',
|
||||
'markingNumber',
|
||||
'matingType',
|
||||
'matingStatus',
|
||||
'inseminationData',
|
||||
'motherLine',
|
||||
'fatherLine',
|
||||
'motherQueenId',
|
||||
'notes',
|
||||
],
|
||||
},
|
||||
UpdateQueenEvaluationDto: {
|
||||
dto: UpdateQueenEvaluationDto,
|
||||
notNull: [
|
||||
'date',
|
||||
'colonyStrength',
|
||||
'gentleness',
|
||||
'combBuild',
|
||||
'swarmingTendency',
|
||||
'broodPattern',
|
||||
'honeyYield',
|
||||
],
|
||||
clearable: ['colonyId', 'notes'],
|
||||
},
|
||||
UpdateRawMaterialLotDto: {
|
||||
dto: UpdateRawMaterialLotDto,
|
||||
notNull: ['lotNumber', 'date', 'quantity'],
|
||||
clearable: ['apiaryId', 'harvestId', 'notes'],
|
||||
},
|
||||
UpdateRoleDto: {
|
||||
dto: UpdateRoleDto,
|
||||
notNull: ['key', 'name', 'permissionKeys'],
|
||||
clearable: ['description'],
|
||||
},
|
||||
UpdateStockItemDto: {
|
||||
dto: UpdateStockItemDto,
|
||||
notNull: ['name', 'category', 'unit', 'quantity'],
|
||||
clearable: ['minThreshold', 'notes'],
|
||||
},
|
||||
UpdateStorageContainerDto: {
|
||||
dto: UpdateStorageContainerDto,
|
||||
notNull: ['code'],
|
||||
clearable: ['containerType', 'capacity', 'location'],
|
||||
},
|
||||
UpdateSwarmControlCheckDto: {
|
||||
dto: UpdateSwarmControlCheckDto,
|
||||
notNull: [
|
||||
'colonyId',
|
||||
'date',
|
||||
'queenCellsCapped',
|
||||
'swarmMood',
|
||||
'actionTaken',
|
||||
],
|
||||
clearable: ['queenCellsFound', 'notes', 'nextCheckDate'],
|
||||
},
|
||||
UpdateTaskDto: {
|
||||
dto: UpdateTaskDto,
|
||||
notNull: ['title', 'priority', 'status', 'relatedType'],
|
||||
clearable: ['description', 'dueDate', 'assignedUserId', 'relatedId'],
|
||||
},
|
||||
UpdateTaskTemplateDto: {
|
||||
dto: UpdateTaskTemplateDto,
|
||||
notNull: ['title', 'priority', 'recurrenceType', 'active'],
|
||||
clearable: [
|
||||
'description',
|
||||
'assignedUserId',
|
||||
'recurrenceMonth',
|
||||
'recurrenceDayOfMonth',
|
||||
'intervalDays',
|
||||
],
|
||||
},
|
||||
UpdateTreatmentDto: {
|
||||
dto: UpdateTreatmentDto,
|
||||
notNull: ['colonyId', 'date', 'product'],
|
||||
clearable: [
|
||||
'startDate',
|
||||
'endDate',
|
||||
'activeIngredient',
|
||||
'amount',
|
||||
'method',
|
||||
'reason',
|
||||
'batchNumber',
|
||||
'supplier',
|
||||
'purchaseProof',
|
||||
'prescribingVet',
|
||||
'notes',
|
||||
'withdrawalPeriodDays',
|
||||
],
|
||||
},
|
||||
UpdateUserDto: {
|
||||
dto: UpdateUserDto,
|
||||
notNull: ['name', 'email', 'roleId'],
|
||||
clearable: [],
|
||||
},
|
||||
UpdateVarroaMeasurementDto: {
|
||||
dto: UpdateVarroaMeasurementDto,
|
||||
notNull: ['colonyId', 'date', 'method'],
|
||||
clearable: [
|
||||
'durationMinutes',
|
||||
'miteCount',
|
||||
'calculatedValue',
|
||||
'assessment',
|
||||
'notes',
|
||||
],
|
||||
},
|
||||
UpdateWebhookDto: {
|
||||
dto: UpdateWebhookDto,
|
||||
notNull: ['url', 'events', 'active'],
|
||||
clearable: [],
|
||||
},
|
||||
UpdateWinterCheckDto: {
|
||||
dto: UpdateWinterCheckDto,
|
||||
notNull: ['colonyId', 'season', 'date'],
|
||||
clearable: [
|
||||
'occupiedSeams',
|
||||
'feedStockKg',
|
||||
'queenPresent',
|
||||
'outcome',
|
||||
'lossCause',
|
||||
'notes',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
async function errorPropertiesFor(
|
||||
dto: new () => object,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<string[]> {
|
||||
const errors = await validate(plainToInstance(dto, body), {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return errors.map((error) => error.property);
|
||||
}
|
||||
|
||||
describe('update DTO null handling', () => {
|
||||
describe.each(Object.entries(contracts))('%s', (_name, contract) => {
|
||||
it('accepts an empty patch', async () => {
|
||||
await expect(errorPropertiesFor(contract.dto, {})).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
if (contract.notNull.length > 0) {
|
||||
it.each(contract.notNull)('rejects null for %s', async (field) => {
|
||||
await expect(
|
||||
errorPropertiesFor(contract.dto, { [field]: null }),
|
||||
).resolves.toEqual([field]);
|
||||
});
|
||||
}
|
||||
|
||||
if (contract.clearable.length > 0) {
|
||||
it.each(contract.clearable)('accepts null for %s', async (field) => {
|
||||
await expect(
|
||||
errorPropertiesFor(contract.dto, { [field]: null }),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,34 @@ describe('DashboardService', () => {
|
||||
expect(alerts).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('living colonies', () => {
|
||||
// Weak, conspicuous and queenless colonies still stand in the apiary.
|
||||
const living = {
|
||||
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
||||
};
|
||||
|
||||
it('summary counts every living colony, not only ACTIVE ones', async () => {
|
||||
await service.summary();
|
||||
|
||||
expect(prisma.colony.count).toHaveBeenCalledWith({ where: living });
|
||||
});
|
||||
|
||||
it('alerts check swarm controls and queens of every living colony', async () => {
|
||||
await service.alerts();
|
||||
|
||||
expect(prisma.swarmControlCheck.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { colony: living } }),
|
||||
);
|
||||
const [, [queenedColoniesQuery]] = prisma.colony.findMany.mock.calls as [
|
||||
unknown,
|
||||
[{ where: object }],
|
||||
];
|
||||
expect(queenedColoniesQuery.where).toEqual(
|
||||
expect.objectContaining(living),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OLD_QUEEN', () => {
|
||||
it('flags a colony whose queen is at least 3 years old', async () => {
|
||||
prisma.colony.findMany
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
|
||||
export interface DashboardSummary {
|
||||
apiariesActive: number;
|
||||
@@ -42,7 +43,7 @@ export class DashboardService {
|
||||
stockItems,
|
||||
] = await Promise.all([
|
||||
this.prisma.apiary.count({ where: { status: 'ACTIVE' } }),
|
||||
this.prisma.colony.count({ where: { status: 'ACTIVE' } }),
|
||||
this.prisma.colony.count({ where: LIVING_COLONY_WHERE }),
|
||||
this.prisma.queen.count({
|
||||
where: { status: { in: ['ACTIVE', 'RESERVE'] } },
|
||||
}),
|
||||
@@ -89,7 +90,7 @@ export class DashboardService {
|
||||
where: { minThreshold: { not: null } },
|
||||
}),
|
||||
this.prisma.swarmControlCheck.findMany({
|
||||
where: { colony: { status: 'ACTIVE' } },
|
||||
where: { colony: LIVING_COLONY_WHERE },
|
||||
orderBy: [{ colonyId: 'asc' }, { date: 'desc' }],
|
||||
select: {
|
||||
colonyId: true,
|
||||
@@ -99,7 +100,7 @@ export class DashboardService {
|
||||
}),
|
||||
this.prisma.colony.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
...LIVING_COLONY_WHERE,
|
||||
currentQueen: { hatchYear: { not: null } },
|
||||
},
|
||||
select: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { DocumentEntityType } from '../../../generated/prisma';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
/**
|
||||
* A document filed against the wrong record can be re-linked instead of
|
||||
@@ -9,18 +10,18 @@ import { DocumentEntityType } from '../../../generated/prisma';
|
||||
*/
|
||||
export class UpdateDocumentDto {
|
||||
@ApiPropertyOptional({ enum: DocumentEntityType })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(DocumentEntityType)
|
||||
entityType?: DocumentEntityType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
entityId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
fileName?: string;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { EquipmentCondition } from '../../../generated/prisma';
|
||||
import { CreateEquipmentDto } from './create-equipment.dto';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class UpdateEquipmentDto extends PartialType(CreateEquipmentDto) {
|
||||
export class UpdateEquipmentDto extends PartialType(CreateEquipmentDto, {
|
||||
skipNullProperties: false,
|
||||
}) {
|
||||
@ApiPropertyOptional({ enum: EquipmentCondition })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(EquipmentCondition)
|
||||
condition?: EquipmentCondition;
|
||||
}
|
||||
@@ -6,4 +6,6 @@ import { CreateExtractionDto } from './create-extraction.dto';
|
||||
* linked to the wrong Ernte is a data-entry error, and re-pointing it is the
|
||||
* only way to repair the traceability chain without deleting the record.
|
||||
*/
|
||||
export class UpdateExtractionDto extends PartialType(CreateExtractionDto) {}
|
||||
export class UpdateExtractionDto extends PartialType(CreateExtractionDto, {
|
||||
skipNullProperties: false,
|
||||
}) {}
|
||||
@@ -6,4 +6,6 @@ import { CreateFeedingDto } from './create-feeding.dto';
|
||||
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||
* repair the colony's history without deleting the record.
|
||||
*/
|
||||
export class UpdateFeedingDto extends PartialType(CreateFeedingDto) {}
|
||||
export class UpdateFeedingDto extends PartialType(CreateFeedingDto, {
|
||||
skipNullProperties: false,
|
||||
}) {}
|
||||
@@ -17,6 +17,7 @@ describe('FeedingsService', () => {
|
||||
};
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
|
||||
|
||||
const feeding = { id: 'feeding-1', colonyId: 'colony-1', date: new Date() };
|
||||
|
||||
@@ -31,7 +32,7 @@ describe('FeedingsService', () => {
|
||||
},
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
service = new FeedingsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
@@ -91,6 +92,15 @@ describe('FeedingsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('update re-enqueues the calendar sync so a changed date moves the event', async () => {
|
||||
prisma.feeding.findUnique.mockResolvedValue(feeding);
|
||||
prisma.feeding.update.mockResolvedValue(feeding);
|
||||
|
||||
await service.update('feeding-1', { date: '2026-05-02' }, 'user-1');
|
||||
|
||||
expect(calendarSync.enqueue).toHaveBeenCalledWith('FEEDING', 'feeding-1');
|
||||
});
|
||||
|
||||
it('summaryByColony sums amounts per unit', async () => {
|
||||
prisma.feeding.groupBy.mockResolvedValue([
|
||||
{ unit: 'l', _sum: { amount: 5.5 } },
|
||||
|
||||
@@ -86,6 +86,9 @@ export class FeedingsService {
|
||||
beforeData: before,
|
||||
afterData: feeding,
|
||||
});
|
||||
// A changed date or colony must move the calendar event, not leave the
|
||||
// old one behind.
|
||||
await this.calendarSync.enqueue('FEEDING', feeding.id);
|
||||
return feeding;
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,6 @@ import { CreateHarvestDto } from './create-harvest.dto';
|
||||
* data-entry error, and re-pointing it is the only way to repair the
|
||||
* traceability chain without deleting the record.
|
||||
*/
|
||||
export class UpdateHarvestDto extends PartialType(CreateHarvestDto) {}
|
||||
export class UpdateHarvestDto extends PartialType(CreateHarvestDto, {
|
||||
skipNullProperties: false,
|
||||
}) {}
|
||||
@@ -13,6 +13,7 @@ describe('HarvestsService', () => {
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
|
||||
let tx: {
|
||||
harvest: { create: jest.Mock; update: jest.Mock };
|
||||
harvestColony: { createMany: jest.Mock; deleteMany: jest.Mock };
|
||||
@@ -41,7 +42,7 @@ describe('HarvestsService', () => {
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
service = new HarvestsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
@@ -119,6 +120,14 @@ describe('HarvestsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('update re-enqueues the calendar sync so a changed date moves the event', async () => {
|
||||
prisma.harvest.findUnique.mockResolvedValue(harvest);
|
||||
|
||||
await service.update('harvest-1', { date: '2026-07-02' }, 'user-1');
|
||||
|
||||
expect(calendarSync.enqueue).toHaveBeenCalledWith('HARVEST', 'harvest-1');
|
||||
});
|
||||
|
||||
it('update replaces the whole colony set when colonyIds is given', async () => {
|
||||
prisma.harvest.findUnique.mockResolvedValue(harvest);
|
||||
|
||||
|
||||
@@ -142,6 +142,9 @@ export class HarvestsService {
|
||||
beforeData: before,
|
||||
afterData: harvest,
|
||||
});
|
||||
// A changed date must move the calendar event, not leave the old one
|
||||
// behind.
|
||||
await this.calendarSync.enqueue('HARVEST', harvest.id);
|
||||
return harvest;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { HealthController } from './health.controller';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@@ -22,9 +23,37 @@ describe('HealthController', () => {
|
||||
});
|
||||
|
||||
it('readiness throws ServiceUnavailableException when the database is unreachable', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
prisma.$queryRaw.mockRejectedValueOnce(new Error('connection refused'));
|
||||
await expect(controller.readiness()).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
// Prisma messages can quote connection details or query values - only
|
||||
// name, code and model reach the log.
|
||||
it('logs a failed readiness check without the Prisma message', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
prisma.$queryRaw.mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientInitializationError(
|
||||
'Can not reach database server at secret-host:5432',
|
||||
'7.9.1',
|
||||
),
|
||||
);
|
||||
|
||||
await expect(controller.readiness()).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
|
||||
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
|
||||
expect(message).toContain('PrismaClientInitializationError');
|
||||
expect(message).not.toContain('secret-host');
|
||||
expect(stack).toBeUndefined();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
|
||||
const READINESS_QUERY_TIMEOUT_MS = 5000;
|
||||
|
||||
@@ -48,7 +49,9 @@ export class HealthController {
|
||||
);
|
||||
return { status: 'ok' };
|
||||
} catch (error: unknown) {
|
||||
this.logger.error('Readiness check failed', error);
|
||||
// Prisma errors by name/code/model only (see loggableError).
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(`Readiness check failed: ${message}`, stack);
|
||||
throw new ServiceUnavailableException('Database not reachable');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class CreateHoneyBatchDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Wird automatisch generiert, falls die Automatisierung "Chargennummer-Format" aktiv ist',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
batchNumber?: string;
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { HoneyBatchStatus } from '../../../generated/prisma';
|
||||
import { CreateHoneyBatchDto } from './create-honey-batch.dto';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
/**
|
||||
* `storageContainerId` is editable here too, not only through
|
||||
* POST /honey-batches/:id/relocate: a batch recorded in the wrong container is
|
||||
* a data-entry error and must be correctable. The service writes the same
|
||||
* HoneyBatchRelocation entry the relocate endpoint writes.
|
||||
* HoneyBatchRelocation entry the relocate endpoint writes. `null` removes the
|
||||
* container assignment; that is no move, so only the audit entry records it.
|
||||
*/
|
||||
class UpdatableHoneyBatchFieldsDto extends PartialType(
|
||||
PickType(CreateHoneyBatchDto, [
|
||||
@@ -18,6 +20,7 @@ class UpdatableHoneyBatchFieldsDto extends PartialType(
|
||||
'waterContent',
|
||||
'storageContainerId',
|
||||
] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
|
||||
export class UpdateHoneyBatchDto extends UpdatableHoneyBatchFieldsDto {
|
||||
@@ -27,7 +30,7 @@ export class UpdateHoneyBatchDto extends UpdatableHoneyBatchFieldsDto {
|
||||
* undo a status that was set by mistake.
|
||||
*/
|
||||
@ApiPropertyOptional({ enum: HoneyBatchStatus })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(HoneyBatchStatus)
|
||||
status?: HoneyBatchStatus;
|
||||
}
|
||||
@@ -8,4 +8,5 @@ import { CreateHoneyMeasurementDto } from './create-honey-measurement.dto';
|
||||
*/
|
||||
export class UpdateHoneyMeasurementDto extends PartialType(
|
||||
CreateHoneyMeasurementDto,
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -395,6 +395,32 @@ describe('HoneyBatchesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the container assignment on null without a relocation', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({
|
||||
...honeyBatch,
|
||||
storageContainerId: 'container-1',
|
||||
});
|
||||
|
||||
await service.update(
|
||||
'batch-1',
|
||||
{ storageContainerId: null as never },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[updateArg]] = prisma.honeyBatch.update.mock.calls as [
|
||||
[{ data: { storageContainerId?: string | null } }],
|
||||
];
|
||||
expect(updateArg.data.storageContainerId).toBeNull();
|
||||
expect(prisma.honeyBatchRelocation.create).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HoneyBatch',
|
||||
entityId: 'batch-1',
|
||||
action: 'UPDATE',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records no relocation when an edit leaves the container untouched', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({
|
||||
...honeyBatch,
|
||||
|
||||
@@ -87,9 +87,15 @@ export class HoneyBatchesService {
|
||||
|
||||
async update(id: string, dto: UpdateHoneyBatchDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
const movesContainer =
|
||||
dto.storageContainerId !== undefined &&
|
||||
dto.storageContainerId !== before.storageContainerId;
|
||||
// A relocation is a move into a container. null only removes the
|
||||
// assignment - a batch may stand in no container, as it may be created
|
||||
// without one - so it is recorded by the audit entry below, not as a
|
||||
// relocation (which always needs a target container).
|
||||
const movedToContainerId =
|
||||
typeof dto.storageContainerId === 'string' &&
|
||||
dto.storageContainerId !== before.storageContainerId
|
||||
? dto.storageContainerId
|
||||
: null;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.honeyBatch.update({
|
||||
@@ -99,12 +105,12 @@ export class HoneyBatchesService {
|
||||
// Correcting the container has to leave the same trace a deliberate
|
||||
// relocation does - otherwise the batch's storage history would skip a
|
||||
// container it was recorded in.
|
||||
if (movesContainer) {
|
||||
if (movedToContainerId) {
|
||||
await tx.honeyBatchRelocation.create({
|
||||
data: {
|
||||
honeyBatchId: id,
|
||||
fromStorageContainerId: before.storageContainerId,
|
||||
toStorageContainerId: dto.storageContainerId!,
|
||||
toStorageContainerId: movedToContainerId,
|
||||
userId: actingUserId,
|
||||
notes: 'Korrektur über Honigcharge bearbeiten',
|
||||
},
|
||||
|
||||
@@ -6,4 +6,6 @@ import { CreateInspectionDto } from './create-inspection.dto';
|
||||
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||
* repair the colony's history without deleting the record.
|
||||
*/
|
||||
export class UpdateInspectionDto extends PartialType(CreateInspectionDto) {}
|
||||
export class UpdateInspectionDto extends PartialType(CreateInspectionDto, {
|
||||
skipNullProperties: false,
|
||||
}) {}
|
||||
@@ -18,6 +18,7 @@ describe('InspectionsService', () => {
|
||||
task: { create: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
|
||||
|
||||
const inspection = {
|
||||
id: 'inspection-1',
|
||||
@@ -40,7 +41,7 @@ describe('InspectionsService', () => {
|
||||
evaluateInspection: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
service = new InspectionsService(
|
||||
prisma as unknown as PrismaService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
@@ -72,6 +73,23 @@ describe('InspectionsService', () => {
|
||||
expect(createArg.data.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('create writes an audit log entry with the created inspection', async () => {
|
||||
prisma.inspection.create.mockResolvedValue(inspection);
|
||||
|
||||
await service.create(
|
||||
{ colonyId: 'colony-1', date: '2026-01-01' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith({
|
||||
entityType: 'Inspection',
|
||||
entityId: 'inspection-1',
|
||||
action: 'CREATE',
|
||||
userId: 'user-1',
|
||||
afterData: inspection,
|
||||
});
|
||||
});
|
||||
|
||||
it('update writes an audit log entry with before and after data', async () => {
|
||||
prisma.inspection.findUnique.mockResolvedValue(inspection);
|
||||
const updated = { ...inspection, notes: 'geändert' };
|
||||
@@ -95,6 +113,18 @@ describe('InspectionsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('update re-enqueues the calendar sync so a changed date moves the event', async () => {
|
||||
prisma.inspection.findUnique.mockResolvedValue(inspection);
|
||||
prisma.inspection.update.mockResolvedValue(inspection);
|
||||
|
||||
await service.update('inspection-1', { date: '2026-05-02' }, 'user-1');
|
||||
|
||||
expect(calendarSync.enqueue).toHaveBeenCalledWith(
|
||||
'INSPECTION',
|
||||
'inspection-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('createTaskFromInspection links the task to the inspection and its colony', async () => {
|
||||
prisma.inspection.findUnique.mockResolvedValue(inspection);
|
||||
prisma.task.create.mockResolvedValue({ id: 'task-1' });
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Inspection, Task } from '../../generated/prisma';
|
||||
import { CreateInspectionDto } from './dto/create-inspection.dto';
|
||||
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
||||
import { UpdateInspectionDto } from './dto/update-inspection.dto';
|
||||
import { optionalDate } from '../common/patch-values';
|
||||
|
||||
@Injectable()
|
||||
export class InspectionsService {
|
||||
@@ -47,6 +48,13 @@ export class InspectionsService {
|
||||
const inspection = await this.prisma.inspection.create({
|
||||
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Inspection',
|
||||
entityId: inspection.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: inspection,
|
||||
});
|
||||
await this.webhooks.dispatch('inspection.created', inspection);
|
||||
await this.automation.evaluateInspection(inspection);
|
||||
await this.calendarSync.enqueue('INSPECTION', inspection.id);
|
||||
@@ -71,6 +79,9 @@ export class InspectionsService {
|
||||
beforeData: before,
|
||||
afterData: inspection,
|
||||
});
|
||||
// A changed date or colony must move the calendar event, not leave the
|
||||
// old one behind.
|
||||
await this.calendarSync.enqueue('INSPECTION', inspection.id);
|
||||
return inspection;
|
||||
}
|
||||
|
||||
@@ -85,7 +96,7 @@ export class InspectionsService {
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
priority: dto.priority,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
||||
dueDate: optionalDate(dto.dueDate),
|
||||
assignedUserId: dto.assignedUserId,
|
||||
createdByUserId: actingUserId,
|
||||
relatedType: 'COLONY',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { LabelElementDto } from './label-element.dto';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class CreateLabelTemplateDto {
|
||||
@ApiProperty()
|
||||
@@ -26,13 +27,13 @@ export class CreateLabelTemplateDto {
|
||||
elements!: LabelElementDto[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsNumber()
|
||||
@Min(10)
|
||||
widthMm?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsNumber()
|
||||
@Min(10)
|
||||
heightMm?: number;
|
||||
@@ -55,25 +56,25 @@ export class CreateLabelTemplateDto {
|
||||
rows?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
marginTopMm?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
marginLeftMm?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
gapXMm?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
gapYMm?: number;
|
||||
@@ -82,12 +83,12 @@ export class CreateLabelTemplateDto {
|
||||
description:
|
||||
'Schnittmarken im Blattrand drucken (zum Schneiden mit dem Papierschneider)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
cropMarks?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
}
|
||||
@@ -3,4 +3,5 @@ import { CreateLabelTemplateDto } from './create-label-template.dto';
|
||||
|
||||
export class UpdateLabelTemplateDto extends PartialType(
|
||||
CreateLabelTemplateDto,
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
/**
|
||||
* Corrects a production batch entered with a typo. The article is fixed once
|
||||
@@ -17,19 +18,19 @@ import {
|
||||
*/
|
||||
export class UpdateProductionBatchDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
lotNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsDateString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Hergestellte Stückzahl' })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
quantity?: number;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
import { RawMaterialKind } from '../../generated/prisma';
|
||||
import { optionalDate } from '../common/patch-values';
|
||||
import { CreateProductionBatchDto } from './dto/create-production-batch.dto';
|
||||
import { UpdateProductionBatchDto } from './dto/update-production-batch.dto';
|
||||
|
||||
@@ -112,9 +113,7 @@ export class ProductionBatchesService {
|
||||
lotNumber,
|
||||
date: new Date(dto.date),
|
||||
quantity: dto.quantity,
|
||||
bestBeforeDate: dto.bestBeforeDate
|
||||
? new Date(dto.bestBeforeDate)
|
||||
: undefined,
|
||||
bestBeforeDate: optionalDate(dto.bestBeforeDate),
|
||||
notes: dto.notes,
|
||||
userId: actingUserId,
|
||||
},
|
||||
@@ -177,12 +176,7 @@ export class ProductionBatchesService {
|
||||
lotNumber: dto.lotNumber,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
quantity: dto.quantity,
|
||||
bestBeforeDate:
|
||||
dto.bestBeforeDate === undefined
|
||||
? undefined
|
||||
: dto.bestBeforeDate === null
|
||||
? null
|
||||
: new Date(dto.bestBeforeDate),
|
||||
bestBeforeDate: optionalDate(dto.bestBeforeDate),
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import { ProductKind, ShopCategory } from '../../../generated/prisma';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class CreateProductDto {
|
||||
@ApiProperty()
|
||||
@@ -37,12 +38,12 @@ export class CreateProductDto {
|
||||
description:
|
||||
'BOTTLED_HONEY: Bestand je Abfüllung; MANUFACTURED: Bestand je Herstellungscharge',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(ProductKind)
|
||||
kind?: ProductKind;
|
||||
|
||||
@ApiPropertyOptional({ enum: ShopCategory })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(ShopCategory)
|
||||
shopCategory?: ShopCategory;
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { ProductStatus } from '../../../generated/prisma';
|
||||
import { CreateProductDto } from './create-product.dto';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class UpdateProductDto extends PartialType(CreateProductDto) {
|
||||
export class UpdateProductDto extends PartialType(CreateProductDto, {
|
||||
skipNullProperties: false,
|
||||
}) {
|
||||
@ApiPropertyOptional({ enum: ProductStatus })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(ProductStatus)
|
||||
status?: ProductStatus;
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { NormalizeEmail } from '../../common/validation/normalize-email';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@NormalizeEmail()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@@ -7,4 +7,5 @@ import { CreateQueenEvaluationDto } from './create-queen-evaluation.dto';
|
||||
*/
|
||||
export class UpdateQueenEvaluationDto extends PartialType(
|
||||
CreateQueenEvaluationDto,
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { QueenStatus } from '../../../generated/prisma';
|
||||
import { CreateQueenDto } from './create-queen.dto';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class UpdateQueenDto extends PartialType(CreateQueenDto) {
|
||||
export class UpdateQueenDto extends PartialType(CreateQueenDto, {
|
||||
skipNullProperties: false,
|
||||
}) {
|
||||
@ApiPropertyOptional({ enum: QueenStatus })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(QueenStatus)
|
||||
status?: QueenStatus;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
/**
|
||||
* Corrects a raw material lot entered with a typo. The kind stays fixed -
|
||||
@@ -17,19 +18,19 @@ import {
|
||||
*/
|
||||
export class UpdateRawMaterialLotDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
lotNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsDateString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Gewonnene Menge in Gramm' })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
quantity?: number;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
PERMISSION_CATALOG,
|
||||
PermissionKey,
|
||||
} from '../../auth/permissions/permission-catalog';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
const CATALOG_KEYS = PERMISSION_CATALOG.map((entry) => entry.key);
|
||||
|
||||
@@ -21,14 +22,14 @@ export class UpdateRoleDto {
|
||||
* lookup of the ADMIN role by key intact.
|
||||
*/
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(50)
|
||||
key?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
@@ -41,7 +42,7 @@ export class UpdateRoleDto {
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CATALOG_KEYS, isArray: true })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsArray()
|
||||
@IsIn(CATALOG_KEYS, { each: true })
|
||||
permissionKeys?: PermissionKey[];
|
||||
|
||||
@@ -156,6 +156,30 @@ describe('StatisticsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiaries living colonies', () => {
|
||||
it('counts weak, conspicuous and queenless colonies as active', async () => {
|
||||
prisma.apiary.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'Wabenhain Nord',
|
||||
colonies: [
|
||||
{ id: 'c1', status: 'ACTIVE' },
|
||||
{ id: 'c2', status: 'WEAK' },
|
||||
{ id: 'c3', status: 'CONSPICUOUS' },
|
||||
{ id: 'c4', status: 'QUEENLESS' },
|
||||
{ id: 'c5', status: 'SOLD' },
|
||||
{ id: 'c6', status: 'MERGED' },
|
||||
],
|
||||
harvests: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const [result] = await service.apiaries();
|
||||
|
||||
expect(result.activeColonies).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('varroa', () => {
|
||||
it('returns measurements and treatment counts per colony', async () => {
|
||||
prisma.varroaMeasurement.findMany.mockResolvedValueOnce([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
|
||||
@Injectable()
|
||||
export class StatisticsService {
|
||||
@@ -125,8 +126,9 @@ export class StatisticsService {
|
||||
(sum, harvest) => sum + (harvest.netWeight ?? 0),
|
||||
0,
|
||||
);
|
||||
const activeColonies = apiary.colonies.filter(
|
||||
(c) => c.status === 'ACTIVE',
|
||||
// Weak, conspicuous and queenless colonies still stand there.
|
||||
const activeColonies = apiary.colonies.filter((c) =>
|
||||
isLivingColonyStatus(c.status),
|
||||
).length;
|
||||
const lostColonies = apiary.colonies.filter((c) =>
|
||||
['DECEASED', 'DISSOLVED'].includes(c.status),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
import { StockCategory } from '../../../generated/prisma';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class CreateStockItemDto {
|
||||
@ApiProperty()
|
||||
@@ -16,7 +17,7 @@ export class CreateStockItemDto {
|
||||
unit!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
quantity?: number;
|
||||
|
||||
@@ -7,4 +7,6 @@ import { CreateStockItemDto } from './create-stock-item.dto';
|
||||
* difference as an ADJUSTMENT movement, so the movement history still adds up
|
||||
* to the stored quantity and nothing is corrected behind its back.
|
||||
*/
|
||||
export class UpdateStockItemDto extends PartialType(CreateStockItemDto) {}
|
||||
export class UpdateStockItemDto extends PartialType(CreateStockItemDto, {
|
||||
skipNullProperties: false,
|
||||
}) {}
|
||||
@@ -8,4 +8,5 @@ export class UpdateStorageContainerDto extends PartialType(
|
||||
'capacity',
|
||||
'location',
|
||||
] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
import { SwarmControlAction, SwarmMood } from '../../../generated/prisma';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class CreateSwarmControlCheckDto {
|
||||
@ApiProperty()
|
||||
@@ -24,17 +25,17 @@ export class CreateSwarmControlCheckDto {
|
||||
queenCellsFound?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
queenCellsCapped?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: SwarmMood })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(SwarmMood)
|
||||
swarmMood?: SwarmMood;
|
||||
|
||||
@ApiPropertyOptional({ enum: SwarmControlAction })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(SwarmControlAction)
|
||||
actionTaken?: SwarmControlAction;
|
||||
|
||||
|
||||
@@ -8,4 +8,5 @@ import { CreateSwarmControlCheckDto } from './create-swarm-control-check.dto';
|
||||
*/
|
||||
export class UpdateSwarmControlCheckDto extends PartialType(
|
||||
CreateSwarmControlCheckDto,
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -102,6 +102,24 @@ describe('SwarmControlService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('update clears the next check date on null', async () => {
|
||||
prisma.swarmControlCheck.findUnique.mockResolvedValue({
|
||||
...check,
|
||||
nextCheckDate: new Date('2026-05-08T00:00:00.000Z'),
|
||||
});
|
||||
prisma.swarmControlCheck.update.mockResolvedValue(check);
|
||||
|
||||
await service.update('check-1', { nextCheckDate: null as never }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.swarmControlCheck.update.mock.calls as [
|
||||
[{ data: { nextCheckDate?: Date | null } }],
|
||||
];
|
||||
expect(updateArg.data.nextCheckDate).toBeNull();
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'UPDATE', entityId: 'check-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('findByColony orders results chronologically', async () => {
|
||||
prisma.swarmControlCheck.findMany.mockResolvedValue([check]);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { SwarmControlCheck } from '../../generated/prisma';
|
||||
import { optionalDate } from '../common/patch-values';
|
||||
import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto';
|
||||
import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto';
|
||||
|
||||
@@ -45,9 +46,7 @@ export class SwarmControlService {
|
||||
data: {
|
||||
...dto,
|
||||
date: new Date(dto.date),
|
||||
nextCheckDate: dto.nextCheckDate
|
||||
? new Date(dto.nextCheckDate)
|
||||
: undefined,
|
||||
nextCheckDate: optionalDate(dto.nextCheckDate),
|
||||
userId: actingUserId,
|
||||
},
|
||||
});
|
||||
@@ -73,9 +72,7 @@ export class SwarmControlService {
|
||||
data: {
|
||||
...dto,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
nextCheckDate: dto.nextCheckDate
|
||||
? new Date(dto.nextCheckDate)
|
||||
: undefined,
|
||||
nextCheckDate: optionalDate(dto.nextCheckDate),
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
TaskPriority,
|
||||
TaskTemplateRecurrenceType,
|
||||
} from '../../../generated/prisma';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class CreateTaskTemplateDto {
|
||||
@ApiProperty()
|
||||
@@ -16,7 +17,7 @@ export class CreateTaskTemplateDto {
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TaskPriority })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(TaskPriority)
|
||||
priority?: TaskPriority;
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { IsBoolean } from 'class-validator';
|
||||
import { CreateTaskTemplateDto } from './create-task-template.dto';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class UpdateTaskTemplateDto extends PartialType(CreateTaskTemplateDto) {
|
||||
export class UpdateTaskTemplateDto extends PartialType(CreateTaskTemplateDto, {
|
||||
skipNullProperties: false,
|
||||
}) {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -131,6 +131,59 @@ describe('TaskTemplatesService', () => {
|
||||
expect(updateArg.data.nextDueDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
describe('update with emptied recurrence fields', () => {
|
||||
it('validates the cleared value it writes instead of the stored one', async () => {
|
||||
prisma.taskTemplate.findUnique.mockResolvedValue(template);
|
||||
|
||||
await expect(
|
||||
service.update(
|
||||
'template-1',
|
||||
{ recurrenceDayOfMonth: null as never },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.taskTemplate.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes the merged recurrence when switching to an interval', async () => {
|
||||
prisma.taskTemplate.findUnique.mockResolvedValue(template);
|
||||
prisma.taskTemplate.update.mockResolvedValue(template);
|
||||
|
||||
await service.update(
|
||||
'template-1',
|
||||
{
|
||||
recurrenceType: 'INTERVAL_DAYS',
|
||||
intervalDays: 10,
|
||||
recurrenceDayOfMonth: null as never,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[updateArg]] = prisma.taskTemplate.update.mock.calls as [
|
||||
[
|
||||
{
|
||||
data: {
|
||||
recurrenceType: string;
|
||||
recurrenceMonth: number | null;
|
||||
recurrenceDayOfMonth: number | null;
|
||||
intervalDays: number | null;
|
||||
nextDueDate: Date;
|
||||
};
|
||||
},
|
||||
],
|
||||
];
|
||||
expect(updateArg.data).toEqual(
|
||||
expect.objectContaining({
|
||||
recurrenceType: 'INTERVAL_DAYS',
|
||||
recurrenceMonth: null,
|
||||
recurrenceDayOfMonth: null,
|
||||
intervalDays: 10,
|
||||
}),
|
||||
);
|
||||
expect(updateArg.data.nextDueDate).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
it('deactivate sets active to false', async () => {
|
||||
prisma.taskTemplate.findUnique.mockResolvedValue(template);
|
||||
prisma.taskTemplate.update.mockResolvedValue({
|
||||
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
RecurrenceInput,
|
||||
} from './recurrence';
|
||||
|
||||
function valueOrStored(
|
||||
value: number | null | undefined,
|
||||
stored: number | null,
|
||||
): number | null {
|
||||
return value === undefined ? stored : value;
|
||||
}
|
||||
|
||||
function toRecurrenceInput(input: {
|
||||
recurrenceType: TaskTemplate['recurrenceType'];
|
||||
recurrenceMonth?: number | null;
|
||||
@@ -84,22 +91,31 @@ export class TaskTemplatesService {
|
||||
dto.recurrenceDayOfMonth !== undefined ||
|
||||
dto.intervalDays !== undefined;
|
||||
|
||||
// The recurrence that is validated is exactly the one written: an
|
||||
// omitted field keeps its stored value, null clears it.
|
||||
const recurrence = recurrenceChanged
|
||||
? {
|
||||
recurrenceType: dto.recurrenceType ?? before.recurrenceType,
|
||||
recurrenceMonth: valueOrStored(
|
||||
dto.recurrenceMonth,
|
||||
before.recurrenceMonth,
|
||||
),
|
||||
recurrenceDayOfMonth: valueOrStored(
|
||||
dto.recurrenceDayOfMonth,
|
||||
before.recurrenceDayOfMonth,
|
||||
),
|
||||
intervalDays: valueOrStored(dto.intervalDays, before.intervalDays),
|
||||
}
|
||||
: undefined;
|
||||
let nextDueDate: Date | undefined;
|
||||
if (recurrenceChanged) {
|
||||
const recurrence = toRecurrenceInput({
|
||||
recurrenceType: dto.recurrenceType ?? before.recurrenceType,
|
||||
recurrenceMonth: dto.recurrenceMonth ?? before.recurrenceMonth,
|
||||
recurrenceDayOfMonth:
|
||||
dto.recurrenceDayOfMonth ?? before.recurrenceDayOfMonth,
|
||||
intervalDays: dto.intervalDays ?? before.intervalDays,
|
||||
});
|
||||
if (recurrence) {
|
||||
this.assertRecurrenceFieldsPresent(recurrence);
|
||||
nextDueDate = computeInitialDueDate(recurrence, new Date());
|
||||
}
|
||||
|
||||
const template = await this.prisma.taskTemplate.update({
|
||||
where: { id },
|
||||
data: { ...dto, nextDueDate },
|
||||
data: { ...dto, ...recurrence, nextDueDate },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'TaskTemplate',
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
TaskRelatedType,
|
||||
TaskStatus,
|
||||
} from '../../../generated/prisma';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class UpdateTaskDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@@ -18,7 +19,7 @@ export class UpdateTaskDto {
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TaskPriority })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(TaskPriority)
|
||||
priority?: TaskPriority;
|
||||
|
||||
@@ -33,12 +34,12 @@ export class UpdateTaskDto {
|
||||
assignedUserId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TaskStatus })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(TaskStatus)
|
||||
status?: TaskStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: TaskRelatedType })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(TaskRelatedType)
|
||||
relatedType?: TaskRelatedType;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('TasksService', () => {
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let taskTemplates: { generateDueTasks: jest.Mock };
|
||||
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
|
||||
|
||||
const task = { id: 'task-1', title: 'Beute prüfen', status: 'OPEN' };
|
||||
|
||||
@@ -35,7 +36,7 @@ describe('TasksService', () => {
|
||||
taskTemplates = {
|
||||
generateDueTasks: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
service = new TasksService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
@@ -85,4 +86,45 @@ describe('TasksService', () => {
|
||||
expect(updateArg.data.status).toBe('DONE');
|
||||
expect(updateArg.data.completedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
describe('update keeps the calendar entry in step', () => {
|
||||
const dueDate = new Date('2026-08-01');
|
||||
|
||||
it('re-enqueues an open task with a due date so a moved date moves the event', async () => {
|
||||
prisma.task.findUnique.mockResolvedValue({ ...task, dueDate });
|
||||
prisma.task.update.mockResolvedValue({ ...task, dueDate });
|
||||
|
||||
await service.update('task-1', { dueDate: '2026-08-01' }, 'user-1');
|
||||
|
||||
expect(calendarSync.enqueue).toHaveBeenCalledWith('TASK', 'task-1');
|
||||
expect(calendarSync.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the due date on null and removes the event', async () => {
|
||||
prisma.task.findUnique.mockResolvedValue({ ...task, dueDate });
|
||||
prisma.task.update.mockResolvedValue({ ...task, dueDate: null });
|
||||
|
||||
await service.update('task-1', { dueDate: null as never }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.task.update.mock.calls as [
|
||||
[{ data: { dueDate?: Date | null } }],
|
||||
];
|
||||
expect(updateArg.data.dueDate).toBeNull();
|
||||
expect(calendarSync.cancel).toHaveBeenCalledWith('TASK', 'task-1');
|
||||
expect(calendarSync.enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['DONE', 'CANCELLED'] as const)(
|
||||
'removes the event when the task is set to %s directly',
|
||||
async (status) => {
|
||||
prisma.task.findUnique.mockResolvedValue({ ...task, dueDate });
|
||||
prisma.task.update.mockResolvedValue({ ...task, dueDate, status });
|
||||
|
||||
await service.update('task-1', { status }, 'user-1');
|
||||
|
||||
expect(calendarSync.cancel).toHaveBeenCalledWith('TASK', 'task-1');
|
||||
expect(calendarSync.enqueue).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
import { Task, TaskPriority, TaskStatus } from '../../generated/prisma';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { optionalDate } from '../common/patch-values';
|
||||
|
||||
/**
|
||||
* Keeps completedAt in step with status when the status is edited directly:
|
||||
@@ -58,7 +59,7 @@ export class TasksService {
|
||||
const task = await this.prisma.task.create({
|
||||
data: {
|
||||
...dto,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
||||
dueDate: optionalDate(dto.dueDate),
|
||||
createdByUserId: actingUserId,
|
||||
},
|
||||
});
|
||||
@@ -86,7 +87,7 @@ export class TasksService {
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
||||
dueDate: optionalDate(dto.dueDate),
|
||||
completedAt: completedAtForStatusChange(before, dto.status),
|
||||
},
|
||||
});
|
||||
@@ -98,7 +99,9 @@ export class TasksService {
|
||||
beforeData: before,
|
||||
afterData: task,
|
||||
});
|
||||
if (task.dueDate) {
|
||||
// Like complete(): a finished or cancelled task leaves the calendar,
|
||||
// a reopened one returns to it.
|
||||
if (task.dueDate && task.status !== 'DONE' && task.status !== 'CANCELLED') {
|
||||
await this.calendarSync.enqueue('TASK', task.id);
|
||||
} else {
|
||||
await this.calendarSync.cancel('TASK', task.id);
|
||||
|
||||
@@ -6,4 +6,6 @@ import { CreateTreatmentDto } from './create-treatment.dto';
|
||||
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||
* repair the colony's history without deleting the record.
|
||||
*/
|
||||
export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto) {}
|
||||
export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto, {
|
||||
skipNullProperties: false,
|
||||
}) {}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import {
|
||||
MAX_WITHDRAWAL_STATUS_COLONIES,
|
||||
WithdrawalStatusQueryDto,
|
||||
} from './withdrawal-status-query.dto';
|
||||
|
||||
async function check(query: Record<string, unknown>) {
|
||||
const dto = plainToInstance(WithdrawalStatusQueryDto, query);
|
||||
const errors = await validate(dto, {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return {
|
||||
dto,
|
||||
messages: errors.flatMap((error) => Object.values(error.constraints ?? {})),
|
||||
};
|
||||
}
|
||||
|
||||
function cuidLike(index: number): string {
|
||||
return `cl${index.toString().padStart(23, '0')}`;
|
||||
}
|
||||
|
||||
describe('WithdrawalStatusQueryDto', () => {
|
||||
describe('colonyIds', () => {
|
||||
it('splits the comma-separated ids', async () => {
|
||||
const { dto, messages } = await check({ colonyIds: 'colonya,colonyb' });
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
expect(dto.colonyIds).toEqual(['colonya', 'colonyb']);
|
||||
});
|
||||
|
||||
it('drops empty segments', async () => {
|
||||
const { dto, messages } = await check({ colonyIds: 'colonya,,' });
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
expect(dto.colonyIds).toEqual(['colonya']);
|
||||
});
|
||||
|
||||
it('accepts the ids of a repeated query parameter', async () => {
|
||||
const { dto, messages } = await check({
|
||||
colonyIds: ['colonya', 'colonyb'],
|
||||
});
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
expect(dto.colonyIds).toEqual(['colonya', 'colonyb']);
|
||||
});
|
||||
|
||||
it(`accepts up to ${MAX_WITHDRAWAL_STATUS_COLONIES} ids`, async () => {
|
||||
const ids = Array.from(
|
||||
{ length: MAX_WITHDRAWAL_STATUS_COLONIES },
|
||||
(_, index) => cuidLike(index),
|
||||
);
|
||||
|
||||
const { messages } = await check({ colonyIds: ids.join(',') });
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects more ids than one request may ask for', async () => {
|
||||
const ids = Array.from(
|
||||
{ length: MAX_WITHDRAWAL_STATUS_COLONIES + 1 },
|
||||
(_, index) => cuidLike(index),
|
||||
);
|
||||
|
||||
const { messages } = await check({ colonyIds: ids.join(',') });
|
||||
|
||||
expect(messages).toEqual([
|
||||
`colonyIds darf höchstens ${MAX_WITHDRAWAL_STATUS_COLONIES} Volk-IDs enthalten`,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['too long', 'c'.repeat(65)],
|
||||
['with a space', 'colony a'],
|
||||
['with a quote', "colony'a"],
|
||||
['with a path', '../colony'],
|
||||
['with a semicolon', 'colony;drop'],
|
||||
])('rejects an id %s', async (_case, id) => {
|
||||
const { messages } = await check({ colonyIds: `colonya,${id}` });
|
||||
|
||||
expect(messages).toEqual(['colonyIds enthält eine ungültige Volk-ID']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
// A harvest covers the colonies of one apiary - far fewer than this. The cap
|
||||
// keeps a single request from turning into an unbounded IN (...) query.
|
||||
export const MAX_WITHDRAWAL_STATUS_COLONIES = 200;
|
||||
const MAX_COLONY_ID_LENGTH = 64;
|
||||
const COLONY_ID_MESSAGE = 'colonyIds enthält eine ungültige Volk-ID';
|
||||
|
||||
const AT_MESSAGE =
|
||||
'at muss ein Datum (JJJJ-MM-TT) oder ein ISO-8601-Zeitpunkt sein';
|
||||
|
||||
// "a,b" and the repeated form colonyIds=a&colonyIds=b both become a list;
|
||||
// empty segments ("a,,b", a trailing comma) carry no id and are dropped.
|
||||
function toColonyIdList({ value }: TransformFnParams): unknown {
|
||||
const parts: unknown =
|
||||
typeof value === 'string' ? value.split(',') : (value as unknown);
|
||||
return Array.isArray(parts) ? parts.filter((part) => part !== '') : parts;
|
||||
}
|
||||
|
||||
export class WithdrawalStatusQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: `Kommagetrennte Volk-IDs, höchstens ${MAX_WITHDRAWAL_STATUS_COLONIES}`,
|
||||
example: 'colony-a,colony-b',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toColonyIdList)
|
||||
@IsArray({ message: COLONY_ID_MESSAGE })
|
||||
@ArrayMaxSize(MAX_WITHDRAWAL_STATUS_COLONIES, {
|
||||
message: `colonyIds darf höchstens ${MAX_WITHDRAWAL_STATUS_COLONIES} Volk-IDs enthalten`,
|
||||
})
|
||||
@IsString({ each: true, message: COLONY_ID_MESSAGE })
|
||||
@MaxLength(MAX_COLONY_ID_LENGTH, { each: true, message: COLONY_ID_MESSAGE })
|
||||
// cuid charset (plus - and _): ids never need anything else.
|
||||
@Matches(/^[A-Za-z0-9_-]+$/, { each: true, message: COLONY_ID_MESSAGE })
|
||||
colonyIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Erntezeitpunkt, für den die Wartezeit geprüft wird (JJJJ-MM-TT oder ISO-8601-Zeitpunkt); ohne Angabe jetzt.',
|
||||
example: '2026-07-15',
|
||||
})
|
||||
@IsOptional()
|
||||
// ISO 8601 also admits forms Date cannot parse (20260715, 2026-07-15T10Z,
|
||||
// +02 offsets) and, without strict, impossible days like 2026-02-30. An
|
||||
// unparseable date would report every window as over, so only the
|
||||
// calendar-checked extended forms Date understands are accepted.
|
||||
@IsDateString(
|
||||
{ strict: true, strictSeparator: true },
|
||||
{ message: AT_MESSAGE },
|
||||
)
|
||||
@Matches(
|
||||
/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/,
|
||||
{ message: AT_MESSAGE },
|
||||
)
|
||||
at?: string;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateTreatmentDto } from './dto/create-treatment.dto';
|
||||
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
|
||||
import { WithdrawalStatusQueryDto } from './dto/withdrawal-status-query.dto';
|
||||
import { TreatmentsService } from './treatments.service';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
|
||||
@@ -28,11 +29,14 @@ export class TreatmentsController {
|
||||
return this.treatmentsService.findAll();
|
||||
}
|
||||
|
||||
/** Active withdrawal windows per colony at `at` (the harvest date), default now. */
|
||||
@RequirePermission('treatments:read')
|
||||
@Get('withdrawal-status')
|
||||
getWithdrawalStatus(@Query('colonyIds') colonyIds?: string) {
|
||||
const ids = colonyIds ? colonyIds.split(',').filter(Boolean) : [];
|
||||
return this.treatmentsService.getWithdrawalStatus(ids);
|
||||
getWithdrawalStatus(@Query() query: WithdrawalStatusQueryDto) {
|
||||
return this.treatmentsService.getWithdrawalStatus(
|
||||
query.colonyIds ?? [],
|
||||
query.at ? new Date(query.at) : new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('treatments:read')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { TreatmentsService } from './treatments.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -17,6 +17,7 @@ describe('TreatmentsService', () => {
|
||||
colony: { findUnique: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
|
||||
|
||||
const treatment = {
|
||||
id: 'treatment-1',
|
||||
@@ -37,7 +38,7 @@ describe('TreatmentsService', () => {
|
||||
},
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
service = new TreatmentsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
@@ -103,6 +104,39 @@ describe('TreatmentsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('update clears start and end date on null', async () => {
|
||||
prisma.treatment.findUnique.mockResolvedValue({
|
||||
...treatment,
|
||||
startDate: new Date('2026-07-01'),
|
||||
endDate: new Date('2026-07-14'),
|
||||
});
|
||||
prisma.treatment.update.mockResolvedValue(treatment);
|
||||
|
||||
await service.update(
|
||||
'treatment-1',
|
||||
{ startDate: null as never, endDate: null as never },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[updateArg]] = prisma.treatment.update.mock.calls as [
|
||||
[{ data: { startDate?: Date | null; endDate?: Date | null } }],
|
||||
];
|
||||
expect(updateArg.data.startDate).toBeNull();
|
||||
expect(updateArg.data.endDate).toBeNull();
|
||||
});
|
||||
|
||||
it('update re-enqueues the calendar sync so a changed date moves the event', async () => {
|
||||
prisma.treatment.findUnique.mockResolvedValue(treatment);
|
||||
prisma.treatment.update.mockResolvedValue(treatment);
|
||||
|
||||
await service.update('treatment-1', { date: '2026-05-02' }, 'user-1');
|
||||
|
||||
expect(calendarSync.enqueue).toHaveBeenCalledWith(
|
||||
'TREATMENT',
|
||||
'treatment-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('findByColony orders results chronologically', async () => {
|
||||
prisma.treatment.findMany.mockResolvedValue([treatment]);
|
||||
|
||||
@@ -125,19 +159,19 @@ describe('TreatmentsService', () => {
|
||||
|
||||
it('reports the treatment whose withdrawal window reaches furthest into the future', async () => {
|
||||
const now = Date.now();
|
||||
const inTenDays = new Date(now + 10 * 24 * 60 * 60 * 1000);
|
||||
const inFiveDays = new Date(now + 5 * 24 * 60 * 60 * 1000);
|
||||
const twoDaysAgo = new Date(now - 2 * 24 * 60 * 60 * 1000);
|
||||
const fiveDaysAgo = new Date(now - 5 * 24 * 60 * 60 * 1000);
|
||||
prisma.treatment.findMany.mockResolvedValue([
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: inTenDays,
|
||||
date: twoDaysAgo,
|
||||
endDate: null,
|
||||
withdrawalPeriodDays: 0,
|
||||
withdrawalPeriodDays: 7,
|
||||
product: 'Ameisensäure',
|
||||
},
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: inFiveDays,
|
||||
date: fiveDaysAgo,
|
||||
endDate: null,
|
||||
withdrawalPeriodDays: 21,
|
||||
product: 'Api Bioxal',
|
||||
@@ -167,5 +201,127 @@ describe('TreatmentsService', () => {
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a treatment that has not started yet', async () => {
|
||||
const inThreeDays = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);
|
||||
prisma.treatment.findMany.mockResolvedValue([
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: inThreeDays,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
withdrawalPeriodDays: 14,
|
||||
product: 'Ameisensäure',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getWithdrawalStatus(['colony-1']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
describe('evaluated for a harvest date', () => {
|
||||
// Ended 2026-07-01 with 14 days withdrawal: honey may be harvested
|
||||
// from 2026-07-15 on.
|
||||
const endedFirstOfJuly = {
|
||||
colonyId: 'colony-1',
|
||||
date: new Date('2026-06-20'),
|
||||
startDate: null,
|
||||
endDate: new Date('2026-07-01'),
|
||||
withdrawalPeriodDays: 14,
|
||||
product: 'Ameisensäure',
|
||||
};
|
||||
|
||||
it('reports a window that is still open on that date', async () => {
|
||||
prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]);
|
||||
|
||||
const result = await service.getWithdrawalStatus(
|
||||
['colony-1'],
|
||||
new Date('2026-07-10'),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
activeUntil: '2026-07-15T00:00:00.000Z',
|
||||
product: 'Ameisensäure',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits a window that has ended by that date', async () => {
|
||||
prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]);
|
||||
|
||||
const result = await service.getWithdrawalStatus(
|
||||
['colony-1'],
|
||||
new Date('2026-07-15'),
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a treatment that starts after that date', async () => {
|
||||
prisma.treatment.findMany.mockResolvedValue([
|
||||
{
|
||||
...endedFirstOfJuly,
|
||||
date: new Date('2026-07-16'),
|
||||
endDate: new Date('2026-07-20'),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getWithdrawalStatus(
|
||||
['colony-1'],
|
||||
new Date('2026-07-15'),
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts a treatment begun on that very day', async () => {
|
||||
prisma.treatment.findMany.mockResolvedValue([
|
||||
{ ...endedFirstOfJuly, date: new Date('2026-07-15'), endDate: null },
|
||||
]);
|
||||
|
||||
const result = await service.getWithdrawalStatus(
|
||||
['colony-1'],
|
||||
new Date('2026-07-15'),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('counts a multi-day treatment whose start lies before that date', async () => {
|
||||
prisma.treatment.findMany.mockResolvedValue([
|
||||
{
|
||||
...endedFirstOfJuly,
|
||||
date: new Date('2026-07-20'),
|
||||
startDate: new Date('2026-07-10'),
|
||||
endDate: new Date('2026-07-20'),
|
||||
withdrawalPeriodDays: 7,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getWithdrawalStatus(
|
||||
['colony-1'],
|
||||
new Date('2026-07-15'),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
activeUntil: '2026-07-27T00:00:00.000Z',
|
||||
product: 'Ameisensäure',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an unparseable date instead of reporting every window as over', async () => {
|
||||
prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]);
|
||||
|
||||
await expect(
|
||||
service.getWithdrawalStatus(['colony-1'], new Date('kein Datum')),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
import { Treatment } from '../../generated/prisma';
|
||||
import { optionalDate } from '../common/patch-values';
|
||||
import { CreateTreatmentDto } from './dto/create-treatment.dto';
|
||||
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
|
||||
|
||||
@@ -13,6 +18,16 @@ export interface WithdrawalStatus {
|
||||
product: string;
|
||||
}
|
||||
|
||||
// When a treatment began: the earlier of its date and the start of a
|
||||
// multi-day treatment - the earlier one errs on the side of withdrawal.
|
||||
function treatmentStart(
|
||||
treatment: Pick<Treatment, 'date' | 'startDate'>,
|
||||
): Date {
|
||||
return treatment.startDate && treatment.startDate < treatment.date
|
||||
? treatment.startDate
|
||||
: treatment.date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TreatmentsService {
|
||||
constructor(
|
||||
@@ -44,9 +59,19 @@ export class TreatmentsService {
|
||||
// For each colony, returns the treatment whose withdrawal window (its
|
||||
// end date, or start date if the treatment is still ongoing, plus its
|
||||
// withdrawalPeriodDays) reaches furthest into the future - only when
|
||||
// that window is still active. Colonies with no active window are
|
||||
// omitted rather than returned as null, since most colonies have none.
|
||||
async getWithdrawalStatus(colonyIds: string[]): Promise<WithdrawalStatus[]> {
|
||||
// that window is still active at `at`, the harvest date (default now).
|
||||
// A treatment begun after `at` has no bearing on honey taken at `at`.
|
||||
// Colonies with no active window are omitted rather than returned as
|
||||
// null, since most colonies have none.
|
||||
async getWithdrawalStatus(
|
||||
colonyIds: string[],
|
||||
at: Date = new Date(),
|
||||
): Promise<WithdrawalStatus[]> {
|
||||
// An invalid date compares false against every window and would report
|
||||
// a treated colony as harvestable - fail closed instead.
|
||||
if (Number.isNaN(at.getTime())) {
|
||||
throw new BadRequestException('Ungültiges Datum für die Wartezeit');
|
||||
}
|
||||
if (colonyIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -56,15 +81,17 @@ export class TreatmentsService {
|
||||
withdrawalPeriodDays: { not: null },
|
||||
},
|
||||
});
|
||||
const now = new Date();
|
||||
const activeByColony = new Map<string, WithdrawalStatus>();
|
||||
for (const treatment of treatments) {
|
||||
if (treatmentStart(treatment) > at) {
|
||||
continue;
|
||||
}
|
||||
const referenceDate = treatment.endDate ?? treatment.date;
|
||||
const activeUntil = new Date(referenceDate);
|
||||
activeUntil.setUTCDate(
|
||||
activeUntil.getUTCDate() + (treatment.withdrawalPeriodDays ?? 0),
|
||||
);
|
||||
if (activeUntil <= now) {
|
||||
if (activeUntil <= at) {
|
||||
continue;
|
||||
}
|
||||
const current = activeByColony.get(treatment.colonyId);
|
||||
@@ -97,8 +124,8 @@ export class TreatmentsService {
|
||||
// treatment for the Bestandsbuch, the colony may be moved later.
|
||||
apiaryId: colony.apiaryId,
|
||||
date: new Date(dto.date),
|
||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||
startDate: optionalDate(dto.startDate),
|
||||
endDate: optionalDate(dto.endDate),
|
||||
userId: actingUserId,
|
||||
},
|
||||
});
|
||||
@@ -134,8 +161,8 @@ export class TreatmentsService {
|
||||
...dto,
|
||||
apiaryId: movedColony ? movedColony.apiaryId : undefined,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||
startDate: optionalDate(dto.startDate),
|
||||
endDate: optionalDate(dto.endDate),
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
@@ -146,6 +173,9 @@ export class TreatmentsService {
|
||||
beforeData: before,
|
||||
afterData: treatment,
|
||||
});
|
||||
// A changed date, colony or product must move the calendar event, not
|
||||
// leave the old one behind.
|
||||
await this.calendarSync.enqueue('TREATMENT', treatment.id);
|
||||
return treatment;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
import { NormalizeEmail } from '../../common/validation/normalize-email';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@@ -16,13 +17,13 @@ export class UpdateUserDto {
|
||||
* unique constraint and surfaces as a 409.
|
||||
*/
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@NormalizeEmail()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Id of an active Role' })
|
||||
@IsOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
roleId?: string;
|
||||
|
||||
@@ -8,4 +8,5 @@ import { CreateVarroaMeasurementDto } from './create-varroa-measurement.dto';
|
||||
*/
|
||||
export class UpdateVarroaMeasurementDto extends PartialType(
|
||||
CreateVarroaMeasurementDto,
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -201,5 +201,143 @@ describe('VarroaService', () => {
|
||||
];
|
||||
expect(updateArg.data.calculatedValue).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('when an input of the derivation is corrected', () => {
|
||||
const naturalMiteFall = {
|
||||
...measurement,
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: 45,
|
||||
durationMinutes: 3 * 24 * 60,
|
||||
calculatedValue: 15,
|
||||
};
|
||||
|
||||
async function storedCalculatedValueAfter(
|
||||
before: object,
|
||||
dto: Parameters<VarroaService['update']>[1],
|
||||
): Promise<number | null | undefined> {
|
||||
prisma.varroaMeasurement.findUnique.mockResolvedValue(before);
|
||||
prisma.varroaMeasurement.update.mockResolvedValue(measurement);
|
||||
await service.update('varroa-1', dto, 'user-1');
|
||||
const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [
|
||||
[{ data: { calculatedValue?: number | null } }],
|
||||
];
|
||||
return updateArg.data.calculatedValue;
|
||||
}
|
||||
|
||||
it('recomputes mites/day when the count changes', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(naturalMiteFall, { miteCount: 60 }),
|
||||
).resolves.toBe(20);
|
||||
});
|
||||
|
||||
it('recomputes mites/day when the duration changes', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(naturalMiteFall, {
|
||||
durationMinutes: 5 * 24 * 60,
|
||||
}),
|
||||
).resolves.toBe(9);
|
||||
});
|
||||
|
||||
it('recomputes when the edit form echoes the stored rate back unchanged', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(naturalMiteFall, {
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: 60,
|
||||
durationMinutes: 3 * 24 * 60,
|
||||
calculatedValue: 15,
|
||||
}),
|
||||
).resolves.toBe(20);
|
||||
});
|
||||
|
||||
it('keeps a changed rate as a manual override', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(naturalMiteFall, {
|
||||
miteCount: 60,
|
||||
calculatedValue: 99,
|
||||
}),
|
||||
).resolves.toBe(99);
|
||||
});
|
||||
|
||||
it('keeps a manually entered rate on an edit that echoes all fields back', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(
|
||||
{ ...naturalMiteFall, calculatedValue: 99 },
|
||||
{
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: 45,
|
||||
durationMinutes: 3 * 24 * 60,
|
||||
calculatedValue: 99,
|
||||
notes: 'Windel sauber',
|
||||
},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears a rate that can no longer be derived', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(naturalMiteFall, { durationMinutes: 0 }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('clears the mites/day rate when the method changes to a non-time-based one', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(naturalMiteFall, {
|
||||
method: 'POWDERED_SUGAR',
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('derives the rate when the method changes to natural mite fall', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(
|
||||
{
|
||||
...naturalMiteFall,
|
||||
method: 'POWDERED_SUGAR',
|
||||
calculatedValue: 3,
|
||||
},
|
||||
{ method: 'NATURAL_MITE_FALL' },
|
||||
),
|
||||
).resolves.toBe(15);
|
||||
});
|
||||
|
||||
// @IsOptional lets an explicit null through the validation pipe.
|
||||
const clearedValue = { calculatedValue: null as unknown as number };
|
||||
|
||||
it('re-derives the rate when a natural mite fall value is cleared', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(
|
||||
{ ...naturalMiteFall, calculatedValue: 99 },
|
||||
clearedValue,
|
||||
),
|
||||
).resolves.toBe(15);
|
||||
});
|
||||
|
||||
it('clears the manual value of a non-time-based method on request', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(
|
||||
{
|
||||
...naturalMiteFall,
|
||||
method: 'POWDERED_SUGAR',
|
||||
calculatedValue: 3,
|
||||
},
|
||||
clearedValue,
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a manual value of a non-time-based method alone when its count changes', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(
|
||||
{
|
||||
...naturalMiteFall,
|
||||
method: 'POWDERED_SUGAR',
|
||||
durationMinutes: null,
|
||||
calculatedValue: 3,
|
||||
},
|
||||
{ miteCount: 12 },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { VarroaMeasurement } from '../../generated/prisma';
|
||||
import { VarroaMeasurement, VarroaMethod } from '../../generated/prisma';
|
||||
import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
|
||||
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
|
||||
|
||||
@@ -30,6 +30,56 @@ function computeNaturalMiteFallPerDay(
|
||||
return (miteCount / durationMinutes) * MINUTES_PER_DAY;
|
||||
}
|
||||
|
||||
function deriveCalculatedValue(
|
||||
method: VarroaMethod,
|
||||
miteCount: number | null | undefined,
|
||||
durationMinutes: number | null | undefined,
|
||||
): number | undefined {
|
||||
return method === 'NATURAL_MITE_FALL'
|
||||
? computeNaturalMiteFallPerDay(miteCount, durationMinutes)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// calculatedValue for an edit, undefined meaning "leave the stored value".
|
||||
// A value that differs from the stored one is a manual override and wins,
|
||||
// as on create. Otherwise - no value sent, or the edit form echoing the
|
||||
// stored one back - the value is derived again from the merged record
|
||||
// whenever an input of the derivation changed (the method, and count or
|
||||
// duration for a natural mite fall) or nothing is stored yet, exactly as
|
||||
// create would derive it: a corrected count must never leave a stale
|
||||
// mites/day rate (and threshold evaluation) behind. Unrelated edits keep
|
||||
// the stored value, which may have been entered by hand.
|
||||
function calculatedValueForUpdate(
|
||||
before: VarroaMeasurement,
|
||||
dto: UpdateVarroaMeasurementDto,
|
||||
): number | null | undefined {
|
||||
const sent = dto.calculatedValue;
|
||||
if (sent !== undefined && sent !== null && sent !== before.calculatedValue) {
|
||||
return sent;
|
||||
}
|
||||
const method = dto.method ?? before.method;
|
||||
const miteCount =
|
||||
dto.miteCount === undefined ? before.miteCount : dto.miteCount;
|
||||
const durationMinutes =
|
||||
dto.durationMinutes === undefined
|
||||
? before.durationMinutes
|
||||
: dto.durationMinutes;
|
||||
const derivationInputChanged =
|
||||
method !== before.method ||
|
||||
(method === 'NATURAL_MITE_FALL' &&
|
||||
(miteCount !== before.miteCount ||
|
||||
durationMinutes !== before.durationMinutes));
|
||||
// An explicit null asks for the value to be cleared - like a create
|
||||
// without a value, that means "derive it where a rule exists".
|
||||
const mustDerive =
|
||||
derivationInputChanged || sent === null || before.calculatedValue === null;
|
||||
if (!mustDerive) {
|
||||
return undefined;
|
||||
}
|
||||
// null clears a value the merged record no longer supports.
|
||||
return deriveCalculatedValue(method, miteCount, durationMinutes) ?? null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VarroaService {
|
||||
constructor(
|
||||
@@ -67,9 +117,7 @@ export class VarroaService {
|
||||
): Promise<VarroaMeasurement> {
|
||||
const calculatedValue =
|
||||
dto.calculatedValue ??
|
||||
(dto.method === 'NATURAL_MITE_FALL'
|
||||
? computeNaturalMiteFallPerDay(dto.miteCount, dto.durationMinutes)
|
||||
: undefined);
|
||||
deriveCalculatedValue(dto.method, dto.miteCount, dto.durationMinutes);
|
||||
const measurement = await this.prisma.varroaMeasurement.create({
|
||||
data: {
|
||||
...dto,
|
||||
@@ -95,20 +143,7 @@ export class VarroaService {
|
||||
actingUserId: string,
|
||||
): Promise<VarroaMeasurement> {
|
||||
const before = await this.findById(id);
|
||||
// Only auto-fill calculatedValue when nothing has set it yet - never
|
||||
// overwrite a value the user (or a prior auto-fill) already stored,
|
||||
// even if this edit only touches an unrelated field like notes.
|
||||
const shouldAutoCompute =
|
||||
dto.calculatedValue === undefined && before.calculatedValue === null;
|
||||
const method = dto.method ?? before.method;
|
||||
const calculatedValue =
|
||||
dto.calculatedValue ??
|
||||
(shouldAutoCompute && method === 'NATURAL_MITE_FALL'
|
||||
? computeNaturalMiteFallPerDay(
|
||||
dto.miteCount ?? before.miteCount,
|
||||
dto.durationMinutes ?? before.durationMinutes,
|
||||
)
|
||||
: undefined);
|
||||
const calculatedValue = calculatedValueForUpdate(before, dto);
|
||||
const measurement = await this.prisma.varroaMeasurement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user