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.
This commit is contained in:
Matthias G
2026-08-03 11:39:54 +02:00
parent c425102d87
commit 65346d0c09
10 changed files with 381 additions and 1 deletions
+2
View File
@@ -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/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle 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/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/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 COPY --chown=nextjs:nodejs entrypoint.sh ./entrypoint.sh
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
+18
View File
@@ -223,6 +223,24 @@ curl -s -X POST -H "Authorization: Bearer $CRON_SECRET" \
Antwort: `{ "published": [...], "meta": { "total": 0 } }`. 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 ## Werkzeuge
| Adresse | Inhalt | | Adresse | Inhalt |
+34
View File
@@ -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)
+8
View File
@@ -305,6 +305,14 @@ export default async function AdminApiPage() {
example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/media/file/<pfad>'} example={'curl -s -H "Authorization: Bearer $TOKEN" \\\n http://localhost:4700/api/v1/media/file/<pfad>'}
/> />
<ApiEndpoint
method="POST"
path="/api/v1/weekly-report"
auth="CRON_SECRET"
summary="Schickt alles der letzten sieben Tage gebündelt nach Slack. Mit dry=1 kommt die Nachricht zurück statt rauszugehen."
example={'curl -s -X POST -H "Authorization: Bearer $CRON_SECRET" \\\n "http://localhost:4700/api/v1/weekly-report?dry=1"'}
/>
<ApiEndpoint <ApiEndpoint
method="POST" method="POST"
path="/api/v1/publish-due" path="/api/v1/publish-due"
+54
View File
@@ -0,0 +1,54 @@
import { sendToSlack, slackWebhook } from '~/lib/slack'
import { buildWeeklyReport, reportDays } from '~/lib/weekly-report'
import { problem } from '~/lib/problem'
function allowed(request: Request): boolean {
const secret = process.env.CRON_SECRET?.trim()
if (!secret) {
return false
}
return request.headers.get('authorization') === `Bearer ${secret}`
}
export async function POST(request: Request) {
if (!allowed(request)) {
return problem(401, 'unauthorized')
}
const url = new URL(request.url)
const dry = url.searchParams.get('dry') === '1'
const wanted = Number(url.searchParams.get('days'))
const days = Number.isInteger(wanted) && wanted > 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 })
}
+20 -1
View File
@@ -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 { 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 { 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' import { visibleAudiences } from '~/domain/audience'
@@ -560,3 +560,22 @@ export async function loadPostNeighbours(args: {
return { previous: older[0], next: newer[0], more } return { previous: older[0], next: newer[0], more }
} }
export async function listPublishedBetween(args: {
scope: ViewerScope
from: Date
to: Date
}): Promise<PostListItem[]> {
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))
}
+16
View File
@@ -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': { '/api/v1/publish-due': {
post: { post: {
summary: 'Fällige Termine veröffentlichen', summary: 'Fällige Termine veröffentlichen',
+29
View File
@@ -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<SlackResult> {
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 }
}
+115
View File
@@ -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<string, ReportProject>()
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<WeeklyReport, 'message'>, 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<WeeklyReport> {
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) }
}
+85
View File
@@ -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> = {}): 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)
})
})