Send through a bot token so messages can be deleted again
Incoming webhooks return no message id, so nothing can be removed later. Settings now take a bot token plus channel, chat.postMessage records channel and timestamp on the run, and the history carries a delete button per message. The webhook stays as a fallback and is marked as not deletable.
This commit is contained in:
+1
-1
@@ -225,7 +225,7 @@ Antwort: `{ "published": [...], "meta": { "total": 0 } }`.
|
|||||||
|
|
||||||
### POST /api/v1/weekly-report
|
### 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 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.
|
Sammelt alles, was in den letzten sieben Tagen veröffentlicht wurde, gruppiert es nach Projekt und schickt es als eine Nachricht nach Slack. Den Weg nach Slack nimmt Logbuch aus der Verwaltung unter Einstellungen: Bot-Token mit Kanal, sonst Webhook, sonst `SLACK_WEBHOOK_URL`. Nur beim Bot-Token merkt sich Logbuch die Nachrichtenkennung und kann sie später wieder löschen. Verlangt ebenfalls `CRON_SECRET`. Jeder Lauf landet im Verlauf auf derselben Seite.
|
||||||
|
|
||||||
| Parameter | Wirkung |
|
| Parameter | Wirkung |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "report_run" ADD COLUMN "channel" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "report_run" ADD COLUMN "message_ts" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "report_run" ADD COLUMN "deleted_at" timestamp with time zone;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
|||||||
"when": 1785751667390,
|
"when": 1785751667390,
|
||||||
"tag": "0011_concerned_domino",
|
"tag": "0011_concerned_domino",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785753181183,
|
||||||
|
"tag": "0012_solid_tarantula",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+27
-6
@@ -289,7 +289,13 @@
|
|||||||
"reportSent": "Der Bericht ist raus.",
|
"reportSent": "Der Bericht ist raus.",
|
||||||
"reportEmpty": "In den letzten sieben Tagen wurde nichts veröffentlicht, deshalb ging keine Nachricht raus.",
|
"reportEmpty": "In den letzten sieben Tagen wurde nichts veröffentlicht, deshalb ging keine Nachricht raus.",
|
||||||
"reportNoWebhook": "Es ist kein Webhook hinterlegt.",
|
"reportNoWebhook": "Es ist kein Webhook hinterlegt.",
|
||||||
"reportRejected": "Slack hat die Nachricht abgelehnt."
|
"reportRejected": "Slack hat die Nachricht abgelehnt.",
|
||||||
|
"messageDeleted": "Die Nachricht ist in Slack gelöscht.",
|
||||||
|
"runUnknown": "Diesen Lauf gibt es nicht.",
|
||||||
|
"runNotDeletable": "Dieser Lauf ging über den Webhook, dafür gibt es keine Nachrichtenkennung.",
|
||||||
|
"runNoToken": "Ohne Bot-Token lässt sich in Slack nichts löschen.",
|
||||||
|
"runDeleteFailed": "Slack hat das Löschen abgelehnt.",
|
||||||
|
"tokenInvalid": "Ein Bot-Token beginnt mit xoxb oder xoxp."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"nameRequired": "Trag einen Namen ein.",
|
"nameRequired": "Trag einen Namen ein.",
|
||||||
@@ -726,7 +732,8 @@
|
|||||||
"total": "Einträge",
|
"total": "Einträge",
|
||||||
"projects": "Projekte",
|
"projects": "Projekte",
|
||||||
"result": "Ergebnis",
|
"result": "Ergebnis",
|
||||||
"trigger": "Ausgelöst von"
|
"trigger": "Ausgelöst von",
|
||||||
|
"message": "Nachricht"
|
||||||
},
|
},
|
||||||
"results": {
|
"results": {
|
||||||
"sent": "Verschickt",
|
"sent": "Verschickt",
|
||||||
@@ -736,16 +743,30 @@
|
|||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"webhook": "Incoming Webhook",
|
"webhook": "Incoming Webhook",
|
||||||
"includeInternal": "Interne Beiträge mitschicken"
|
"includeInternal": "Interne Beiträge mitschicken",
|
||||||
|
"botToken": "Bot-Token",
|
||||||
|
"channel": "Kanal"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"webhook": "Aus Slack unter Incoming Webhooks. Die Adresse hängt fest an einem Kanal.",
|
"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.",
|
"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."
|
"sendNow": "Schickt den Stand der letzten sieben Tage sofort in den Kanal.",
|
||||||
|
"botToken": "Aus der Slack-App unter OAuth & Permissions, beginnt mit xoxb. Mit Bot-Token lassen sich Nachrichten später wieder löschen.",
|
||||||
|
"channel": "Name oder Kennung des Kanals, etwa logbuch oder C01234567."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"webhookInvalid": "Das sieht nicht nach einer Slack-Webhook-Adresse aus."
|
"webhookInvalid": "Das sieht nicht nach einer Slack-Webhook-Adresse aus.",
|
||||||
}
|
"tokenInvalid": "Ein Bot-Token beginnt mit xoxb oder xoxp."
|
||||||
|
},
|
||||||
|
"tokenStored": "Hinterlegt, wird nicht mehr angezeigt.",
|
||||||
|
"replaceToken": "Anderen Token eintragen",
|
||||||
|
"messageDelete": "In Slack löschen",
|
||||||
|
"messageDeleting": "Wird gelöscht",
|
||||||
|
"messageDeleted": "Gelöscht",
|
||||||
|
"messageWebhook": "Über Webhook, nicht löschbar",
|
||||||
|
"pathBot": "Versand über den Bot-Token. Nachrichten lassen sich hier wieder löschen.",
|
||||||
|
"pathWebhook": "Versand über den Webhook. Nachrichten lassen sich danach nicht mehr löschen, dafür braucht es einen Bot-Token.",
|
||||||
|
"pathNone": "Noch kein Weg hinterlegt, es geht nichts raus."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
|
|||||||
+27
-6
@@ -289,7 +289,13 @@
|
|||||||
"reportSent": "The digest is out.",
|
"reportSent": "The digest is out.",
|
||||||
"reportEmpty": "Nothing was published in the last seven days, so no message went out.",
|
"reportEmpty": "Nothing was published in the last seven days, so no message went out.",
|
||||||
"reportNoWebhook": "No webhook is stored.",
|
"reportNoWebhook": "No webhook is stored.",
|
||||||
"reportRejected": "Slack rejected the message."
|
"reportRejected": "Slack rejected the message.",
|
||||||
|
"messageDeleted": "The message is deleted in Slack.",
|
||||||
|
"runUnknown": "That run does not exist.",
|
||||||
|
"runNotDeletable": "That run went through the webhook, so there is no message id.",
|
||||||
|
"runNoToken": "Without a bot token nothing can be deleted in Slack.",
|
||||||
|
"runDeleteFailed": "Slack refused the deletion.",
|
||||||
|
"tokenInvalid": "A bot token starts with xoxb or xoxp."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"nameRequired": "Enter a name.",
|
"nameRequired": "Enter a name.",
|
||||||
@@ -726,7 +732,8 @@
|
|||||||
"total": "Entries",
|
"total": "Entries",
|
||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
"result": "Result",
|
"result": "Result",
|
||||||
"trigger": "Triggered by"
|
"trigger": "Triggered by",
|
||||||
|
"message": "Message"
|
||||||
},
|
},
|
||||||
"results": {
|
"results": {
|
||||||
"sent": "Sent",
|
"sent": "Sent",
|
||||||
@@ -736,16 +743,30 @@
|
|||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"webhook": "Incoming webhook",
|
"webhook": "Incoming webhook",
|
||||||
"includeInternal": "Include internal entries"
|
"includeInternal": "Include internal entries",
|
||||||
|
"botToken": "Bot token",
|
||||||
|
"channel": "Channel"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"webhook": "From Slack under Incoming Webhooks. The address is bound to one channel.",
|
"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.",
|
"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."
|
"sendNow": "Sends the last seven days into the channel right away.",
|
||||||
|
"botToken": "From the Slack app under OAuth & Permissions, starts with xoxb. A bot token lets messages be deleted later.",
|
||||||
|
"channel": "Channel name or id, for example logbuch or C01234567."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"webhookInvalid": "That does not look like a Slack webhook address."
|
"webhookInvalid": "That does not look like a Slack webhook address.",
|
||||||
}
|
"tokenInvalid": "A bot token starts with xoxb or xoxp."
|
||||||
|
},
|
||||||
|
"tokenStored": "Stored, no longer shown.",
|
||||||
|
"replaceToken": "Enter a different token",
|
||||||
|
"messageDelete": "Delete in Slack",
|
||||||
|
"messageDeleting": "Deleting",
|
||||||
|
"messageDeleted": "Deleted",
|
||||||
|
"messageWebhook": "Sent by webhook, cannot be deleted",
|
||||||
|
"pathBot": "Sending through the bot token. Messages can be deleted from here.",
|
||||||
|
"pathWebhook": "Sending through the webhook. Messages cannot be deleted afterwards, that needs a bot token.",
|
||||||
|
"pathNone": "No route stored yet, nothing goes out."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
|
|||||||
import { getFormatter, getTranslations } from 'next-intl/server'
|
import { getFormatter, getTranslations } from 'next-intl/server'
|
||||||
import { AdminHeading } from '~/components/admin/AdminHeading'
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
import { AdminNotice } from '~/components/admin/AdminNotice'
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
|
import { MessageDeleteButton } from '~/components/admin/MessageDeleteButton'
|
||||||
import { SlackForm } from '~/components/admin/SlackForm'
|
import { SlackForm } from '~/components/admin/SlackForm'
|
||||||
import { cellClass, headCellClass } from '~/components/admin/styles'
|
import { cellClass, headCellClass } from '~/components/admin/styles'
|
||||||
import { SectionLabel } from '~/components/ui/SectionLabel'
|
import { SectionLabel } from '~/components/ui/SectionLabel'
|
||||||
@@ -47,7 +48,9 @@ export default async function AdminSettingsPage() {
|
|||||||
<section className="flex flex-col gap-5">
|
<section className="flex flex-col gap-5">
|
||||||
<SectionLabel>{t('slack')}</SectionLabel>
|
<SectionLabel>{t('slack')}</SectionLabel>
|
||||||
<SlackForm
|
<SlackForm
|
||||||
hasWebhook={config.webhook !== undefined}
|
hasWebhook={config.target?.kind === 'webhook'}
|
||||||
|
hasBot={config.hasBot}
|
||||||
|
channel={config.channel}
|
||||||
fromEnv={config.fromEnv}
|
fromEnv={config.fromEnv}
|
||||||
includeInternal={config.includeInternal}
|
includeInternal={config.includeInternal}
|
||||||
/>
|
/>
|
||||||
@@ -76,6 +79,7 @@ export default async function AdminSettingsPage() {
|
|||||||
<th scope="col" className={headCellClass}>{t('columns.projects')}</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.result')}</th>
|
||||||
<th scope="col" className={headCellClass}>{t('columns.trigger')}</th>
|
<th scope="col" className={headCellClass}>{t('columns.trigger')}</th>
|
||||||
|
<th scope="col" className={headCellClass}>{t('columns.message')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -97,6 +101,13 @@ export default async function AdminSettingsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className={`${cellClass} whitespace-nowrap`}>{t(`results.${resultKey(run)}`)}</td>
|
<td className={`${cellClass} whitespace-nowrap`}>{t(`results.${resultKey(run)}`)}</td>
|
||||||
<td className={`${cellClass} whitespace-nowrap font-mono text-micro`}>{run.trigger}</td>
|
<td className={`${cellClass} whitespace-nowrap font-mono text-micro`}>{run.trigger}</td>
|
||||||
|
<td className={`${cellClass} whitespace-nowrap`}>
|
||||||
|
{run.deletedAt
|
||||||
|
? t('messageDeleted')
|
||||||
|
: run.messageTs
|
||||||
|
? <MessageDeleteButton runId={run.id} />
|
||||||
|
: run.sent ? t('messageWebhook') : '-'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useActionState } from 'react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { TbTrash } from 'react-icons/tb'
|
||||||
|
import { AdminSubmit } from './AdminSubmit'
|
||||||
|
import { errorClass } from './styles'
|
||||||
|
import { deleteSlackMessage } from '~/lib/admin-actions'
|
||||||
|
import { emptyAdminFormState } from '~/lib/admin-forms'
|
||||||
|
|
||||||
|
export type MessageDeleteButtonProps = {
|
||||||
|
runId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageDeleteButton({ runId }: MessageDeleteButtonProps) {
|
||||||
|
const t = useTranslations('admin.settings')
|
||||||
|
const messages = useTranslations('admin.messages')
|
||||||
|
const [state, formAction] = useActionState(deleteSlackMessage, emptyAdminFormState)
|
||||||
|
|
||||||
|
if (state.status === 'ok') {
|
||||||
|
return <span className="font-mono text-micro text-ink-3">{t('messageDeleted')}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="flex flex-col gap-1">
|
||||||
|
<input type="hidden" name="runId" value={runId} />
|
||||||
|
<AdminSubmit quiet pendingLabel={t('messageDeleting')} icon={<TbTrash className="size-4" />}>
|
||||||
|
{t('messageDelete')}
|
||||||
|
</AdminSubmit>
|
||||||
|
{state.status === 'error' && state.message ? (
|
||||||
|
<span role="alert" className={errorClass}>{messages(state.message)}</span>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,21 +12,70 @@ import { emptyAdminFormState } from '~/lib/admin-forms'
|
|||||||
|
|
||||||
export type SlackFormProps = {
|
export type SlackFormProps = {
|
||||||
hasWebhook: boolean
|
hasWebhook: boolean
|
||||||
|
hasBot: boolean
|
||||||
|
channel: string
|
||||||
fromEnv: boolean
|
fromEnv: boolean
|
||||||
includeInternal: boolean
|
includeInternal: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SlackForm({ hasWebhook, fromEnv, includeInternal }: SlackFormProps) {
|
export function SlackForm({ hasWebhook, hasBot, channel, fromEnv, includeInternal }: SlackFormProps) {
|
||||||
const t = useTranslations('admin.settings')
|
const t = useTranslations('admin.settings')
|
||||||
const [state, formAction] = useActionState(saveSlackSettings, emptyAdminFormState)
|
const [state, formAction] = useActionState(saveSlackSettings, emptyAdminFormState)
|
||||||
const [report, reportAction] = useActionState(sendWeeklyReportNow, emptyAdminFormState)
|
const [report, reportAction] = useActionState(sendWeeklyReportNow, emptyAdminFormState)
|
||||||
const [replace, setReplace] = useState(!hasWebhook)
|
const [replace, setReplace] = useState(!hasWebhook)
|
||||||
|
const [replaceToken, setReplaceToken] = useState(!hasBot)
|
||||||
|
const path = hasBot ? 'pathBot' : hasWebhook ? 'pathWebhook' : 'pathNone'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<form action={formAction} className="flex max-w-3xl flex-col gap-6">
|
<form action={formAction} className="flex max-w-3xl flex-col gap-6">
|
||||||
<AdminFormMessage state={state} />
|
<AdminFormMessage state={state} />
|
||||||
|
|
||||||
|
<p className="m-0 text-pretty text-ink-2">{t(path)}</p>
|
||||||
|
|
||||||
|
{hasBot && !replaceToken ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className={labelClass}>{t('fields.botToken')}</span>
|
||||||
|
<span className="font-mono text-small text-ink-2">{t('tokenStored')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReplaceToken(true)}
|
||||||
|
className="cursor-pointer self-start bg-transparent p-0 font-mono text-micro font-semibold uppercase tracking-label text-ink-2 hover:text-signal"
|
||||||
|
>
|
||||||
|
{t('replaceToken')}
|
||||||
|
</button>
|
||||||
|
<input type="hidden" name="keepToken" value="on" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AdminField
|
||||||
|
id="botToken"
|
||||||
|
label={t('fields.botToken')}
|
||||||
|
hint={t('hints.botToken')}
|
||||||
|
error={state.fields?.botToken ? t(`errors.${state.fields.botToken}`) : undefined}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="botToken"
|
||||||
|
name="botToken"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="xoxb-..."
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AdminField id="channel" label={t('fields.channel')} hint={t('hints.channel')}>
|
||||||
|
<input
|
||||||
|
id="channel"
|
||||||
|
name="channel"
|
||||||
|
type="text"
|
||||||
|
defaultValue={channel}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="logbuch"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</AdminField>
|
||||||
|
|
||||||
{hasWebhook && !replace ? (
|
{hasWebhook && !replace ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className={labelClass}>{t('fields.webhook')}</span>
|
<span className={labelClass}>{t('fields.webhook')}</span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { desc, eq, inArray } from 'drizzle-orm'
|
|||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { appSettings, reportRuns, type ReportRun } from '../schema'
|
import { appSettings, reportRuns, type ReportRun } from '../schema'
|
||||||
|
|
||||||
export type SettingKey = 'slack.webhook' | 'slack.includeInternal'
|
export type SettingKey = 'slack.webhook' | 'slack.botToken' | 'slack.channel' | 'slack.includeInternal'
|
||||||
|
|
||||||
export async function readSettings(keys: SettingKey[]): Promise<Map<SettingKey, string>> {
|
export async function readSettings(keys: SettingKey[]): Promise<Map<SettingKey, string>> {
|
||||||
const rows = await db.select().from(appSettings).where(inArray(appSettings.key, keys))
|
const rows = await db.select().from(appSettings).where(inArray(appSettings.key, keys))
|
||||||
@@ -29,6 +29,8 @@ export type ReportRunValues = {
|
|||||||
sent: boolean
|
sent: boolean
|
||||||
trigger: string
|
trigger: string
|
||||||
detail?: string | null
|
detail?: string | null
|
||||||
|
channel?: string | null
|
||||||
|
messageTs?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function recordReportRun(values: ReportRunValues): Promise<ReportRun> {
|
export async function recordReportRun(values: ReportRunValues): Promise<ReportRun> {
|
||||||
@@ -37,6 +39,16 @@ export async function recordReportRun(values: ReportRunValues): Promise<ReportRu
|
|||||||
return rows[0]!
|
return rows[0]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findReportRun(id: string): Promise<ReportRun | undefined> {
|
||||||
|
const rows = await db.select().from(reportRuns).where(eq(reportRuns.id, id)).limit(1)
|
||||||
|
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markReportRunDeleted(id: string): Promise<void> {
|
||||||
|
await db.update(reportRuns).set({ deletedAt: new Date() }).where(eq(reportRuns.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
export async function listReportRuns(limit: number): Promise<ReportRun[]> {
|
export async function listReportRuns(limit: number): Promise<ReportRun[]> {
|
||||||
return db.select().from(reportRuns).orderBy(desc(reportRuns.createdAt)).limit(limit)
|
return db.select().from(reportRuns).orderBy(desc(reportRuns.createdAt)).limit(limit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export const reportRuns = pgTable('report_run', {
|
|||||||
sent: boolean('sent').notNull().default(false),
|
sent: boolean('sent').notNull().default(false),
|
||||||
trigger: text('trigger').notNull(),
|
trigger: text('trigger').notNull(),
|
||||||
detail: text('detail'),
|
detail: text('detail'),
|
||||||
|
channel: text('channel'),
|
||||||
|
messageTs: text('message_ts'),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AppSetting = typeof appSettings.$inferSelect
|
export type AppSetting = typeof appSettings.$inferSelect
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { performSaveBrand } from './admin-brands'
|
|||||||
import { performCreateClient, performRevokeClient } from './admin-clients'
|
import { performCreateClient, performRevokeClient } from './admin-clients'
|
||||||
import { performDeletePostType, performSavePostType } from './admin-post-types'
|
import { performDeletePostType, performSavePostType } from './admin-post-types'
|
||||||
import { performSaveProject } from './admin-projects'
|
import { performSaveProject } from './admin-projects'
|
||||||
import { performSaveSlack, performSendReport } from './admin-settings'
|
import { performDeleteMessage, performSaveSlack, performSendReport } from './admin-settings'
|
||||||
import { performGrantRole, performRevokeRole, performSetUserAdmin } from './admin-users'
|
import { performGrantRole, performRevokeRole, performSetUserAdmin } from './admin-users'
|
||||||
import type { AdminFormState } from './admin-forms'
|
import type { AdminFormState } from './admin-forms'
|
||||||
import {
|
import {
|
||||||
@@ -159,3 +159,12 @@ export async function sendWeeklyReportNow(_state: AdminFormState): Promise<Admin
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteSlackMessage(_state: AdminFormState, formData: FormData): Promise<AdminFormState> {
|
||||||
|
const viewer = await requireAdmin()
|
||||||
|
const result = await performDeleteMessage(viewer, formData)
|
||||||
|
|
||||||
|
revalidatePath(adminSettingsPath)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
import { recordAudit } from '~/data/repositories/audit'
|
import { recordAudit } from '~/data/repositories/audit'
|
||||||
import { clearSetting, writeSetting } from '~/data/repositories/settings'
|
import {
|
||||||
import { invalid, readField, readFlag, type AdminFormState } from './admin-forms'
|
clearSetting,
|
||||||
|
findReportRun,
|
||||||
|
markReportRunDeleted,
|
||||||
|
readSettings,
|
||||||
|
writeSetting,
|
||||||
|
} from '~/data/repositories/settings'
|
||||||
|
import { invalid, isUuid, readField, readFlag, type AdminFormState } from './admin-forms'
|
||||||
import { runWeeklyReport } from './report-settings'
|
import { runWeeklyReport } from './report-settings'
|
||||||
|
import { deleteFromSlack } from './slack'
|
||||||
import type { Viewer } from './auth-access'
|
import type { Viewer } from './auth-access'
|
||||||
|
|
||||||
function looksLikeWebhook(value: string): boolean {
|
function looksLikeWebhook(value: string): boolean {
|
||||||
return value === '' || /^https:\/\/hooks\.slack\.com\/services\/[\w/+-]+$/u.test(value)
|
return value === '' || /^https:\/\/hooks\.slack\.com\/services\/[\w/+-]+$/u.test(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeToken(value: string): boolean {
|
||||||
|
return value === '' || /^xox[bp]-[\w-]+$/u.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
export async function performSaveSlack(viewer: Viewer, formData: FormData): Promise<AdminFormState> {
|
export async function performSaveSlack(viewer: Viewer, formData: FormData): Promise<AdminFormState> {
|
||||||
const webhook = readField(formData, 'webhook')
|
const webhook = readField(formData, 'webhook')
|
||||||
|
const token = readField(formData, 'botToken')
|
||||||
|
const channel = readField(formData, 'channel')
|
||||||
const keep = readFlag(formData, 'keep')
|
const keep = readFlag(formData, 'keep')
|
||||||
|
const keepToken = readFlag(formData, 'keepToken')
|
||||||
const includeInternal = readFlag(formData, 'includeInternal')
|
const includeInternal = readFlag(formData, 'includeInternal')
|
||||||
|
|
||||||
if (!keep && !looksLikeWebhook(webhook)) {
|
if (!keep && !looksLikeWebhook(webhook)) {
|
||||||
return invalid('webhookInvalid', undefined, { webhook: 'webhookInvalid' })
|
return invalid('webhookInvalid', undefined, { webhook: 'webhookInvalid' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!keepToken && !looksLikeToken(token)) {
|
||||||
|
return invalid('tokenInvalid', undefined, { botToken: 'tokenInvalid' })
|
||||||
|
}
|
||||||
|
|
||||||
if (!keep) {
|
if (!keep) {
|
||||||
if (webhook === '') {
|
if (webhook === '') {
|
||||||
await clearSetting('slack.webhook')
|
await clearSetting('slack.webhook')
|
||||||
@@ -25,6 +43,20 @@ export async function performSaveSlack(viewer: Viewer, formData: FormData): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!keepToken) {
|
||||||
|
if (token === '') {
|
||||||
|
await clearSetting('slack.botToken')
|
||||||
|
} else {
|
||||||
|
await writeSetting('slack.botToken', token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel === '') {
|
||||||
|
await clearSetting('slack.channel')
|
||||||
|
} else {
|
||||||
|
await writeSetting('slack.channel', channel)
|
||||||
|
}
|
||||||
|
|
||||||
await writeSetting('slack.includeInternal', includeInternal ? 'true' : 'false')
|
await writeSetting('slack.includeInternal', includeInternal ? 'true' : 'false')
|
||||||
|
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
@@ -33,7 +65,12 @@ export async function performSaveSlack(viewer: Viewer, formData: FormData): Prom
|
|||||||
action: 'settings.slack',
|
action: 'settings.slack',
|
||||||
entity: 'setting',
|
entity: 'setting',
|
||||||
entityId: 'slack',
|
entityId: 'slack',
|
||||||
data: { webhookSet: keep ? 'unchanged' : webhook !== '', includeInternal },
|
data: {
|
||||||
|
webhookSet: keep ? 'unchanged' : webhook !== '',
|
||||||
|
botTokenSet: keepToken ? 'unchanged' : token !== '',
|
||||||
|
channel,
|
||||||
|
includeInternal,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return { status: 'ok', message: 'slackSaved' }
|
return { status: 'ok', message: 'slackSaved' }
|
||||||
@@ -57,3 +94,47 @@ export async function performSendReport(viewer: Viewer, origin: string): Promise
|
|||||||
|
|
||||||
return invalid(outcome.reason === 'nothing_published' ? 'reportEmpty' : outcome.reason === 'not_configured' ? 'reportNoWebhook' : 'reportRejected')
|
return invalid(outcome.reason === 'nothing_published' ? 'reportEmpty' : outcome.reason === 'not_configured' ? 'reportNoWebhook' : 'reportRejected')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function performDeleteMessage(viewer: Viewer, formData: FormData): Promise<AdminFormState> {
|
||||||
|
const id = readField(formData, 'runId')
|
||||||
|
|
||||||
|
if (!isUuid(id)) {
|
||||||
|
return invalid('runUnknown')
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = await findReportRun(id)
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
return invalid('runUnknown')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!run.channel || !run.messageTs) {
|
||||||
|
return invalid('runNotDeletable')
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = await readSettings(['slack.botToken'])
|
||||||
|
const token = values.get('slack.botToken')?.trim()
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return invalid('runNoToken')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deleteFromSlack(token, run.channel, run.messageTs)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return invalid('runDeleteFailed')
|
||||||
|
}
|
||||||
|
|
||||||
|
await markReportRunDeleted(run.id)
|
||||||
|
|
||||||
|
await recordAudit({
|
||||||
|
actorId: viewer.id,
|
||||||
|
actorLabel: viewer.email,
|
||||||
|
action: 'settings.reportDelete',
|
||||||
|
entity: 'setting',
|
||||||
|
entityId: run.id,
|
||||||
|
data: { channel: run.channel, messageTs: run.messageTs },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { status: 'ok', message: 'messageDeleted' }
|
||||||
|
}
|
||||||
|
|||||||
+34
-15
@@ -1,12 +1,14 @@
|
|||||||
import { readSettings, recordReportRun } from '~/data/repositories/settings'
|
import { readSettings, recordReportRun } from '~/data/repositories/settings'
|
||||||
import { sendToSlack } from './slack'
|
import { sendToSlack, slackWebhook, type SlackTarget } from './slack'
|
||||||
import { buildWeeklyReport, reportDays } from './weekly-report'
|
import { buildWeeklyReport, reportDays } from './weekly-report'
|
||||||
import type { ReportRun } from '~/data/schema'
|
import type { ReportRun } from '~/data/schema'
|
||||||
import type { ViewerScope } from '~/domain/types'
|
import type { ViewerScope } from '~/domain/types'
|
||||||
|
|
||||||
export type SlackConfig = {
|
export type SlackConfig = {
|
||||||
webhook: string | undefined
|
target: SlackTarget | undefined
|
||||||
fromEnv: boolean
|
fromEnv: boolean
|
||||||
|
hasBot: boolean
|
||||||
|
channel: string
|
||||||
includeInternal: boolean
|
includeInternal: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,13 +19,24 @@ export type ReportOutcome = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function readSlackConfig(): Promise<SlackConfig> {
|
export async function readSlackConfig(): Promise<SlackConfig> {
|
||||||
const values = await readSettings(['slack.webhook', 'slack.includeInternal'])
|
const values = await readSettings(['slack.webhook', 'slack.botToken', 'slack.channel', 'slack.includeInternal'])
|
||||||
|
const token = values.get('slack.botToken')?.trim()
|
||||||
|
const channel = values.get('slack.channel')?.trim() ?? ''
|
||||||
const stored = values.get('slack.webhook')?.trim()
|
const stored = values.get('slack.webhook')?.trim()
|
||||||
const env = process.env.SLACK_WEBHOOK_URL?.trim()
|
const env = slackWebhook()
|
||||||
|
const webhook = stored || env
|
||||||
|
|
||||||
|
const target: SlackTarget | undefined = token && channel !== ''
|
||||||
|
? { kind: 'bot', token, channel }
|
||||||
|
: webhook
|
||||||
|
? { kind: 'webhook', url: webhook }
|
||||||
|
: undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
webhook: stored || env || undefined,
|
target,
|
||||||
fromEnv: !stored && Boolean(env),
|
fromEnv: target?.kind === 'webhook' && !stored,
|
||||||
|
hasBot: Boolean(token),
|
||||||
|
channel,
|
||||||
includeInternal: values.get('slack.includeInternal') !== 'false',
|
includeInternal: values.get('slack.includeInternal') !== 'false',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,24 +78,30 @@ export async function runWeeklyReport(args: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.webhook) {
|
if (!config.target) {
|
||||||
return {
|
return {
|
||||||
run: await recordReportRun({ ...base, sent: false, detail: 'not_configured' }),
|
run: await recordReportRun({ ...base, sent: false, detail: 'not_configured' }),
|
||||||
reason: 'not_configured',
|
reason: 'not_configured',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await sendToSlack(report.message, config.webhook)
|
const result = await sendToSlack(report.message, config.target)
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const detail = result.detail ?? result.reason
|
return {
|
||||||
|
run: await recordReportRun({ ...base, sent: false, detail: result.detail }),
|
||||||
|
reason: 'rejected',
|
||||||
|
detail: result.detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
run: await recordReportRun({ ...base, sent: false, detail }),
|
run: await recordReportRun({
|
||||||
reason: 'rejected',
|
...base,
|
||||||
detail,
|
sent: true,
|
||||||
|
channel: result.sent.channel,
|
||||||
|
messageTs: result.sent.messageTs,
|
||||||
|
}),
|
||||||
|
reason: 'sent',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { run: await recordReportRun({ ...base, sent: true }), reason: 'sent' }
|
|
||||||
}
|
|
||||||
|
|||||||
+59
-8
@@ -1,8 +1,17 @@
|
|||||||
import type { SlackMessage } from './weekly-report'
|
import type { SlackMessage } from './weekly-report'
|
||||||
|
|
||||||
|
export type SlackTarget =
|
||||||
|
| { kind: 'bot', token: string, channel: string }
|
||||||
|
| { kind: 'webhook', url: string }
|
||||||
|
|
||||||
|
export type SlackSent = {
|
||||||
|
channel: string | null
|
||||||
|
messageTs: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type SlackResult =
|
export type SlackResult =
|
||||||
| { ok: true }
|
| { ok: true, sent: SlackSent }
|
||||||
| { ok: false, reason: 'not_configured' | 'rejected', detail?: string }
|
| { ok: false, detail: string }
|
||||||
|
|
||||||
export function slackWebhook(): string | undefined {
|
export function slackWebhook(): string | undefined {
|
||||||
const url = process.env.SLACK_WEBHOOK_URL?.trim()
|
const url = process.env.SLACK_WEBHOOK_URL?.trim()
|
||||||
@@ -10,20 +19,62 @@ export function slackWebhook(): string | undefined {
|
|||||||
return url === '' ? undefined : url
|
return url === '' ? undefined : url
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendToSlack(message: SlackMessage, webhook = slackWebhook()): Promise<SlackResult> {
|
async function callApi(token: string, method: string, body: unknown): Promise<{ ok: boolean, data: Record<string, unknown> }> {
|
||||||
if (!webhook) {
|
const response = await fetch(`https://slack.com/api/${method}`, {
|
||||||
return { ok: false, reason: 'not_configured' }
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({})) as Record<string, unknown>
|
||||||
|
|
||||||
|
return { ok: response.ok && data.ok === true, data }
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(webhook, {
|
export async function sendToSlack(message: SlackMessage, target: SlackTarget): Promise<SlackResult> {
|
||||||
|
if (target.kind === 'bot') {
|
||||||
|
const { ok, data } = await callApi(target.token, 'chat.postMessage', {
|
||||||
|
channel: target.channel,
|
||||||
|
text: message.text,
|
||||||
|
blocks: message.blocks,
|
||||||
|
unfurl_links: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
return { ok: false, detail: String(data.error ?? 'unknown_error') }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
sent: {
|
||||||
|
channel: typeof data.channel === 'string' ? data.channel : target.channel,
|
||||||
|
messageTs: typeof data.ts === 'string' ? data.ts : null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(target.url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(message),
|
body: JSON.stringify(message),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return { ok: false, reason: 'rejected', detail: `${response.status} ${await response.text()}` }
|
return { ok: false, detail: `${response.status} ${await response.text()}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true, sent: { channel: null, messageTs: null } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFromSlack(token: string, channel: string, messageTs: string): Promise<SlackResult> {
|
||||||
|
const { ok, data } = await callApi(token, 'chat.delete', { channel, ts: messageTs })
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
return { ok: false, detail: String(data.error ?? 'unknown_error') }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, sent: { channel, messageTs } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { db } from '~/data/db'
|
|||||||
import { appSettings } from '~/data/schema'
|
import { appSettings } from '~/data/schema'
|
||||||
import { listReportRuns, writeSetting } from '~/data/repositories/settings'
|
import { listReportRuns, writeSetting } from '~/data/repositories/settings'
|
||||||
import { readSlackConfig, reportScope, runWeeklyReport } from '~/lib/report-settings'
|
import { readSlackConfig, reportScope, runWeeklyReport } from '~/lib/report-settings'
|
||||||
|
import { makeBrand, makeProject } from '../support/admin'
|
||||||
|
import { makePost } from '../support/entries'
|
||||||
|
|
||||||
const origin = 'https://logbuch.nyo.de'
|
const origin = 'https://logbuch.nyo.de'
|
||||||
|
|
||||||
@@ -19,7 +21,8 @@ describe('readSlackConfig', () => {
|
|||||||
|
|
||||||
const config = await readSlackConfig()
|
const config = await readSlackConfig()
|
||||||
|
|
||||||
expect(config.webhook).toContain('verwaltung')
|
expect(config.target).toMatchObject({ kind: 'webhook' })
|
||||||
|
expect(config.target && 'url' in config.target ? config.target.url : '').toContain('verwaltung')
|
||||||
expect(config.fromEnv).toBe(false)
|
expect(config.fromEnv).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -55,3 +58,66 @@ describe('runWeeklyReport', () => {
|
|||||||
expect(runs[0]).toMatchObject({ sent: false, total: 0, trigger: 'test', detail: 'nothing_published' })
|
expect(runs[0]).toMatchObject({ sent: false, total: 0, trigger: 'test', detail: 'nothing_published' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Bot-Token', () => {
|
||||||
|
it('nimmt Bot-Token und Kanal vor dem Webhook', async () => {
|
||||||
|
await writeSetting('slack.webhook', 'https://hooks.slack.com/services/a/b/c')
|
||||||
|
await writeSetting('slack.botToken', 'xoxb-1234')
|
||||||
|
await writeSetting('slack.channel', 'logbuch')
|
||||||
|
|
||||||
|
const config = await readSlackConfig()
|
||||||
|
|
||||||
|
expect(config.target).toEqual({ kind: 'bot', token: 'xoxb-1234', channel: 'logbuch' })
|
||||||
|
expect(config.hasBot).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('braucht zum Bot-Token einen Kanal', async () => {
|
||||||
|
await writeSetting('slack.botToken', 'xoxb-1234')
|
||||||
|
|
||||||
|
const config = await readSlackConfig()
|
||||||
|
|
||||||
|
expect(config.target).toBeUndefined()
|
||||||
|
expect(config.hasBot).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merkt sich Kanal und Nachrichtenkennung eines Versands', async () => {
|
||||||
|
const brand = await makeBrand()
|
||||||
|
const project = await makeProject(brand.id)
|
||||||
|
const post = await makePost(project.id, { number: 1, slug: 'eins', status: 'published', publishAt: new Date() })
|
||||||
|
|
||||||
|
expect(post.id).toBeDefined()
|
||||||
|
|
||||||
|
await writeSetting('slack.botToken', 'xoxb-1234')
|
||||||
|
await writeSetting('slack.channel', 'logbuch')
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(
|
||||||
|
JSON.stringify({ ok: true, channel: 'C999', ts: '1785600000.000100' }),
|
||||||
|
{ headers: { 'content-type': 'application/json' } },
|
||||||
|
)))
|
||||||
|
|
||||||
|
const outcome = await runWeeklyReport({ origin, trigger: 'test' })
|
||||||
|
|
||||||
|
expect(outcome.reason).toBe('sent')
|
||||||
|
expect(outcome.run).toMatchObject({ sent: true, channel: 'C999', messageTs: '1785600000.000100' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vermerkt eine Ablehnung mit dem Grund von Slack', async () => {
|
||||||
|
const brand = await makeBrand()
|
||||||
|
const project = await makeProject(brand.id)
|
||||||
|
|
||||||
|
await makePost(project.id, { number: 1, slug: 'eins', status: 'published', publishAt: new Date() })
|
||||||
|
|
||||||
|
await writeSetting('slack.botToken', 'xoxb-1234')
|
||||||
|
await writeSetting('slack.channel', 'logbuch')
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(
|
||||||
|
JSON.stringify({ ok: false, error: 'channel_not_found' }),
|
||||||
|
{ headers: { 'content-type': 'application/json' } },
|
||||||
|
)))
|
||||||
|
|
||||||
|
const outcome = await runWeeklyReport({ origin, trigger: 'test' })
|
||||||
|
|
||||||
|
expect(outcome.reason).toBe('rejected')
|
||||||
|
expect(outcome.run).toMatchObject({ sent: false, detail: 'channel_not_found' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user