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:
+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 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 |
|
| Parameter | Wirkung |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -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
@@ -78,6 +78,13 @@
|
|||||||
"when": 1785579010161,
|
"when": 1785579010161,
|
||||||
"tag": "0010_steady_wither",
|
"tag": "0010_steady_wither",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785751667390,
|
||||||
|
"tag": "0011_concerned_domino",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+53
-3
@@ -273,7 +273,8 @@
|
|||||||
"clients": "Zugänge",
|
"clients": "Zugänge",
|
||||||
"manage": "Verwalten",
|
"manage": "Verwalten",
|
||||||
"postTypes": "Beitragsarten",
|
"postTypes": "Beitragsarten",
|
||||||
"api": "API"
|
"api": "API",
|
||||||
|
"settings": "Einstellungen"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"created": "Angelegt.",
|
"created": "Angelegt.",
|
||||||
@@ -283,7 +284,12 @@
|
|||||||
"invalid": "Bitte prüfe die markierten Felder.",
|
"invalid": "Bitte prüfe die markierten Felder.",
|
||||||
"slugTaken": "Diesen Slug gibt es schon. Wähle einen anderen.",
|
"slugTaken": "Diesen Slug gibt es schon. Wähle einen anderen.",
|
||||||
"selfAdmin": "Das eigene Verwaltungsrecht kannst du nicht ändern. Das muss ein anderes Verwaltungskonto tun.",
|
"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": {
|
"errors": {
|
||||||
"nameRequired": "Trag einen Namen ein.",
|
"nameRequired": "Trag einen Namen ein.",
|
||||||
@@ -696,7 +702,51 @@
|
|||||||
"publishHint": "Kein Zugang darf veröffentlichen. Das bleibt an Konten gebunden."
|
"publishHint": "Kein Zugang darf veröffentlichen. Das bleibt an Konten gebunden."
|
||||||
},
|
},
|
||||||
"recentEntries": "Zuletzt bearbeitet",
|
"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": {
|
"filter": {
|
||||||
"type": "Art",
|
"type": "Art",
|
||||||
|
|||||||
+53
-3
@@ -273,7 +273,8 @@
|
|||||||
"clients": "Access",
|
"clients": "Access",
|
||||||
"manage": "Manage",
|
"manage": "Manage",
|
||||||
"postTypes": "Post types",
|
"postTypes": "Post types",
|
||||||
"api": "API"
|
"api": "API",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"created": "Created.",
|
"created": "Created.",
|
||||||
@@ -283,7 +284,12 @@
|
|||||||
"invalid": "Please check the marked fields.",
|
"invalid": "Please check the marked fields.",
|
||||||
"slugTaken": "That slug already exists. Pick another one.",
|
"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.",
|
"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": {
|
"errors": {
|
||||||
"nameRequired": "Enter a name.",
|
"nameRequired": "Enter a name.",
|
||||||
@@ -696,7 +702,51 @@
|
|||||||
"publishHint": "No access may publish. That stays bound to accounts."
|
"publishHint": "No access may publish. That stays bound to accounts."
|
||||||
},
|
},
|
||||||
"recentEntries": "Recently edited",
|
"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": {
|
"filter": {
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { sendToSlack, slackWebhook } from '~/lib/slack'
|
import { runWeeklyReport } from '~/lib/report-settings'
|
||||||
import { buildWeeklyReport, reportDays } from '~/lib/weekly-report'
|
import { buildWeeklyReport, reportDays } from '~/lib/weekly-report'
|
||||||
|
import { readSlackConfig, reportScope } from '~/lib/report-settings'
|
||||||
import { problem } from '~/lib/problem'
|
import { problem } from '~/lib/problem'
|
||||||
|
|
||||||
function allowed(request: Request): boolean {
|
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 days = Number.isInteger(wanted) && wanted > 0 && wanted <= 90 ? wanted : reportDays
|
||||||
const origin = process.env.BETTER_AUTH_URL ?? url.origin
|
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) {
|
if (dry) {
|
||||||
return Response.json({ sent: false, reason: 'dry_run', meta, message: report.message })
|
const config = await readSlackConfig()
|
||||||
|
const report = await buildWeeklyReport({ origin, days, scope: reportScope(config) })
|
||||||
|
|
||||||
|
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 (!slackWebhook()) {
|
const outcome = await runWeeklyReport({ origin, days, trigger: 'cron' })
|
||||||
return problem(501, 'slack_not_configured', 'SLACK_WEBHOOK_URL fehlt.')
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await sendToSlack(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 (!result.ok) {
|
|
||||||
return problem(502, 'slack_rejected', result.detail ?? 'Slack hat die Nachricht abgelehnt.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ sent: true, meta })
|
if (outcome.reason === 'rejected') {
|
||||||
|
return problem(502, 'slack_rejected', outcome.detail ?? 'Slack hat die Nachricht abgelehnt.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { getTranslations } from 'next-intl/server'
|
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 { NavLink } from './NavLink'
|
||||||
import { Wordmark } from './Wordmark'
|
import { Wordmark } from './Wordmark'
|
||||||
import { Scroller } from '~/components/ui/Scroller'
|
import { Scroller } from '~/components/ui/Scroller'
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
adminEntriesPath,
|
adminEntriesPath,
|
||||||
adminPostTypesPath,
|
adminPostTypesPath,
|
||||||
adminProjectsPath,
|
adminProjectsPath,
|
||||||
|
adminSettingsPath,
|
||||||
adminUsersPath,
|
adminUsersPath,
|
||||||
} from '~/lib/admin-routes'
|
} from '~/lib/admin-routes'
|
||||||
import { overviewPath } from '~/lib/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: 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: 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: 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" /> })
|
manage.push({ href: '/admin/api' as Route, label: t('nav.api'), icon: <TbCode className="size-4" /> })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -7,3 +7,4 @@ export * from './post-types'
|
|||||||
export * from './posts'
|
export * from './posts'
|
||||||
export * from './clients'
|
export * from './clients'
|
||||||
export * from './idempotency'
|
export * from './idempotency'
|
||||||
|
export * from './settings'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,6 +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 { 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 {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
adminEntriesPath,
|
adminEntriesPath,
|
||||||
adminPostTypesPath,
|
adminPostTypesPath,
|
||||||
adminProjectsPath,
|
adminProjectsPath,
|
||||||
|
adminSettingsPath,
|
||||||
adminUsersPath,
|
adminUsersPath,
|
||||||
} from './admin-routes'
|
} from './admin-routes'
|
||||||
import { requireAdmin } from './auth-guards'
|
import { requireAdmin } from './auth-guards'
|
||||||
@@ -138,3 +140,22 @@ export async function revokeApiClient(_state: AdminFormState, formData: FormData
|
|||||||
|
|
||||||
return result
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const adminNewBrandPath = '/admin/brands/new' as Route
|
|||||||
export const adminUsersPath = '/admin/users' as Route
|
export const adminUsersPath = '/admin/users' as Route
|
||||||
export const adminClientsPath = '/admin/clients' as Route
|
export const adminClientsPath = '/admin/clients' as Route
|
||||||
export const adminPostTypesPath = '/admin/post-types' 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 const adminNewPostTypePath = '/admin/post-types/new' as Route
|
||||||
|
|
||||||
export function adminApiFilePath(file: 'openapi' | 'style-guide' | 'skill'): Route {
|
export function adminApiFilePath(file: 'openapi' | 'style-guide' | 'skill'): Route {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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' }
|
||||||
|
}
|
||||||
@@ -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
@@ -13,6 +13,6 @@ rmSync(testStorage, { recursive: true, force: true })
|
|||||||
process.env.STORAGE_DIR = testStorage
|
process.env.STORAGE_DIR = testStorage
|
||||||
|
|
||||||
beforeEach(async () => {
|
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()
|
await insertDefaultPostTypes()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user