/* eslint-disable @typescript-eslint/no-require-imports -- Exercise real actions with isolated request and mail boundaries. */ const { test, before, beforeEach, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const ts = require('typescript'); const { randomUUID } = require('node:crypto'); const { PrismaClient } = require('@prisma/client'); const React = require('react'); const { renderToStaticMarkup } = require('react-dom/server'); const { render } = require('@react-email/render'); let prisma, current, f, service, actions, load; const tenants = [], emails = [], afters = []; let failedEmail = null; const sender = async input => { emails.push(input); return input.to === failedEmail ? { success: false, error: 'synthetic failure' } : { success: true }; }; function loader(overrides = {}) { const cache = new Map(); function load(file) { file = path.resolve(file); if (cache.has(file)) return cache.get(file).exports; const mod = { exports: {} }; cache.set(file, mod); const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true }, fileName: file, }).outputText; const req = spec => { if (Object.hasOwn(overrides, spec)) return overrides[spec]; if (spec === 'server-only') return {}; if (spec === '@/lib/prisma') return { prisma }; if (spec === 'next/headers') return { headers: async () => new Headers() }; if (spec === 'next/server') return { after: callback => afters.push(callback) }; if (spec === 'next/cache') return { revalidatePath() {} }; if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } }; if (spec.startsWith('@/') || spec.startsWith('.')) { const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec); for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix); } return require(spec); }; vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports); return mod.exports; } return load; } const authBoundary = { requireKitaSession: async () => ({ user: current }), requireRole: async roles => { assert.ok(roles.includes(current.role)); return { user: current }; } }; before(async () => { const raw = process.env.TEST_DATABASE_URL; if (!raw) throw new Error('TEST_DATABASE_URL muss auf die separate lokale kita_announcements_test zeigen.'); const url = new URL(raw); assert.ok(['127.0.0.1','localhost'].includes(url.hostname)); assert.equal(url.pathname, '/kita_announcements_test'); prisma = new PrismaClient({ datasourceUrl: raw }); process.env.AUTH_URL = 'http://127.0.0.1:3014'; load = loader({ '@/lib/auth-utils': authBoundary, '@/lib/mail': { sendAppEmail: sender } }); service = load('src/lib/announcement-delivery.ts'); actions = load('src/actions/announcements.ts'); }); after(async () => { if (prisma) { await prisma.kita.deleteMany({ where: { id: { in: tenants } } }); await prisma.$disconnect(); } }); beforeEach(async () => { emails.length = 0; afters.length = 0; failedEmail = null; const kita = await prisma.kita.create({ data: { name: 'Mitteilungen Test', slug: randomUUID() } }); tenants.push(kita.id); const family = await prisma.family.create({ data: { kitaId: kita.id, name: 'Testhaushalt' } }); f = { kita, family }; const admin = await person('ADMIN', null); const parent = await person('ELTERN', family.id); f = { ...f, admin, parent }; current = admin; }); async function person(role = 'ELTERN', familyId = f.family.id, extra = {}) { return prisma.user.create({ data: { kitaId: f.kita.id, familyId, role, email: `${randomUUID()}@example.invalid`, firstName: 'Test', lastName: role, passwordHash: 'test', emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date(), ...extra } }); } function form(data) { const result = new FormData(); for (const [key, value] of Object.entries(data)) result.set(key, value); return result; } async function publication(sendEmail = true, audience = 'ELTERN') { assert.equal((await actions.createAnnouncement(form({ title: 'Sommerfest Aufbau', content: 'Am Dienstag treffen wir uns.', audience, sendEmail: sendEmail ? 'on' : 'off' }))).success, true); return prisma.announcement.findFirstOrThrow({ where: { kitaId: f.kita.id }, orderBy: { createdAt: 'desc' } }); } const jobs = id => prisma.announcementEmail.findMany({ where: { kitaId: f.kita.id, announcementId: id }, orderBy: { createdAt: 'asc' } }); const edit = (row, data = {}) => actions.updateAnnouncement(form({ id: row.id, revision: String(row.revision), title: row.title, content: 'Aktualisierung: Wir treffen uns am Mittwoch.', sendEmail: 'off', ...data })); const deliver = (now = new Date()) => service.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender, now); async function flush() { while (afters.length) await afters.shift()(); } test('Veröffentlichung speichert Versandaufträge vor der Antwort; after sendet einzeln', async () => { const row = await publication(); assert.equal(emails.length, 0); assert.equal((await jobs(row.id)).length, 1); await flush(); assert.equal(emails.length, 1); assert.equal(emails[0].to, f.parent.email); assert.ok((await jobs(row.id))[0].sentAt); }); test('Eine fehlgeschlagene Adresse blockiert andere Empfänger nicht', async () => { const partner = await person('KOORDINATOR'); const row = await publication(); failedEmail = f.parent.email; const result = await deliver(); assert.equal(result.sent, 1); assert.equal(result.failed, 1); assert.equal(emails.length, 2); assert.ok((await jobs(row.id)).find(job => job.userId === partner.id).sentAt); }); test('Wiederholung verwendet denselben Auftrag und lässt erfolgreiche Sendungen aus', async () => { await person(); const row = await publication(); failedEmail = f.parent.email; await deliver(); const first = emails.find(mail => mail.to === f.parent.email); failedEmail = null; assert.equal((await actions.retryAnnouncementEmails(row.id, 1)).queued, 1); await flush(); assert.equal(emails.length, 3); assert.equal(emails[2].idempotencyKey, first.idempotencyKey); assert.equal((await actions.retryAnnouncementEmails(row.id, 1)).queued, 0); assert.equal((await jobs(row.id)).filter(job => job.sentAt).length, 2); }); test('Automatische Wiederholung wartet bis zur gespeicherten Frist', async () => { const row = await publication(); failedEmail = f.parent.email; const now = new Date(); await deliver(now); const job = (await jobs(row.id))[0]; assert.ok(job.nextAttemptAt > now); failedEmail = null; assert.equal((await deliver(now)).sent, 0); assert.equal((await deliver(job.nextAttemptAt)).sent, 1); }); test('Parallele Worker versenden eine Veröffentlichung nur einmal', async () => { await publication(); await Promise.all([deliver(), deliver()]); assert.equal(emails.length, 1); }); test('Eine abgelaufene Reservierung wird nach einem Worker-Abbruch übernommen', async () => { const row = await publication(); await prisma.announcementEmail.update({ where: { id: (await jobs(row.id))[0].id }, data: { claimToken: 'aborted', leaseUntil: new Date(0) } }); assert.equal((await deliver()).sent, 1); }); test('Neue Fassung verwirft alte ungesendete Aufträge und setzt gelesen zurück', async () => { const row = await publication(); current = f.parent; assert.equal((await actions.markAnnouncementRead(row.id, 1)).success, true); current = f.admin; assert.equal((await edit(row)).changed, true); const updated = await prisma.announcement.findUniqueOrThrow({ where: { id: row.id } }); assert.equal(updated.revision, 2); assert.ok(updated.editedAt); assert.equal(updated.audience, row.audience); assert.equal(updated.authorId, row.authorId); assert.equal(await prisma.announcementRead.count({ where: { announcementId: row.id } }), 0); assert.ok((await jobs(row.id))[0].skippedAt); await flush(); assert.equal(emails.length, 0); }); test('Eine alte geöffnete Fassung darf eine Korrektur nicht als gelesen markieren', async () => { const row = await publication(false); await edit(row); current = f.parent; assert.ok((await actions.markAnnouncementRead(row.id, 1)).error); assert.equal(await prisma.announcementRead.count({ where: { announcementId: row.id } }), 0); assert.equal((await actions.markAnnouncementRead(row.id, 2)).success, true); }); test('Paralleles Lesen und Korrigieren hinterlässt keinen Lesestatus für den alten Text', async () => { const row = await publication(false); const reader = loader({ '@/lib/auth-utils': { requireKitaSession: async () => ({ user: f.parent }) } })('src/actions/announcements.ts'); const results = await Promise.all([reader.markAnnouncementRead(row.id, 1), edit(row)]); assert.equal(results[1].success, true); assert.equal(await prisma.announcementRead.count({ where: { announcementId: row.id } }), 0); }); test('Veraltete und parallele Bearbeitungen überschreiben keine neuere Korrektur', async () => { const row = await publication(false); const results = await Promise.all([edit(row), edit(row, { content: 'Eine andere Korrektur.' })]); assert.equal(results.filter(result => result.success).length, 1); assert.equal(results.filter(result => result.error).length, 1); assert.ok((await edit(row)).error); assert.equal((await prisma.announcement.findUniqueOrThrow({ where: { id: row.id } })).revision, 2); }); test('Unveränderter Text erhält Version und Lesestatus; Versand bleibt einmalig', async () => { const row = await publication(false); current = f.parent; await actions.markAnnouncementRead(row.id, 1); current = f.admin; const input = { content: row.content, sendEmail: 'on' }; assert.equal((await edit(row, input)).queued, 1); assert.equal((await edit(row, input)).queued, 0); assert.equal((await prisma.announcement.findUniqueOrThrow({ where: { id: row.id } })).revision, 1); assert.equal(await prisma.announcementRead.count({ where: { announcementId: row.id } }), 1); }); test('Korrektur-Mail ist ausdrücklich optional und versendet nur die aktuelle Fassung', async () => { const row = await publication(); await deliver(); assert.equal((await edit(row, { sendEmail: 'on' })).queued, 1); await flush(); assert.equal(emails.length, 2); assert.match(emails[1].subject, /^Aktualisierte/); assert.ok((await render(emails[1].react)).includes('Mittwoch')); assert.notEqual(emails[0].idempotencyKey, emails[1].idempotencyKey); }); test('Bereits laufender Versand verhindert Änderungen bis zum Abschluss', async () => { const row = await publication(); let release; let entered; const started = new Promise(resolve => { entered = resolve; }); const blocked = async input => { entered(); await new Promise(resolve => { release = resolve; }); emails.push(input); return { success: true }; }; const sending = service.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, blocked); await started; assert.match((await edit(row)).error, /läuft gerade/); release(); await sending; assert.equal((await edit(row)).success, true); }); test('Rollen- und Sessionänderung entzieht die Bearbeitungsberechtigung auch bei alter Session', async () => { const row = await publication(false); await prisma.user.update({ where: { id: f.admin.id }, data: { role: 'KOORDINATOR' } }); assert.ok((await edit(row)).error); await prisma.user.update({ where: { id: f.admin.id }, data: { role: 'ADMIN', sessionVersion: { increment: 1 } } }); assert.ok((await edit(row)).error); }); test('Fremde Kita kann weder bearbeiten noch Versand wiederholen oder als gelesen markieren', async () => { const row = await publication(); const other = await prisma.kita.create({ data: { name: 'Fremd', slug: randomUUID() } }); tenants.push(other.id); current = { ...f.admin, kitaId: other.id }; assert.ok((await edit(row)).error); assert.ok((await actions.retryAnnouncementEmails(row.id, 1)).error); assert.ok((await actions.markAnnouncementRead(row.id, 1)).error); }); test('Zusammengesetzte Fremdschlüssel verhindern fremde Nutzer und Beiträge', async () => { const row = await publication(false); const other = await prisma.kita.create({ data: { name: 'Fremd', slug: randomUUID() } }); tenants.push(other.id); const foreign = await prisma.user.create({ data: { kitaId: other.id, email: `${randomUUID()}@example.invalid`, firstName: 'Fremd', lastName: 'Test', passwordHash: 'test' } }); const base = { kitaId: f.kita.id, announcementId: row.id, userId: f.parent.id, revision: 1 }; await assert.rejects(prisma.announcementEmail.create({ data: { ...base, userId: foreign.id } }), error => error.code === 'P2003'); await assert.rejects(prisma.announcementEmail.create({ data: { ...base, kitaId: other.id, userId: foreign.id } }), error => error.code === 'P2003'); }); test('Empfängergruppe und aktive Konten bestimmen die vorbereiteten Aufträge', async () => { await person('ERZIEHER', null); await person('ELTERN', f.family.id, { emailVerifiedAt: null }); await person('ELTERN', f.family.id, { disabledAt: new Date() }); await person('ELTERN', f.family.id, { privacyPolicyAcceptedAt: null }); await person('ELTERN', f.family.id, { lockedUntil: new Date(Date.now() + 3600000) }); const parent = await publication(); assert.equal((await jobs(parent.id)).length, 1); const team = await publication(true, 'ERZIEHER'); assert.equal((await jobs(team.id)).length, 1); }); test('Berechtigung wird vor Wiederholung erneut geprüft', async () => { const row = await publication(); await prisma.user.update({ where: { id: f.parent.id }, data: { role: 'ERZIEHER' } }); assert.equal((await deliver()).skipped, 1); assert.equal(emails.length, 0); assert.ok((await jobs(row.id))[0].skippedAt); }); test('Deaktivierung und widerrufener Consent stoppen vorbereitete Sendungen', async () => { const second = await person(); await publication(); await prisma.user.update({ where: { id: f.parent.id }, data: { disabledAt: new Date() } }); await prisma.user.update({ where: { id: second.id }, data: { privacyPolicyAcceptedAt: null } }); assert.equal((await deliver()).skipped, 2); assert.equal(emails.length, 0); }); test('Fehlende App-Adresse bleibt als Fehler wiederholbar gespeichert', async () => { const row = await publication(); const saved = [process.env.AUTH_URL, process.env.NEXTAUTH_URL, process.env.NEXT_PUBLIC_SITE_URL]; try { delete process.env.AUTH_URL; delete process.env.NEXTAUTH_URL; delete process.env.NEXT_PUBLIC_SITE_URL; assert.equal((await deliver()).failed, 1); assert.ok((await jobs(row.id))[0].failed); assert.equal(emails.length, 0); } finally { ['AUTH_URL','NEXTAUTH_URL','NEXT_PUBLIC_SITE_URL'].forEach((key, i) => { if (saved[i] === undefined) delete process.env[key]; else process.env[key] = saved[i]; }); } }); test('Der begrenzte Batch wird in weiteren Läufen vollständig abgearbeitet', async () => { for (let i = 0; i < 14; i++) await person(); await publication(); assert.equal((await deliver()).sent, 12); assert.equal((await deliver()).sent, 3); assert.equal(emails.length, 15); }); test('Speicherfehler bei Versandaufträgen macht die Veröffentlichung vollständig rückgängig', async () => { const local = loader({ '@/lib/auth-utils': authBoundary, '@/lib/announcement-delivery': { queueAnnouncementEmails: async () => { throw new Error('synthetic failure'); } } })('src/actions/announcements.ts'); await assert.rejects(local.createAnnouncement(form({ title: 'Kein Teilzustand', content: 'Kein Teilzustand erlaubt.', audience: 'ELTERN', sendEmail: 'on' }))); assert.equal(await prisma.announcement.count({ where: { kitaId: f.kita.id } }), 0); }); test('Anhänge sperren Versand bis zum vollständigen Schreiben und bleiben bei Korrektur erhalten', async () => { let release; const blocked = new Promise(resolve => { release = resolve; }); const local = loader({ '@/lib/auth-utils': authBoundary, 'fs/promises': { mkdir: async () => {}, writeFile: async () => blocked, rm: async () => {} } })('src/actions/announcements.ts'); const data = form({ title: 'Mit Anhang', content: 'Ein Dokument mit Details.', audience: 'ELTERN', sendEmail: 'on' }); data.append('attachments', new File(['%PDF-1.4\ntest\n%%EOF'], 'info.pdf', { type: 'application/pdf' })); const creating = local.createAnnouncement(data); let row; for (let attempt = 0; attempt < 100; attempt++) { row = await prisma.announcement.findFirst({ where: { kitaId: f.kita.id }, include: { attachments: true } }); if (row) break; await new Promise(resolve => setTimeout(resolve, 10)); } assert.ok(row); assert.equal(row.emailReady, false); assert.equal((await deliver()).sent, 0); release(); assert.equal((await creating).success, true); assert.equal((await edit(row)).success, true); assert.equal(await prisma.attachment.count({ where: { announcementId: row.id } }), 1); assert.equal((await jobs(row.id))[0].skippedAt !== null, true); }); test('Fehler beim Schreiben des Anhangs entfernt auch vorbereitete E-Mails', async () => { const local = loader({ '@/lib/auth-utils': authBoundary, 'fs/promises': { mkdir: async () => {}, writeFile: async () => { throw new Error('synthetic disk failure'); }, rm: async () => {} } })('src/actions/announcements.ts'); const data = form({ title: 'Kaputter Anhang', content: 'Ein Dokument mit Details.', audience: 'ELTERN', sendEmail: 'on' }); data.append('attachments', new File(['%PDF-1.4\ntest\n%%EOF'], 'info.pdf', { type: 'application/pdf' })); assert.ok((await local.createAnnouncement(data)).error); assert.equal(await prisma.announcement.count({ where: { kitaId: f.kita.id } }), 0); assert.equal(await prisma.announcementEmail.count({ where: { kitaId: f.kita.id } }), 0); }); test('Verwaltungsübersicht bietet Suche und Pagination für ältere Beiträge', async () => { await prisma.announcement.createMany({ data: Array.from({ length: 14 }, (_, i) => ({ kitaId: f.kita.id, authorId: f.admin.id, title: `Meldung ${i}`, content: `Beschreibung ${i}`, createdAt: new Date(Date.now() - i * 1000) })) }); const pages = loader({ '@/lib/auth-utils': authBoundary, 'next/link': { __esModule: true, default: ({ href, children, ...props }) => React.createElement('a', { href, ...props }, children) }, './news-form': { NewsForm: () => null } }); const page = pages('src/app/dashboard/admin/news/page.tsx').default; const first = renderToStaticMarkup(await page({ searchParams: Promise.resolve({}) })); assert.ok(first.includes('Ältere Beiträge')); assert.equal((first.match(/Bearbeiten & Versand/g) || []).length, 12); const second = renderToStaticMarkup(await page({ searchParams: Promise.resolve({ page: '2' }) })); assert.equal((second.match(/Bearbeiten & Versand/g) || []).length, 2); assert.ok(second.includes('Neuere Beiträge')); const search = renderToStaticMarkup(await page({ searchParams: Promise.resolve({ q: 'Beschreibung 13' }) })); assert.ok(search.includes('Meldung 13')); assert.ok(!search.includes('Meldung 0')); assert.ok(search.includes('value="Beschreibung 13"')); }); test('Korrekturen älterer Beiträge erscheinen wieder vorne im ungelesenen Elternfeed', async () => { const row = await publication(false); await prisma.announcement.update({ where: { id: row.id }, data: { createdAt: new Date('2020-01-01'), updatedAt: new Date('2020-01-01') } }); await prisma.announcement.create({ data: { kitaId: f.kita.id, authorId: f.admin.id, title: 'Neue Meldung', content: 'Neuere Meldung zum Kitaalltag.', audience: 'ELTERN' } }); await edit(row); current = f.parent; const feed = await load('src/lib/announcements.ts').getAnnouncements({ user: f.parent }, { take: 3 }); assert.equal(feed[0].id, row.id); assert.equal(feed[0].revision, 2); assert.equal(feed[0].isUnread, true); assert.ok(feed[0].editedAt); }); test('Nach Abbruch vor Freigabe wird ein vollständig geschriebener Anhang wieder aufgenommen', async () => { const row = await publication(); await prisma.announcement.update({ where: { id: row.id }, data: { emailReady: false, createdAt: new Date(Date.now() - 600000), attachments: { create: { fileName: 'info.pdf', fileUrl: '/unused', fileType: 'application/pdf', sizeBytes: 15 } } } }); const recovered = loader({ 'node:fs/promises': { stat: async () => ({ size: 15 }) } })('src/lib/announcement-delivery.ts'); assert.equal((await recovered.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender)).sent, 1); assert.equal((await prisma.announcement.findUniqueOrThrow({ where: { id: row.id } })).emailReady, true); }); test('Teilweise geschriebene und fehlende Anhänge geben den Versand nicht frei', async () => { const row = await publication(); await prisma.announcement.update({ where: { id: row.id }, data: { emailReady: false, createdAt: new Date(Date.now() - 600000), attachments: { create: { fileName: 'info.pdf', fileUrl: '/unused', fileType: 'application/pdf', sizeBytes: 15 } } } }); const partial = loader({ 'node:fs/promises': { stat: async () => ({ size: 3 }) } })('src/lib/announcement-delivery.ts'); assert.equal((await partial.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender)).sent, 0); const missing = loader({ 'node:fs/promises': { stat: async () => { throw new Error('missing'); } } })('src/lib/announcement-delivery.ts'); assert.equal((await missing.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender)).sent, 0); assert.equal(emails.length, 0); }); test('Fehlende Anhänge anderer Beiträge verdrängen eine wiederherstellbare Veröffentlichung nicht', async () => { const old = new Date(Date.now() - 600000); for (let i = 0; i < 13; i++) await prisma.announcement.create({ data: { id: `${f.kita.id}-unfinished-${i}`, kitaId: f.kita.id, authorId: f.admin.id, title: 'Unfertig', content: 'Ein Anhang fehlt.', emailReady: false, createdAt: old, attachments: { create: { fileName: 'info.pdf', fileUrl: '/unused', fileType: 'application/pdf', sizeBytes: 15 } } } }); const row = await prisma.announcement.create({ data: { id: `${f.kita.id}-zz-complete`, kitaId: f.kita.id, authorId: f.admin.id, title: 'Fertig', content: 'Der Anhang ist vollständig.', audience: 'ELTERN', emailReady: false, createdAt: old, attachments: { create: { fileName: 'info.pdf', fileUrl: '/unused', fileType: 'application/pdf', sizeBytes: 15 } }, emails: { create: { kitaId: f.kita.id, userId: f.parent.id, revision: 1 } } }, include: { attachments: true } }); const recovered = loader({ 'node:fs/promises': { stat: async file => { if (file.endsWith(row.attachments[0].id)) return { size: 15 }; throw new Error('missing'); } } })('src/lib/announcement-delivery.ts'); assert.equal((await recovered.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, sender)).sent, 1); }); test('Adminrechte erhalten Haushalts- und Teammail-Zugehörigkeit; globale Admins sind keine Empfänger', async () => { const staff = await person('ADMIN', null); const educator = await prisma.educator.create({ data: { kitaId: f.kita.id, firstName: 'Test', lastName: 'Fachkraft' } }); await prisma.staffProfile.create({ data: { kitaId: f.kita.id, userId: staff.id, educatorId: educator.id, employmentStart: new Date('2020-01-01'), trackingStart: new Date('2020-01-01') } }); const parentAdmin = await person('ADMIN'); await person('SUPERADMIN'); const parentRow = await publication(true, 'ELTERN'); assert.deepEqual((await jobs(parentRow.id)).map(job => job.userId).sort(), [f.parent.id, parentAdmin.id].sort()); const staffRow = await publication(true, 'ERZIEHER'); assert.deepEqual((await jobs(staffRow.id)).map(job => job.userId), [staff.id]); await deliver(); assert.ok(emails.some(mail => mail.to === staff.email)); }); test('Drei parallele Worker verarbeiten viele Empfänger ohne Konflikte oder doppelte Sendungen', async () => { for (let i = 0; i < 23; i++) await person(); const row = await publication(); const slow = async input => { await new Promise(resolve => setTimeout(resolve, 10)); return sender(input); }; await Promise.all(Array.from({ length: 3 }, () => service.deliverDueAnnouncementEmails({ kitaId: f.kita.id }, slow))); await deliver(); await deliver(); assert.equal(emails.length, 24); assert.equal(new Set(emails.map(mail => mail.to)).size, 24); assert.equal((await jobs(row.id)).filter(job => job.sentAt).length, 24); });