Manage the Slack digest in the admin

New settings area: the webhook lives in the database and is only shown
shortened afterwards, the environment variable stays as a fallback. A switch
decides whether internal entries travel, a button sends the digest right
away, and every run is recorded with period, count, projects, result and
who triggered it.
This commit is contained in:
Matthias G
2026-08-03 12:13:57 +02:00
parent 65346d0c09
commit 0fd6da62d1
19 changed files with 2663 additions and 31 deletions
+1 -1
View File
@@ -225,7 +225,7 @@ 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`.
Sammelt alles, was in den letzten sieben Tagen veröffentlicht wurde, gruppiert es nach Projekt und schickt es als eine Nachricht nach Slack. Den Webhook nimmt Logbuch aus der Verwaltung unter Einstellungen, sonst aus `SLACK_WEBHOOK_URL`. Verlangt ebenfalls `CRON_SECRET`. Jeder Lauf landet im Verlauf auf derselben Seite.
| Parameter | Wirkung |
|---|---|
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE "app_setting" (
"key" text PRIMARY KEY NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "report_run" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"from_at" timestamp with time zone NOT NULL,
"to_at" timestamp with time zone NOT NULL,
"total" integer NOT NULL,
"projects" jsonb DEFAULT '[]'::jsonb NOT NULL,
"sent" boolean DEFAULT false NOT NULL,
"trigger" text NOT NULL,
"detail" text
);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -78,6 +78,13 @@
"when": 1785579010161,
"tag": "0010_steady_wither",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1785751667390,
"tag": "0011_concerned_domino",
"breakpoints": true
}
]
}
+53 -3
View File
@@ -273,7 +273,8 @@
"clients": "Zugänge",
"manage": "Verwalten",
"postTypes": "Beitragsarten",
"api": "API"
"api": "API",
"settings": "Einstellungen"
},
"messages": {
"created": "Angelegt.",
@@ -283,7 +284,12 @@
"invalid": "Bitte prüfe die markierten Felder.",
"slugTaken": "Diesen Slug gibt es schon. Wähle einen anderen.",
"selfAdmin": "Das eigene Verwaltungsrecht kannst du nicht ändern. Das muss ein anderes Verwaltungskonto tun.",
"typeInUse": "Diese Art wird noch benutzt. Nimm stattdessen den Haken bei Aktiv heraus."
"typeInUse": "Diese Art wird noch benutzt. Nimm stattdessen den Haken bei Aktiv heraus.",
"slackSaved": "Die Slack-Einstellungen sind gespeichert.",
"reportSent": "Der Bericht ist raus.",
"reportEmpty": "In den letzten sieben Tagen wurde nichts veröffentlicht, deshalb ging keine Nachricht raus.",
"reportNoWebhook": "Es ist kein Webhook hinterlegt.",
"reportRejected": "Slack hat die Nachricht abgelehnt."
},
"errors": {
"nameRequired": "Trag einen Namen ein.",
@@ -696,7 +702,51 @@
"publishHint": "Kein Zugang darf veröffentlichen. Das bleibt an Konten gebunden."
},
"recentEntries": "Zuletzt bearbeitet",
"allEntries": "Alle Beiträge"
"allEntries": "Alle Beiträge",
"settings": {
"eyebrow": "Verwaltung",
"title": "Einstellungen",
"intro": "Slack-Bericht und was er enthält. Der Zeitplan liegt in Coolify, nicht hier.",
"slack": "Slack",
"save": "Speichern",
"saving": "Wird gespeichert",
"sendNow": "Bericht jetzt senden",
"sending": "Wird gesendet",
"webhookStored": "Hinterlegt, wird nicht mehr angezeigt.",
"webhookFromEnv": "Kommt aus der Umgebungsvariable SLACK_WEBHOOK_URL.",
"replaceWebhook": "Anderen Webhook eintragen",
"schedule": "Zeitplan",
"scheduleText": "Der Lauf startet montags um 08:00 als Aufgabe in Coolify. Er veröffentlicht erst fällige Termine und schickt danach den Bericht. Nichts veröffentlicht heißt keine Nachricht.",
"history": "Verlauf",
"historyMeta": "{count, plural, =0 {kein Lauf} one {# Lauf} other {# Läufe}}",
"historyEmpty": "Noch kein Lauf vermerkt.",
"columns": {
"when": "Wann",
"period": "Zeitraum",
"total": "Einträge",
"projects": "Projekte",
"result": "Ergebnis",
"trigger": "Ausgelöst von"
},
"results": {
"sent": "Verschickt",
"nothing_published": "Nichts veröffentlicht",
"not_configured": "Kein Webhook",
"rejected": "Von Slack abgelehnt"
},
"fields": {
"webhook": "Incoming Webhook",
"includeInternal": "Interne Beiträge mitschicken"
},
"hints": {
"webhook": "Aus Slack unter Incoming Webhooks. Die Adresse hängt fest an einem Kanal.",
"includeInternal": "Slack ist intern, deshalb standardmäßig an. Aus heißt nur Kunden- und öffentliche Beiträge.",
"sendNow": "Schickt den Stand der letzten sieben Tage sofort in den Kanal."
},
"errors": {
"webhookInvalid": "Das sieht nicht nach einer Slack-Webhook-Adresse aus."
}
}
},
"filter": {
"type": "Art",
+53 -3
View File
@@ -273,7 +273,8 @@
"clients": "Access",
"manage": "Manage",
"postTypes": "Post types",
"api": "API"
"api": "API",
"settings": "Settings"
},
"messages": {
"created": "Created.",
@@ -283,7 +284,12 @@
"invalid": "Please check the marked fields.",
"slugTaken": "That slug already exists. Pick another one.",
"selfAdmin": "You cannot change the administration right on your own account. Another administration account has to do it.",
"typeInUse": "This type is still in use. Clear the active checkbox instead."
"typeInUse": "This type is still in use. Clear the active checkbox instead.",
"slackSaved": "The Slack settings are saved.",
"reportSent": "The digest is out.",
"reportEmpty": "Nothing was published in the last seven days, so no message went out.",
"reportNoWebhook": "No webhook is stored.",
"reportRejected": "Slack rejected the message."
},
"errors": {
"nameRequired": "Enter a name.",
@@ -696,7 +702,51 @@
"publishHint": "No access may publish. That stays bound to accounts."
},
"recentEntries": "Recently edited",
"allEntries": "All entries"
"allEntries": "All entries",
"settings": {
"eyebrow": "Administration",
"title": "Settings",
"intro": "The Slack digest and what it contains. The schedule lives in Coolify, not here.",
"slack": "Slack",
"save": "Save",
"saving": "Saving",
"sendNow": "Send the digest now",
"sending": "Sending",
"webhookStored": "Stored, no longer shown.",
"webhookFromEnv": "Comes from the SLACK_WEBHOOK_URL variable.",
"replaceWebhook": "Enter a different webhook",
"schedule": "Schedule",
"scheduleText": "The run starts Mondays at 08:00 as a Coolify task. It publishes due entries first, then sends the digest. Nothing published means no message.",
"history": "History",
"historyMeta": "{count, plural, =0 {no run} one {# run} other {# runs}}",
"historyEmpty": "No run recorded yet.",
"columns": {
"when": "When",
"period": "Period",
"total": "Entries",
"projects": "Projects",
"result": "Result",
"trigger": "Triggered by"
},
"results": {
"sent": "Sent",
"nothing_published": "Nothing published",
"not_configured": "No webhook",
"rejected": "Rejected by Slack"
},
"fields": {
"webhook": "Incoming webhook",
"includeInternal": "Include internal entries"
},
"hints": {
"webhook": "From Slack under Incoming Webhooks. The address is bound to one channel.",
"includeInternal": "Slack is internal, so this is on by default. Off means customer and public entries only.",
"sendNow": "Sends the last seven days into the channel right away."
},
"errors": {
"webhookInvalid": "That does not look like a Slack webhook address."
}
}
},
"filter": {
"type": "Type",
+109
View File
@@ -0,0 +1,109 @@
import type { Metadata } from 'next'
import { getFormatter, getTranslations } from 'next-intl/server'
import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice'
import { SlackForm } from '~/components/admin/SlackForm'
import { cellClass, headCellClass } from '~/components/admin/styles'
import { SectionLabel } from '~/components/ui/SectionLabel'
import { Scroller } from '~/components/ui/Scroller'
import { listReportRuns } from '~/data/repositories/settings'
import { longDateTimeFormat } from '~/lib/dates'
import { readSlackConfig } from '~/lib/report-settings'
import { requireAdmin } from '~/lib/auth-guards'
const historyCount = 20
export async function generateMetadata(): Promise<Metadata> {
const app = await getTranslations('app')
const t = await getTranslations('admin.settings')
return { title: `${t('title')} - ${app('name')}` }
}
function resultKey(run: { sent: boolean, detail: string | null }): string {
if (run.sent) {
return 'sent'
}
if (run.detail === 'nothing_published' || run.detail === 'not_configured') {
return run.detail
}
return 'rejected'
}
export default async function AdminSettingsPage() {
await requireAdmin()
const t = await getTranslations('admin.settings')
const format = await getFormatter()
const config = await readSlackConfig()
const runs = await listReportRuns(historyCount)
return (
<div className="flex flex-col gap-12 pt-12">
<AdminHeading eyebrow={t('eyebrow')} title={t('title')} intro={t('intro')} />
<section className="flex flex-col gap-5">
<SectionLabel>{t('slack')}</SectionLabel>
<SlackForm
hasWebhook={config.webhook !== undefined}
fromEnv={config.fromEnv}
includeInternal={config.includeInternal}
/>
</section>
<section className="flex flex-col gap-4">
<SectionLabel>{t('schedule')}</SectionLabel>
<p className="m-0 max-w-3xl text-pretty text-ink-2">{t('scheduleText')}</p>
</section>
<section className="flex flex-col gap-4">
<SectionLabel meta={<span>{t('historyMeta', { count: runs.length })}</span>}>
{t('history')}
</SectionLabel>
{runs.length === 0 ? (
<AdminNotice>{t('historyEmpty')}</AdminNotice>
) : (
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-3xl border-collapse text-left">
<thead>
<tr>
<th scope="col" className={headCellClass}>{t('columns.when')}</th>
<th scope="col" className={headCellClass}>{t('columns.period')}</th>
<th scope="col" className={headCellClass}>{t('columns.total')}</th>
<th scope="col" className={headCellClass}>{t('columns.projects')}</th>
<th scope="col" className={headCellClass}>{t('columns.result')}</th>
<th scope="col" className={headCellClass}>{t('columns.trigger')}</th>
</tr>
</thead>
<tbody>
{runs.map(run => (
<tr key={run.id}>
<td className={`${cellClass} whitespace-nowrap font-mono text-micro`}>
{format.dateTime(run.createdAt, longDateTimeFormat)}
</td>
<td className={`${cellClass} whitespace-nowrap font-mono text-micro`}>
{format.dateTime(run.fromAt, longDateTimeFormat)}
</td>
<td className={`${cellClass} whitespace-nowrap font-mono text-micro tabular-nums`}>
{run.total}
</td>
<td className={cellClass}>
{run.projects.length === 0
? '-'
: run.projects.map(project => `${project.name} (${project.count})`).join(', ')}
</td>
<td className={`${cellClass} whitespace-nowrap`}>{t(`results.${resultKey(run)}`)}</td>
<td className={`${cellClass} whitespace-nowrap font-mono text-micro`}>{run.trigger}</td>
</tr>
))}
</tbody>
</table>
</Scroller>
)}
</section>
</div>
)
}
+28 -16
View File
@@ -1,5 +1,6 @@
import { sendToSlack, slackWebhook } from '~/lib/slack'
import { runWeeklyReport } from '~/lib/report-settings'
import { buildWeeklyReport, reportDays } from '~/lib/weekly-report'
import { readSlackConfig, reportScope } from '~/lib/report-settings'
import { problem } from '~/lib/problem'
function allowed(request: Request): boolean {
@@ -23,32 +24,43 @@ export async function POST(request: Request) {
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 })
if (dry) {
const config = await readSlackConfig()
const report = await buildWeeklyReport({ origin, days, scope: reportScope(config) })
const meta = {
return Response.json({
sent: false,
reason: report.message ? 'dry_run' : 'nothing_published',
meta: {
from: report.from.toISOString(),
to: report.to.toISOString(),
total: report.total,
projects: report.projects.map(project => ({ slug: project.slug, count: project.items.length })),
},
message: report.message,
})
}
if (!report.message) {
return Response.json({ sent: false, reason: 'nothing_published', meta })
const outcome = await runWeeklyReport({ origin, days, trigger: 'cron' })
const body = {
sent: outcome.run.sent,
reason: outcome.reason,
meta: {
from: outcome.run.fromAt.toISOString(),
to: outcome.run.toAt.toISOString(),
total: outcome.run.total,
projects: outcome.run.projects,
},
}
if (dry) {
return Response.json({ sent: false, reason: 'dry_run', meta, message: report.message })
if (outcome.reason === 'not_configured') {
return problem(501, 'slack_not_configured', 'Kein Webhook hinterlegt, weder in der Verwaltung noch als SLACK_WEBHOOK_URL.')
}
if (!slackWebhook()) {
return problem(501, 'slack_not_configured', 'SLACK_WEBHOOK_URL fehlt.')
if (outcome.reason === 'rejected') {
return problem(502, 'slack_rejected', outcome.detail ?? 'Slack hat die Nachricht abgelehnt.')
}
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 })
return Response.json(body)
}
+92
View File
@@ -0,0 +1,92 @@
'use client'
import { useActionState, useState } from 'react'
import { useTranslations } from 'next-intl'
import { TbSend } from 'react-icons/tb'
import { AdminField } from './AdminField'
import { AdminFormMessage } from './AdminFormMessage'
import { AdminSubmit } from './AdminSubmit'
import { checkboxClass, hintClass, inputClass, labelClass } from './styles'
import { saveSlackSettings, sendWeeklyReportNow } from '~/lib/admin-actions'
import { emptyAdminFormState } from '~/lib/admin-forms'
export type SlackFormProps = {
hasWebhook: boolean
fromEnv: boolean
includeInternal: boolean
}
export function SlackForm({ hasWebhook, fromEnv, includeInternal }: SlackFormProps) {
const t = useTranslations('admin.settings')
const [state, formAction] = useActionState(saveSlackSettings, emptyAdminFormState)
const [report, reportAction] = useActionState(sendWeeklyReportNow, emptyAdminFormState)
const [replace, setReplace] = useState(!hasWebhook)
return (
<div className="flex flex-col gap-8">
<form action={formAction} className="flex max-w-3xl flex-col gap-6">
<AdminFormMessage state={state} />
{hasWebhook && !replace ? (
<div className="flex flex-col gap-2">
<span className={labelClass}>{t('fields.webhook')}</span>
<span className="font-mono text-small text-ink-2">
{fromEnv ? t('webhookFromEnv') : t('webhookStored')}
</span>
<span className="flex flex-wrap items-center gap-4">
<button
type="button"
onClick={() => setReplace(true)}
className="cursor-pointer bg-transparent p-0 font-mono text-micro font-semibold uppercase tracking-label text-ink-2 hover:text-signal"
>
{t('replaceWebhook')}
</button>
</span>
<input type="hidden" name="keep" value="on" />
</div>
) : (
<AdminField
id="webhook"
label={t('fields.webhook')}
hint={t('hints.webhook')}
error={state.fields?.webhook ? t(`errors.${state.fields.webhook}`) : undefined}
>
<input
id="webhook"
name="webhook"
type="url"
autoComplete="off"
placeholder="https://hooks.slack.com/services/..."
className={inputClass}
/>
</AdminField>
)}
<label className="flex items-start gap-3">
<input
type="checkbox"
name="includeInternal"
defaultChecked={includeInternal}
className={`${checkboxClass} mt-1`}
/>
<span className="flex flex-col gap-1">
<span className="font-display text-base text-ink">{t('fields.includeInternal')}</span>
<span className={hintClass}>{t('hints.includeInternal')}</span>
</span>
</label>
<AdminSubmit pendingLabel={t('saving')}>{t('save')}</AdminSubmit>
</form>
<form action={reportAction} className="flex flex-col gap-3">
<AdminFormMessage state={report} />
<span className="flex flex-wrap items-center gap-4">
<AdminSubmit quiet pendingLabel={t('sending')} icon={<TbSend className="size-4" />}>
{t('sendNow')}
</AdminSubmit>
<span className={hintClass}>{t('hints.sendNow')}</span>
</span>
</form>
</div>
)
}
+3 -1
View File
@@ -1,6 +1,6 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { TbAddressBook, TbArrowNarrowLeft, TbCode, TbKey, TbLayoutGrid, TbNotebook, TbPhoto, TbStack2, TbTags, TbUsers } from 'react-icons/tb'
import { TbAddressBook, TbArrowNarrowLeft, TbCode, TbKey, TbLayoutGrid, TbNotebook, TbPhoto, TbSettings, TbStack2, TbTags, TbUsers } from 'react-icons/tb'
import { NavLink } from './NavLink'
import { Wordmark } from './Wordmark'
import { Scroller } from '~/components/ui/Scroller'
@@ -15,6 +15,7 @@ import {
adminEntriesPath,
adminPostTypesPath,
adminProjectsPath,
adminSettingsPath,
adminUsersPath,
} from '~/lib/admin-routes'
import { overviewPath } from '~/lib/routes'
@@ -51,6 +52,7 @@ export async function AdminNav({ viewer, number }: AdminNavProps) {
manage.push({ href: adminPostTypesPath, label: t('nav.postTypes'), icon: <TbTags className="size-4" /> })
manage.push({ href: adminUsersPath, label: t('nav.users'), icon: <TbUsers className="size-4" /> })
manage.push({ href: adminClientsPath, label: t('nav.clients'), icon: <TbKey className="size-4" /> })
manage.push({ href: adminSettingsPath, label: t('nav.settings'), icon: <TbSettings className="size-4" /> })
manage.push({ href: '/admin/api' as Route, label: t('nav.api'), icon: <TbCode className="size-4" /> })
}
+42
View File
@@ -0,0 +1,42 @@
import { desc, eq, inArray } from 'drizzle-orm'
import { db } from '../db'
import { appSettings, reportRuns, type ReportRun } from '../schema'
export type SettingKey = 'slack.webhook' | 'slack.includeInternal'
export async function readSettings(keys: SettingKey[]): Promise<Map<SettingKey, string>> {
const rows = await db.select().from(appSettings).where(inArray(appSettings.key, keys))
return new Map(rows.map(row => [row.key as SettingKey, row.value]))
}
export async function writeSetting(key: SettingKey, value: string): Promise<void> {
await db
.insert(appSettings)
.values({ key, value, updatedAt: new Date() })
.onConflictDoUpdate({ target: appSettings.key, set: { value, updatedAt: new Date() } })
}
export async function clearSetting(key: SettingKey): Promise<void> {
await db.delete(appSettings).where(eq(appSettings.key, key))
}
export type ReportRunValues = {
fromAt: Date
toAt: Date
total: number
projects: { slug: string, name: string, count: number }[]
sent: boolean
trigger: string
detail?: string | null
}
export async function recordReportRun(values: ReportRunValues): Promise<ReportRun> {
const rows = await db.insert(reportRuns).values({ ...values, detail: values.detail ?? null }).returning()
return rows[0]!
}
export async function listReportRuns(limit: number): Promise<ReportRun[]> {
return db.select().from(reportRuns).orderBy(desc(reportRuns.createdAt)).limit(limit)
}
+1
View File
@@ -7,3 +7,4 @@ export * from './post-types'
export * from './posts'
export * from './clients'
export * from './idempotency'
export * from './settings'
+22
View File
@@ -0,0 +1,22 @@
import { boolean, integer, jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
export const appSettings = pgTable('app_setting', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const reportRuns = pgTable('report_run', {
id: uuid('id').primaryKey().defaultRandom(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
fromAt: timestamp('from_at', { withTimezone: true }).notNull(),
toAt: timestamp('to_at', { withTimezone: true }).notNull(),
total: integer('total').notNull(),
projects: jsonb('projects').$type<{ slug: string, name: string, count: number }[]>().notNull().default([]),
sent: boolean('sent').notNull().default(false),
trigger: text('trigger').notNull(),
detail: text('detail'),
})
export type AppSetting = typeof appSettings.$inferSelect
export type ReportRun = typeof reportRuns.$inferSelect
+21
View File
@@ -6,6 +6,7 @@ import { performSaveBrand } from './admin-brands'
import { performCreateClient, performRevokeClient } from './admin-clients'
import { performDeletePostType, performSavePostType } from './admin-post-types'
import { performSaveProject } from './admin-projects'
import { performSaveSlack, performSendReport } from './admin-settings'
import { performGrantRole, performRevokeRole, performSetUserAdmin } from './admin-users'
import type { AdminFormState } from './admin-forms'
import {
@@ -14,6 +15,7 @@ import {
adminEntriesPath,
adminPostTypesPath,
adminProjectsPath,
adminSettingsPath,
adminUsersPath,
} from './admin-routes'
import { requireAdmin } from './auth-guards'
@@ -138,3 +140,22 @@ export async function revokeApiClient(_state: AdminFormState, formData: FormData
return result
}
export async function saveSlackSettings(_state: AdminFormState, formData: FormData): Promise<AdminFormState> {
const viewer = await requireAdmin()
const result = await performSaveSlack(viewer, formData)
revalidatePath(adminSettingsPath)
return result
}
export async function sendWeeklyReportNow(_state: AdminFormState): Promise<AdminFormState> {
const viewer = await requireAdmin()
const origin = process.env.BETTER_AUTH_URL ?? ''
const result = await performSendReport(viewer, origin)
revalidatePath(adminSettingsPath)
return result
}
+1
View File
@@ -10,6 +10,7 @@ export const adminNewBrandPath = '/admin/brands/new' as Route
export const adminUsersPath = '/admin/users' as Route
export const adminClientsPath = '/admin/clients' as Route
export const adminPostTypesPath = '/admin/post-types' as Route
export const adminSettingsPath = '/admin/settings' as Route
export const adminNewPostTypePath = '/admin/post-types/new' as Route
export function adminApiFilePath(file: 'openapi' | 'style-guide' | 'skill'): Route {
+59
View File
@@ -0,0 +1,59 @@
import { recordAudit } from '~/data/repositories/audit'
import { clearSetting, writeSetting } from '~/data/repositories/settings'
import { invalid, readField, readFlag, type AdminFormState } from './admin-forms'
import { runWeeklyReport } from './report-settings'
import type { Viewer } from './auth-access'
function looksLikeWebhook(value: string): boolean {
return value === '' || /^https:\/\/hooks\.slack\.com\/services\/[\w/+-]+$/u.test(value)
}
export async function performSaveSlack(viewer: Viewer, formData: FormData): Promise<AdminFormState> {
const webhook = readField(formData, 'webhook')
const keep = readFlag(formData, 'keep')
const includeInternal = readFlag(formData, 'includeInternal')
if (!keep && !looksLikeWebhook(webhook)) {
return invalid('webhookInvalid', undefined, { webhook: 'webhookInvalid' })
}
if (!keep) {
if (webhook === '') {
await clearSetting('slack.webhook')
} else {
await writeSetting('slack.webhook', webhook)
}
}
await writeSetting('slack.includeInternal', includeInternal ? 'true' : 'false')
await recordAudit({
actorId: viewer.id,
actorLabel: viewer.email,
action: 'settings.slack',
entity: 'setting',
entityId: 'slack',
data: { webhookSet: keep ? 'unchanged' : webhook !== '', includeInternal },
})
return { status: 'ok', message: 'slackSaved' }
}
export async function performSendReport(viewer: Viewer, origin: string): Promise<AdminFormState> {
const outcome = await runWeeklyReport({ origin, trigger: `admin:${viewer.email}` })
await recordAudit({
actorId: viewer.id,
actorLabel: viewer.email,
action: 'settings.report',
entity: 'setting',
entityId: 'slack',
data: { reason: outcome.reason, total: outcome.run.total },
})
if (outcome.reason === 'sent') {
return { status: 'ok', message: 'reportSent' }
}
return invalid(outcome.reason === 'nothing_published' ? 'reportEmpty' : outcome.reason === 'not_configured' ? 'reportNoWebhook' : 'reportRejected')
}
+88
View File
@@ -0,0 +1,88 @@
import { readSettings, recordReportRun } from '~/data/repositories/settings'
import { sendToSlack } from './slack'
import { buildWeeklyReport, reportDays } from './weekly-report'
import type { ReportRun } from '~/data/schema'
import type { ViewerScope } from '~/domain/types'
export type SlackConfig = {
webhook: string | undefined
fromEnv: boolean
includeInternal: boolean
}
export type ReportOutcome = {
run: ReportRun
reason: 'sent' | 'nothing_published' | 'not_configured' | 'rejected'
detail?: string
}
export async function readSlackConfig(): Promise<SlackConfig> {
const values = await readSettings(['slack.webhook', 'slack.includeInternal'])
const stored = values.get('slack.webhook')?.trim()
const env = process.env.SLACK_WEBHOOK_URL?.trim()
return {
webhook: stored || env || undefined,
fromEnv: !stored && Boolean(env),
includeInternal: values.get('slack.includeInternal') !== 'false',
}
}
export function reportScope(config: SlackConfig): ViewerScope {
return config.includeInternal ? 'internal' : 'customer'
}
export async function runWeeklyReport(args: {
origin: string
trigger: string
days?: number
now?: Date
}): Promise<ReportOutcome> {
const config = await readSlackConfig()
const report = await buildWeeklyReport({
origin: args.origin,
days: args.days ?? reportDays,
now: args.now,
scope: reportScope(config),
})
const base = {
fromAt: report.from,
toAt: report.to,
total: report.total,
projects: report.projects.map(project => ({
slug: project.slug,
name: project.name,
count: project.items.length,
})),
trigger: args.trigger,
}
if (!report.message) {
return {
run: await recordReportRun({ ...base, sent: false, detail: 'nothing_published' }),
reason: 'nothing_published',
}
}
if (!config.webhook) {
return {
run: await recordReportRun({ ...base, sent: false, detail: 'not_configured' }),
reason: 'not_configured',
}
}
const result = await sendToSlack(report.message, config.webhook)
if (!result.ok) {
const detail = result.detail ?? result.reason
return {
run: await recordReportRun({ ...base, sent: false, detail }),
reason: 'rejected',
detail,
}
}
return { run: await recordReportRun({ ...base, sent: true }), reason: 'sent' }
}
+57
View File
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { db } from '~/data/db'
import { appSettings } from '~/data/schema'
import { listReportRuns, writeSetting } from '~/data/repositories/settings'
import { readSlackConfig, reportScope, runWeeklyReport } from '~/lib/report-settings'
const origin = 'https://logbuch.nyo.de'
beforeEach(async () => {
await db.delete(appSettings)
delete process.env.SLACK_WEBHOOK_URL
vi.unstubAllGlobals()
})
describe('readSlackConfig', () => {
it('nimmt den hinterlegten Webhook vor der Umgebungsvariable', async () => {
process.env.SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/aus/der/umgebung'
await writeSetting('slack.webhook', 'https://hooks.slack.com/services/aus/der/verwaltung')
const config = await readSlackConfig()
expect(config.webhook).toContain('verwaltung')
expect(config.fromEnv).toBe(false)
})
it('fällt auf die Umgebungsvariable zurück', async () => {
process.env.SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/aus/der/umgebung'
const config = await readSlackConfig()
expect(config.fromEnv).toBe(true)
})
it('schickt interne Beiträge mit, solange nichts anderes gesetzt ist', async () => {
expect(reportScope(await readSlackConfig())).toBe('internal')
await writeSetting('slack.includeInternal', 'false')
expect(reportScope(await readSlackConfig())).toBe('customer')
})
})
describe('runWeeklyReport', () => {
it('vermerkt einen Lauf ohne Veröffentlichungen, ohne zu senden', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const outcome = await runWeeklyReport({ origin, trigger: 'test' })
expect(outcome.reason).toBe('nothing_published')
expect(fetchMock).not.toHaveBeenCalled()
const runs = await listReportRuns(1)
expect(runs[0]).toMatchObject({ sent: false, total: 0, trigger: 'test', detail: 'nothing_published' })
})
})
+1 -1
View File
@@ -13,6 +13,6 @@ rmSync(testStorage, { recursive: true, force: true })
process.env.STORAGE_DIR = testStorage
beforeEach(async () => {
await db.execute(sql`truncate table post_read, api_client, post_tag, tag, block, post, issue, media, project, brand, post_type restart identity cascade`)
await db.execute(sql`truncate table post_read, api_client, post_tag, tag, block, post, issue, media, project, brand, post_type, app_setting, report_run restart identity cascade`)
await insertDefaultPostTypes()
})