feat(nextcloud-status): erneute Prüfung nach Ausfall und Hinweis auf der Kachel
- Wiederholungsauftrag jede Minute für Clouds mit einem Fehlschlag älter als fünf Minuten - Neue Adresse setzt den Prüfstand zurück, der gemeldete Zustand bleibt - Kachel: Hinweis Prüfung fehlgeschlagen, Fehlercodes als lesbarer Text mit Tooltip Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { ALERT_MAIL_RETRY_MS, NextcloudAlertService } from './nextcloud-alert.service';
|
||||
import { buildNextcloudAlertMail } from './nextcloud-alert-mail';
|
||||
import type { NextcloudRating } from './nextcloud-rating';
|
||||
|
||||
const NOW = new Date('2026-10-02T12:30:00Z');
|
||||
@@ -313,4 +314,26 @@ describe('NextcloudAlertService Empfaenger (L-06)', () => {
|
||||
expect(ctx.prisma.user.findMany).not.toHaveBeenCalled();
|
||||
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rot -> gruen (z. B. nach korrigierter Adresse): Betreff "wieder in Ordnung" und Zeile "Aktueller Stand"', async () => {
|
||||
withSubscriber(ctx);
|
||||
let built: { subject: string; text: string } | null = null;
|
||||
ctx.mail.sendNextcloudAlertEmail.mockImplementation(
|
||||
async (_t: string, _to: string, input: Parameters<typeof buildNextcloudAlertMail>[0]) => {
|
||||
built = buildNextcloudAlertMail(input, 'https://tessera.example.invalid');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
await run2(ctx, { ...ROW, alertState: 'red' }, GREEN);
|
||||
expect(built).not.toBeNull();
|
||||
expect((built as unknown as { subject: string }).subject).toBe(
|
||||
'Nextcloud Kunde A: wieder in Ordnung',
|
||||
);
|
||||
expect((built as unknown as { text: string }).text).toContain('Aktueller Stand: Aktuell');
|
||||
});
|
||||
});
|
||||
|
||||
async function run2(ctx: ReturnType<typeof makeService>, row: typeof ROW, rating: NextcloudRating) {
|
||||
const result = await ctx.service.evaluateAfterCheck('t1', row, rating, NOW);
|
||||
await result.delivery;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RETRY_DELAY_MS } from './nextcloud-alert-rules';
|
||||
import {
|
||||
NEXTCLOUD_CRON,
|
||||
NEXTCLOUD_JOB_NAME,
|
||||
NEXTCLOUD_RETRY_CRON,
|
||||
NEXTCLOUD_RETRY_JOB_NAME,
|
||||
NextcloudStatusSchedulerService,
|
||||
} from './nextcloud-status-scheduler.service';
|
||||
|
||||
@@ -40,10 +43,22 @@ function makeScheduler(rows: { id: string; tenantId: string }[] = [], failFor: s
|
||||
}
|
||||
|
||||
describe('NextcloudStatusSchedulerService', () => {
|
||||
it('registriert genau einen stuendlichen Auftrag ohne Datenbankzugriff und startet ihn', async () => {
|
||||
const { registry, service, release, scheduler } = makeScheduler();
|
||||
it('registriert den stuendlichen Auftrag und die Wiederholung ohne Datenbankzugriff und startet beide', async () => {
|
||||
const { registry, service, release, scheduler, logSpy } = makeScheduler();
|
||||
await scheduler.onApplicationBootstrap();
|
||||
expect(registry.addCronJob).toHaveBeenCalledTimes(1);
|
||||
expect(registry.addCronJob).toHaveBeenCalledTimes(2);
|
||||
expect(registry.__jobs.has(NEXTCLOUD_RETRY_JOB_NAME)).toBe(true);
|
||||
expect(NEXTCLOUD_RETRY_JOB_NAME).toBe('nextcloud-status-retry');
|
||||
expect(NEXTCLOUD_RETRY_CRON).toBe('* * * * *');
|
||||
const retryJob = registry.__jobs.get(NEXTCLOUD_RETRY_JOB_NAME);
|
||||
expect(retryJob.cronTime.source).toBe(NEXTCLOUD_RETRY_CRON);
|
||||
expect(retryJob.isActive ?? retryJob.running).toBeTruthy();
|
||||
expect(
|
||||
logSpy.mock.calls.some((c) =>
|
||||
String(c[0]).includes('Nextcloud-Status retry job registered: * * * * *'),
|
||||
),
|
||||
).toBe(true);
|
||||
retryJob.stop();
|
||||
expect(registry.__jobs.has(NEXTCLOUD_JOB_NAME)).toBe(true);
|
||||
const job = registry.__jobs.get(NEXTCLOUD_JOB_NAME);
|
||||
expect(job.cronTime.source).toBe(NEXTCLOUD_CRON);
|
||||
@@ -108,4 +123,83 @@ describe('NextcloudStatusSchedulerService', () => {
|
||||
await scheduler.tick();
|
||||
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('retryTick', () => {
|
||||
const NOW = new Date('2026-10-02T12:10:00Z');
|
||||
|
||||
it('laedt nur Clouds mit Fehlschlag aelter als fuenf Minuten und prueft sie gebunden', async () => {
|
||||
const rows = [
|
||||
{ id: 'a', tenantId: 'tA' },
|
||||
{ id: 'b', tenantId: 'tB' },
|
||||
];
|
||||
const { scheduler, service, calls } = makeScheduler(rows);
|
||||
await scheduler.retryTick(NOW);
|
||||
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledWith({
|
||||
retryDueBefore: new Date(NOW.getTime() - RETRY_DELAY_MS),
|
||||
});
|
||||
expect(RETRY_DELAY_MS).toBe(5 * 60 * 1000);
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
['tA', 'a'],
|
||||
['tB', 'b'],
|
||||
]),
|
||||
);
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('prueft hoechstens vier gleichzeitig', async () => {
|
||||
const rows = Array.from({ length: 10 }, (_, i) => ({ id: `c${i}`, tenantId: 't' }));
|
||||
const { scheduler, service } = makeScheduler(rows);
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
service.checkInstance.mockImplementation(async () => {
|
||||
inFlight++;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
inFlight--;
|
||||
});
|
||||
await scheduler.retryTick(NOW);
|
||||
expect(service.checkInstance).toHaveBeenCalledTimes(10);
|
||||
expect(peak).toBeLessThanOrEqual(4);
|
||||
expect(peak).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('ueberspringt, solange der vorige Durchlauf laeuft; der stuendliche Durchlauf blockiert ihn nicht', async () => {
|
||||
const { scheduler, service, warnSpy } = makeScheduler([{ id: 'a', tenantId: 't' }]);
|
||||
let release!: () => void;
|
||||
service.checkInstance.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const first = scheduler.retryTick(NOW);
|
||||
await vi.waitFor(() => expect(service.checkInstance).toHaveBeenCalledTimes(1));
|
||||
await scheduler.retryTick(NOW);
|
||||
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
// der stuendliche Durchlauf hat einen eigenen Schutz
|
||||
await scheduler.tick();
|
||||
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(2);
|
||||
release();
|
||||
await first;
|
||||
});
|
||||
|
||||
it('wirft nie: ein Fehler beim Laden und ein Fehler je Cloud werden protokolliert', async () => {
|
||||
const { scheduler, service, errorSpy, calls } = makeScheduler(
|
||||
[
|
||||
{ id: 'x', tenantId: 't' },
|
||||
{ id: 'y', tenantId: 't' },
|
||||
],
|
||||
['x'],
|
||||
);
|
||||
await expect(scheduler.retryTick(NOW)).resolves.toBeUndefined();
|
||||
expect(calls.map((c) => c[1]).sort()).toEqual(['x', 'y']);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.loadAllInstancesForScheduler.mockRejectedValueOnce(new Error('db weg'));
|
||||
await expect(scheduler.retryTick(NOW)).resolves.toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { RETRY_DELAY_MS } from './nextcloud-alert-rules';
|
||||
import { NextcloudReleaseService } from './nextcloud-release.service';
|
||||
import {
|
||||
CHECK_CONCURRENCY,
|
||||
@@ -27,6 +28,10 @@ const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): voi
|
||||
export const NEXTCLOUD_JOB_NAME = 'nextcloud-status-poll';
|
||||
/** Jede volle Stunde (L-08). */
|
||||
export const NEXTCLOUD_CRON = '0 * * * *';
|
||||
/** Name des Wiederholungsauftrags (quick-261002-kxc, L-03). */
|
||||
export const NEXTCLOUD_RETRY_JOB_NAME = 'nextcloud-status-retry';
|
||||
/** Jede Minute: prueft nur Clouds, deren erster Fehlschlag fuenf Minuten zurueckliegt. */
|
||||
export const NEXTCLOUD_RETRY_CRON = '* * * * *';
|
||||
|
||||
/**
|
||||
* NextcloudStatusSchedulerService — stuendliche Pruefung aller Clouds
|
||||
@@ -50,6 +55,7 @@ export const NEXTCLOUD_CRON = '0 * * * *';
|
||||
export class NextcloudStatusSchedulerService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(NextcloudStatusSchedulerService.name);
|
||||
private running = false;
|
||||
private retryRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
@@ -75,6 +81,15 @@ export class NextcloudStatusSchedulerService implements OnApplicationBootstrap {
|
||||
this.schedulerRegistry.addCronJob(NEXTCLOUD_JOB_NAME, job as any);
|
||||
job.start();
|
||||
this.logger.log(`Nextcloud-Status cron job registered: ${NEXTCLOUD_CRON}`);
|
||||
const retryJob = new CronJobClass(NEXTCLOUD_RETRY_CRON, () => {
|
||||
this.retryTick().catch((err) =>
|
||||
this.logger.error(`Nextcloud retry tick failed: ${(err as Error).message}`),
|
||||
);
|
||||
});
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService
|
||||
this.schedulerRegistry.addCronJob(NEXTCLOUD_RETRY_JOB_NAME, retryJob as any);
|
||||
retryJob.start();
|
||||
this.logger.log(`Nextcloud-Status retry job registered: ${NEXTCLOUD_RETRY_CRON}`);
|
||||
void this.release.refresh().catch(() => undefined);
|
||||
} catch (err) {
|
||||
this.logger.error(`Nextcloud-Status scheduler init failed: ${(err as Error).message}`);
|
||||
@@ -89,29 +104,56 @@ export class NextcloudStatusSchedulerService implements OnApplicationBootstrap {
|
||||
}
|
||||
this.running = true;
|
||||
try {
|
||||
const rows = await this.service.loadAllInstancesForScheduler();
|
||||
// Je Mandant gruppiert, damit jede Pruefung an IHREN Mandanten gebunden bleibt.
|
||||
const byTenant = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
const ids = byTenant.get(row.tenantId) ?? [];
|
||||
ids.push(row.id);
|
||||
byTenant.set(row.tenantId, ids);
|
||||
}
|
||||
const work: { tenantId: string; id: string }[] = [];
|
||||
for (const [tenantId, ids] of byTenant) {
|
||||
for (const id of ids) work.push({ tenantId, id });
|
||||
}
|
||||
await runWithConcurrency(work, CHECK_CONCURRENCY, async ({ tenantId, id }) => {
|
||||
try {
|
||||
await this.service.checkInstance(tenantId, id);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Nextcloud check failed for instance ${id} (tenant ${tenantId}): ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
await this.checkRows(await this.service.loadAllInstancesForScheduler());
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiederholung: nur Clouds mit genau einem Fehlschlag, der mindestens
|
||||
* `RETRY_DELAY_MS` zurueckliegt. Wirft nie; ein laufender Durchlauf haelt
|
||||
* den naechsten an.
|
||||
*/
|
||||
async retryTick(now: Date = new Date()): Promise<void> {
|
||||
if (this.retryRunning) {
|
||||
this.logger.warn('Nextcloud retry tick skipped — previous run still active');
|
||||
return;
|
||||
}
|
||||
this.retryRunning = true;
|
||||
try {
|
||||
const rows = await this.service.loadAllInstancesForScheduler({
|
||||
retryDueBefore: new Date(now.getTime() - RETRY_DELAY_MS),
|
||||
});
|
||||
await this.checkRows(rows);
|
||||
} catch (err) {
|
||||
this.logger.error(`Nextcloud retry tick failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.retryRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Prueft jede Cloud an ihren eigenen Mandanten gebunden, hoechstens vier gleichzeitig. */
|
||||
private async checkRows(rows: { id: string; tenantId: string }[]): Promise<void> {
|
||||
// Je Mandant gruppiert, damit jede Pruefung an IHREN Mandanten gebunden bleibt.
|
||||
const byTenant = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
const ids = byTenant.get(row.tenantId) ?? [];
|
||||
ids.push(row.id);
|
||||
byTenant.set(row.tenantId, ids);
|
||||
}
|
||||
const work: { tenantId: string; id: string }[] = [];
|
||||
for (const [tenantId, ids] of byTenant) {
|
||||
for (const id of ids) work.push({ tenantId, id });
|
||||
}
|
||||
await runWithConcurrency(work, CHECK_CONCURRENCY, async ({ tenantId, id }) => {
|
||||
try {
|
||||
await this.service.checkInstance(tenantId, id);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Nextcloud check failed for instance ${id} (tenant ${tenantId}): ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,11 +271,25 @@ describe('NextcloudStatusService', () => {
|
||||
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateInstance: neue Adresse wird normalisiert und neu geprueft, unveraenderte nicht', async () => {
|
||||
it('updateInstance: neue Adresse wird normalisiert, setzt den Pruefstand zurueck (alertState bleibt) und prueft neu, unveraenderte nicht', async () => {
|
||||
await service.updateInstance('t1', 'i1', { baseUrl: 'https://neu.example.de/index.php/' });
|
||||
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
|
||||
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
|
||||
expect(data).toEqual({
|
||||
baseUrl: 'https://neu.example.de',
|
||||
reachable: null,
|
||||
maintenance: null,
|
||||
needsDbUpgrade: null,
|
||||
versionString: null,
|
||||
edition: null,
|
||||
productName: null,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
lastCheckedAt: null,
|
||||
consecutiveFailures: 0,
|
||||
firstFailureAt: null,
|
||||
});
|
||||
expect(data).not.toHaveProperty('alertState');
|
||||
expect(data).not.toHaveProperty('alertReason');
|
||||
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(fetchNextcloudStatus).mockClear();
|
||||
@@ -283,6 +297,20 @@ describe('NextcloudStatusService', () => {
|
||||
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateInstance: rote Cloud, korrigierte Adresse antwortet gruen -> Meldung "wieder in Ordnung" wird entschieden', async () => {
|
||||
prisma.nextcloudInstance.findFirst.mockResolvedValue({
|
||||
id: 'i1',
|
||||
baseUrl: 'https://kaputt.example.de',
|
||||
consecutiveFailures: 0,
|
||||
});
|
||||
prisma.nextcloudInstance.update.mockResolvedValue(makeRow({ alertState: 'red' }));
|
||||
await service.updateInstance('t1', 'i1', { baseUrl: 'https://cloud.a.de' });
|
||||
expect(alerts.evaluateAfterCheck).toHaveBeenCalledTimes(1);
|
||||
const [, row, rating] = alerts.evaluateAfterCheck.mock.calls[0];
|
||||
expect(row.alertState).toBe('red');
|
||||
expect(rating).toMatchObject({ level: 'green', reason: 'current' });
|
||||
});
|
||||
|
||||
it('updateInstance: ungueltige Adresse -> BadRequest', async () => {
|
||||
await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
@@ -435,5 +463,25 @@ describe('NextcloudStatusService', () => {
|
||||
});
|
||||
expect(rows).toEqual([{ id: 'i1', tenantId: 't1' }]);
|
||||
});
|
||||
|
||||
it('loadAllInstancesForScheduler mit retryDueBefore filtert auf genau einen Fehlschlag, Select bleibt Kennung und Mandant', async () => {
|
||||
prisma.nextcloudInstance.findMany.mockResolvedValue([{ id: 'i1', tenantId: 't1' }]);
|
||||
const due = new Date('2026-10-02T12:00:00Z');
|
||||
await service.loadAllInstancesForScheduler({ retryDueBefore: due });
|
||||
expect(prisma.nextcloudInstance.findMany).toHaveBeenCalledWith({
|
||||
where: { consecutiveFailures: 1, firstFailureAt: { lte: due } },
|
||||
select: { id: true, tenantId: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('pendingRetry ist genau bei einem Fehlschlag wahr', async () => {
|
||||
prisma.nextcloudInstance.findMany.mockResolvedValue([
|
||||
makeRow({ id: 'a', consecutiveFailures: 0 }),
|
||||
makeRow({ id: 'b', consecutiveFailures: 1 }),
|
||||
makeRow({ id: 'c', consecutiveFailures: 2 }),
|
||||
]);
|
||||
const result = await service.listForTenant('t1', 'u1');
|
||||
expect(result.instances.map((i) => i.status.pendingRetry)).toEqual([false, true, false]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ export const PUBLIC_SELECT = {
|
||||
edition: true,
|
||||
errorKind: true,
|
||||
errorDetail: true,
|
||||
consecutiveFailures: true,
|
||||
} as const;
|
||||
|
||||
type PublicRow = {
|
||||
@@ -56,6 +57,7 @@ type PublicRow = {
|
||||
edition: string | null;
|
||||
errorKind: string | null;
|
||||
errorDetail: string | null;
|
||||
consecutiveFailures: number;
|
||||
};
|
||||
|
||||
export interface NextcloudInstanceView {
|
||||
@@ -74,6 +76,11 @@ export interface NextcloudInstanceView {
|
||||
edition: string | null;
|
||||
errorKind: string | null;
|
||||
errorDetail: string | null;
|
||||
/**
|
||||
* Genau ein Fehlschlag in Folge: die Kachel zeigt den letzten guten Stand
|
||||
* mit Hinweis, die Wiederholung folgt in wenigen Minuten (L-03).
|
||||
*/
|
||||
pendingRetry: boolean;
|
||||
};
|
||||
rating: NextcloudRating;
|
||||
/**
|
||||
@@ -125,6 +132,7 @@ export class NextcloudStatusService {
|
||||
|
||||
private toView(row: PublicRow, reference: NextcloudReference | null): NextcloudInstanceView {
|
||||
const status = {
|
||||
pendingRetry: row.consecutiveFailures === 1,
|
||||
checkedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null,
|
||||
reachable: row.reachable,
|
||||
maintenance: row.maintenance,
|
||||
@@ -271,6 +279,22 @@ export class NextcloudStatusService {
|
||||
if (baseUrl !== existing.baseUrl) {
|
||||
data.baseUrl = baseUrl;
|
||||
urlChanged = true;
|
||||
// Neue Adresse: der alte Pruefstand gilt nicht mehr (D-K8). `alertState`
|
||||
// bleibt bewusst unberuehrt — wird eine kaputte Adresse korrigiert und
|
||||
// antwortet die neue, geht "wieder in Ordnung" an die Abonnenten.
|
||||
Object.assign(data, {
|
||||
reachable: null,
|
||||
maintenance: null,
|
||||
needsDbUpgrade: null,
|
||||
versionString: null,
|
||||
edition: null,
|
||||
productName: null,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
lastCheckedAt: null,
|
||||
consecutiveFailures: 0,
|
||||
firstFailureAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,11 +427,25 @@ export class NextcloudStatusService {
|
||||
* Leserecht ueber `system_read_policy ... FOR SELECT` der Migration
|
||||
* 20261002150000): nur Kennung und Mandant ALLER Clouds, nie Logo-Bytes oder
|
||||
* Adressen. Geprueft und geschrieben wird danach je Cloud an ihren eigenen
|
||||
* Mandanten gebunden (`checkInstance`).
|
||||
* Mandanten gebunden (`checkInstance`). Mit `retryDueBefore` (Wiederholung
|
||||
* nach dem ersten Fehlschlag, quick-261002-kxc) filtert dieselbe Abfrage auf
|
||||
* Clouds mit genau einem Fehlschlag, der vor diesem Zeitpunkt lag.
|
||||
*/
|
||||
async loadAllInstancesForScheduler(): Promise<{ id: string; tenantId: string }[]> {
|
||||
async loadAllInstancesForScheduler(filter?: {
|
||||
retryDueBefore: Date;
|
||||
}): Promise<{ id: string; tenantId: string }[]> {
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
return systemPrisma.nextcloudInstance.findMany({
|
||||
// Wiederholungsauftrag (D-K3): nur Clouds mit genau einem Fehlschlag, dessen
|
||||
// Zeitpunkt lange genug zurueckliegt — derselbe Systemlesezugriff, kein neuer.
|
||||
...(filter
|
||||
? {
|
||||
where: {
|
||||
consecutiveFailures: 1,
|
||||
firstFailureAt: { lte: filter.retryDueBefore },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
select: { id: true, tenantId: true },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user