From 65346d0c09bb587bea299a185d748285fc2934a2 Mon Sep 17 00:00:00 2001 From: Matthias G Date: Mon, 3 Aug 2026 11:39:54 +0200 Subject: [PATCH] Send a weekly digest to Slack POST /api/v1/weekly-report collects everything published in the last seven days, groups it by project and posts one message. dry=1 returns the message instead of sending it, no publications means no message, a missing webhook answers 501 instead of failing quietly. scripts/cron.mjs runs the due publishing first, then the digest. --- Dockerfile | 2 + docs/api.md | 18 ++++ scripts/cron.mjs | 34 ++++++++ src/app/admin/api/page.tsx | 8 ++ src/app/api/v1/weekly-report/route.ts | 54 ++++++++++++ src/data/repositories/archive.ts | 21 ++++- src/lib/openapi.ts | 16 ++++ src/lib/slack.ts | 29 +++++++ src/lib/weekly-report.ts | 115 ++++++++++++++++++++++++++ tests/lib/weekly-report.test.ts | 85 +++++++++++++++++++ 10 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 scripts/cron.mjs create mode 100644 src/app/api/v1/weekly-report/route.ts create mode 100644 src/lib/slack.ts create mode 100644 src/lib/weekly-report.ts create mode 100644 tests/lib/weekly-report.test.ts diff --git a/Dockerfile b/Dockerfile index 9bd248a..7628553 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,9 @@ RUN --mount=from=sharp,source=/out,target=/tmp/img \ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle COPY --from=builder --chown=nextjs:nodejs /app/docs/style-guide.md ./docs/style-guide.md +COPY --from=builder --chown=nextjs:nodejs /app/docs/skill.md ./docs/skill.md COPY --from=builder --chown=nextjs:nodejs /app/scripts/migrate.mjs ./scripts/migrate.mjs +COPY --from=builder --chown=nextjs:nodejs /app/scripts/cron.mjs ./scripts/cron.mjs COPY --chown=nextjs:nodejs entrypoint.sh ./entrypoint.sh RUN chmod +x entrypoint.sh diff --git a/docs/api.md b/docs/api.md index e05abf5..ee81172 100644 --- a/docs/api.md +++ b/docs/api.md @@ -223,6 +223,24 @@ curl -s -X POST -H "Authorization: Bearer $CRON_SECRET" \ Antwort: `{ "published": [...], "meta": { "total": 0 } }`. +### POST /api/v1/weekly-report + +Sammelt alles, was in den letzten sieben Tagen veröffentlicht wurde, gruppiert es nach Projekt und schickt es als eine Nachricht in den Slack-Kanal aus `SLACK_WEBHOOK_URL`. Verlangt ebenfalls `CRON_SECRET`. + +| Parameter | Wirkung | +|---|---| +| `dry=1` | baut die Nachricht und gibt sie zurück, ohne zu senden | +| `days` | anderes Fenster als sieben Tage, 1 bis 90 | + +```bash +curl -s -X POST -H "Authorization: Bearer $CRON_SECRET" \ + "http://localhost:4700/api/v1/weekly-report?dry=1" +``` + +Wurde nichts veröffentlicht, geht keine Nachricht raus und die Antwort sagt `nothing_published`. Fehlt der Webhook, kommt 501 statt stiller Stille. + +Im Container erledigt `node scripts/cron.mjs` beide Aufrufe hintereinander, erst fällige Termine, dann der Bericht. + ## Werkzeuge | Adresse | Inhalt | diff --git a/scripts/cron.mjs b/scripts/cron.mjs new file mode 100644 index 0000000..a52a434 --- /dev/null +++ b/scripts/cron.mjs @@ -0,0 +1,34 @@ +const base = process.env.CRON_BASE ?? `http://127.0.0.1:${process.env.PORT ?? 4700}` +const secret = process.env.CRON_SECRET?.trim() + +if (!secret) { + console.error('[cron] CRON_SECRET fehlt') + process.exit(1) +} + +const tasks = process.argv.slice(2) +const paths = tasks.length > 0 ? tasks : ['/api/v1/publish-due', '/api/v1/weekly-report'] + +let failed = false + +for (const path of paths) { + try { + const response = await fetch(`${base}${path}`, { + method: 'POST', + headers: { authorization: `Bearer ${secret}` }, + }) + + const body = await response.text() + + console.log(`[cron] ${path} ${response.status} ${body}`) + + if (!response.ok) { + failed = true + } + } catch (error) { + console.error(`[cron] ${path} fehlgeschlagen`, error) + failed = true + } +} + +process.exit(failed ? 1 : 0) diff --git a/src/app/admin/api/page.tsx b/src/app/admin/api/page.tsx index 9dee330..860d115 100644 --- a/src/app/admin/api/page.tsx +++ b/src/app/admin/api/page.tsx @@ -305,6 +305,14 @@ export default async function AdminApiPage() { example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/media/file/'} /> + + 0 && wanted <= 90 ? wanted : reportDays + const origin = process.env.BETTER_AUTH_URL ?? url.origin + + const report = await buildWeeklyReport({ origin, days }) + + const meta = { + from: report.from.toISOString(), + to: report.to.toISOString(), + total: report.total, + projects: report.projects.map(project => ({ slug: project.slug, count: project.items.length })), + } + + if (!report.message) { + return Response.json({ sent: false, reason: 'nothing_published', meta }) + } + + if (dry) { + return Response.json({ sent: false, reason: 'dry_run', meta, message: report.message }) + } + + if (!slackWebhook()) { + return problem(501, 'slack_not_configured', 'SLACK_WEBHOOK_URL fehlt.') + } + + const result = await sendToSlack(report.message) + + if (!result.ok) { + return problem(502, 'slack_rejected', result.detail ?? 'Slack hat die Nachricht abgelehnt.') + } + + return Response.json({ sent: true, meta }) +} diff --git a/src/data/repositories/archive.ts b/src/data/repositories/archive.ts index a6533e4..234288d 100644 --- a/src/data/repositories/archive.ts +++ b/src/data/repositories/archive.ts @@ -1,4 +1,4 @@ -import { and, asc, count, desc, eq, gt, ilike, inArray, isNotNull, lt, max, ne, or, sql, type SQL } from 'drizzle-orm' +import { and, asc, count, desc, eq, gt, gte, ilike, inArray, isNotNull, lt, max, ne, or, sql, type SQL } from 'drizzle-orm' import { db } from '../db' import { blocks, brands, media, postTags, postTypes, posts, projects, tags, type Block, type Brand, type Media, type MediaVariant, type Post, type Project } from '../schema' import { visibleAudiences } from '~/domain/audience' @@ -560,3 +560,22 @@ export async function loadPostNeighbours(args: { return { previous: older[0], next: newer[0], more } } + +export async function listPublishedBetween(args: { + scope: ViewerScope + from: Date + to: Date +}): Promise { + return db + .select(selection) + .from(posts) + .innerJoin(projects, eq(projects.id, posts.projectId)) + .innerJoin(postTypes, typeJoin) + .where(and( + ...visible(args.scope), + isNotNull(posts.publishAt), + gte(posts.publishAt, args.from), + lt(posts.publishAt, args.to), + )) + .orderBy(asc(projects.sort), asc(projects.name), desc(posts.publishAt), desc(posts.id)) +} diff --git a/src/lib/openapi.ts b/src/lib/openapi.ts index 2a3a10f..504ba83 100644 --- a/src/lib/openapi.ts +++ b/src/lib/openapi.ts @@ -376,6 +376,22 @@ export function openApiDocument(baseUrl: string) { }, }, }, + '/api/v1/weekly-report': { + post: { + summary: 'Wochenbericht nach Slack schicken', + description: 'Für eine Zeitsteuerung gedacht, nicht für Zugänge. Verlangt CRON_SECRET als Bearer-Token. Mit dry=1 kommt die Nachricht zurück statt rauszugehen.', + parameters: [ + { name: 'dry', in: 'query', schema: { type: 'string', enum: ['1'] } }, + { name: 'days', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 90, default: 7 } }, + ], + responses: { + '200': { description: 'Bericht verschickt oder zurückgegeben' }, + '501': { description: 'SLACK_WEBHOOK_URL fehlt' }, + '502': { description: 'Slack hat die Nachricht abgelehnt' }, + ...errors([401]), + }, + }, + }, '/api/v1/publish-due': { post: { summary: 'Fällige Termine veröffentlichen', diff --git a/src/lib/slack.ts b/src/lib/slack.ts new file mode 100644 index 0000000..92003f8 --- /dev/null +++ b/src/lib/slack.ts @@ -0,0 +1,29 @@ +import type { SlackMessage } from './weekly-report' + +export type SlackResult = + | { ok: true } + | { ok: false, reason: 'not_configured' | 'rejected', detail?: string } + +export function slackWebhook(): string | undefined { + const url = process.env.SLACK_WEBHOOK_URL?.trim() + + return url === '' ? undefined : url +} + +export async function sendToSlack(message: SlackMessage, webhook = slackWebhook()): Promise { + if (!webhook) { + return { ok: false, reason: 'not_configured' } + } + + const response = await fetch(webhook, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(message), + }) + + if (!response.ok) { + return { ok: false, reason: 'rejected', detail: `${response.status} ${await response.text()}` } + } + + return { ok: true } +} diff --git a/src/lib/weekly-report.ts b/src/lib/weekly-report.ts new file mode 100644 index 0000000..ec5cc1c --- /dev/null +++ b/src/lib/weekly-report.ts @@ -0,0 +1,115 @@ +import { listPublishedBetween } from '~/data/repositories/archive' +import { entryMark } from '~/components/ui/Plate' +import { postPath } from './routes' +import type { PostListItem } from '~/data/repositories/posts' +import type { ViewerScope } from '~/domain/types' + +export const reportDays = 7 + +export type ReportProject = { + slug: string + name: string + color: string + items: PostListItem[] +} + +export type WeeklyReport = { + from: Date + to: Date + total: number + projects: ReportProject[] + message: SlackMessage | null +} + +export type SlackMessage = { + text: string + blocks: unknown[] +} + +const dateFormat = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + +export function windowFor(now: Date, days = reportDays): { from: Date, to: Date } { + const to = new Date(now) + const from = new Date(to.getTime() - days * 24 * 60 * 60 * 1000) + + return { from, to } +} + +export function groupByProject(items: PostListItem[]): ReportProject[] { + const projects = new Map() + + for (const item of items) { + const known = projects.get(item.projectSlug) + + if (known) { + known.items.push(item) + continue + } + + projects.set(item.projectSlug, { + slug: item.projectSlug, + name: item.projectName, + color: item.projectColor, + items: [item], + }) + } + + return [...projects.values()] +} + +function line(item: PostListItem, origin: string): string { + const mark = entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number) + const url = `${origin}${postPath(item.projectSlug, item.slug)}` + + return `\`${mark}\` ${item.type.labelDe}: <${url}|${item.title}>` +} + +export function buildMessage(report: Omit, origin: string): SlackMessage | null { + if (report.total === 0) { + return null + } + + const period = `${dateFormat.format(report.from)} bis ${dateFormat.format(report.to)}` + const count = report.total === 1 ? '1 Eintrag' : `${report.total} Einträge` + const text = `Logbuch, ${period}: ${count}` + + const blocks: unknown[] = [ + { type: 'header', text: { type: 'plain_text', text: 'Logbuch' } }, + { + type: 'context', + elements: [{ type: 'mrkdwn', text: `${period} · ${count}` }], + }, + ] + + for (const project of report.projects) { + blocks.push({ type: 'divider' }) + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: [`*${project.name}*`, ...project.items.map(item => line(item, origin))].join('\n'), + }, + }) + } + + blocks.push({ + type: 'context', + elements: [{ type: 'mrkdwn', text: `<${origin}|Alles im Logbuch nachlesen>` }], + }) + + return { text, blocks } +} + +export async function buildWeeklyReport(args: { + origin: string + now?: Date + days?: number + scope?: ViewerScope +}): Promise { + const { from, to } = windowFor(args.now ?? new Date(), args.days ?? reportDays) + const items = await listPublishedBetween({ scope: args.scope ?? 'internal', from, to }) + const projects = groupByProject(items) + const base = { from, to, total: items.length, projects } + + return { ...base, message: buildMessage(base, args.origin) } +} diff --git a/tests/lib/weekly-report.test.ts b/tests/lib/weekly-report.test.ts new file mode 100644 index 0000000..376ce8b --- /dev/null +++ b/tests/lib/weekly-report.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { buildMessage, groupByProject, windowFor } from '~/lib/weekly-report' +import type { PostListItem } from '~/data/repositories/posts' + +function item(overrides: Partial = {}): PostListItem { + return { + id: 'c0ffee00-0000-4000-8000-000000000001', + slug: 'regel-engine', + title: 'Regel-Engine: Bedingung trifft Aktion', + teaser: null, + type: { id: 't1', key: 'feature', labelDe: 'Feature', labelEn: 'Feature', color: '#2b4a9b' }, + audience: 'internal', + publishAt: new Date('2026-07-30T09:00:00.000Z'), + authorName: 'Paul', + coverMediaId: null, + number: 142, + projectSlug: 'trakk', + projectName: 'Trakk', + projectCode: 'TRK', + projectColor: '#2e7d5b', + ...overrides, + } +} + +const origin = 'https://logbuch.nyo.de' + +describe('windowFor', () => { + it('spannt sieben Tage bis zum Aufruf', () => { + const { from, to } = windowFor(new Date('2026-08-03T06:00:00.000Z')) + + expect(to.toISOString()).toBe('2026-08-03T06:00:00.000Z') + expect(from.toISOString()).toBe('2026-07-27T06:00:00.000Z') + }) +}) + +describe('groupByProject', () => { + it('fasst Einträge je Projekt zusammen und behält die Reihenfolge', () => { + const groups = groupByProject([ + item(), + item({ id: 'b', slug: 'zwei', projectSlug: 'orbit', projectName: 'Orbit', projectCode: 'ORB' }), + item({ id: 'c', slug: 'drei', number: 143 }), + ]) + + expect(groups.map(group => group.slug)).toEqual(['trakk', 'orbit']) + expect(groups[0]!.items).toHaveLength(2) + }) +}) + +describe('buildMessage', () => { + const base = { + from: new Date('2026-07-27T06:00:00.000Z'), + to: new Date('2026-08-03T06:00:00.000Z'), + } + + it('bleibt still, wenn nichts veröffentlicht wurde', () => { + expect(buildMessage({ ...base, total: 0, projects: [] }, origin)).toBeNull() + }) + + it('nennt Zeitraum, Anzahl und jedes Projekt', () => { + const projects = groupByProject([item(), item({ id: 'b', slug: 'zwei', projectSlug: 'orbit', projectName: 'Orbit', projectCode: 'ORB' })]) + const message = buildMessage({ ...base, total: 2, projects }, origin) + + expect(message?.text).toBe('Logbuch, 27.07.2026 bis 03.08.2026: 2 Einträge') + + const dump = JSON.stringify(message?.blocks) + + expect(dump).toContain('27.07.2026 bis 03.08.2026') + expect(dump).toContain('*Trakk*') + expect(dump).toContain('*Orbit*') + expect(dump).toContain('TRK-0142') + expect(dump).toContain('https://logbuch.nyo.de/trakk/regel-engine') + }) + + it('zählt einen einzelnen Eintrag im Singular', () => { + const message = buildMessage({ ...base, total: 1, projects: groupByProject([item()]) }, origin) + + expect(message?.text).toContain('1 Eintrag') + }) + + it('verzichtet auf Emojis', () => { + const message = buildMessage({ ...base, total: 1, projects: groupByProject([item()]) }, origin) + + expect(JSON.stringify(message)).not.toMatch(/\p{Extended_Pictographic}/u) + }) +})