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:
@@ -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
|
||||
|
||||
|
||||
+18
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
@@ -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>'}
|
||||
/>
|
||||
|
||||
<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
|
||||
method="POST"
|
||||
path="/api/v1/publish-due"
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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<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))
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user