From 0fd6da62d1b705467126502ddef7fc4deddeca8e Mon Sep 17 00:00:00 2001 From: Matthias G Date: Mon, 3 Aug 2026 12:13:57 +0200 Subject: [PATCH] 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. --- docs/api.md | 2 +- drizzle/0011_concerned_domino.sql | 17 + drizzle/meta/0011_snapshot.json | 2002 +++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/de.json | 56 +- messages/en.json | 56 +- src/app/admin/settings/page.tsx | 109 ++ src/app/api/v1/weekly-report/route.ts | 56 +- src/components/admin/SlackForm.tsx | 92 ++ src/components/layout/AdminNav.tsx | 4 +- src/data/repositories/settings.ts | 42 + src/data/schema/index.ts | 1 + src/data/schema/settings.ts | 22 + src/lib/admin-actions.ts | 21 + src/lib/admin-routes.ts | 1 + src/lib/admin-settings.ts | 59 + src/lib/report-settings.ts | 88 ++ tests/lib/report-settings.test.ts | 57 + tests/setup.ts | 2 +- 19 files changed, 2663 insertions(+), 31 deletions(-) create mode 100644 drizzle/0011_concerned_domino.sql create mode 100644 drizzle/meta/0011_snapshot.json create mode 100644 src/app/admin/settings/page.tsx create mode 100644 src/components/admin/SlackForm.tsx create mode 100644 src/data/repositories/settings.ts create mode 100644 src/data/schema/settings.ts create mode 100644 src/lib/admin-settings.ts create mode 100644 src/lib/report-settings.ts create mode 100644 tests/lib/report-settings.test.ts diff --git a/docs/api.md b/docs/api.md index ee81172..b64d6c2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 | |---|---| diff --git a/drizzle/0011_concerned_domino.sql b/drizzle/0011_concerned_domino.sql new file mode 100644 index 0000000..00ed4c0 --- /dev/null +++ b/drizzle/0011_concerned_domino.sql @@ -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 +); diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..4b3b9b8 --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,2002 @@ +{ + "id": "cbf1058e-f11a-4856-8aa8-185452f3a8e0", + "prevId": "560c398a-4ec6-478e-99c6-89374eef5d35", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_type": { + "name": "actor_type", + "type": "audit_actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_created": { + "name": "audit_log_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entity": { + "name": "audit_log_entity", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_project_role": { + "name": "user_project_role", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "project_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'moderator'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_project_role_project": { + "name": "user_project_role_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_project_role_user_id_user_id_fk": { + "name": "user_project_role_user_id_user_id_fk", + "tableFrom": "user_project_role", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_project_role_project_id_project_id_fk": { + "name": "user_project_role_project_id_project_id_fk", + "tableFrom": "user_project_role", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_project_role_user_id_project_id_pk": { + "name": "user_project_role_user_id_project_id_pk", + "columns": [ + "user_id", + "project_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand": { + "name": "brand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brand_slug_unique": { + "name": "brand_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brand_id": { + "name": "brand_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_brand_id_brand_id_fk": { + "name": "project_brand_id_brand_id_fk", + "tableFrom": "project", + "tableTo": "brand", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_slug_unique": { + "name": "project_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_counter": { + "name": "post_counter", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "post_counter_project_id_project_id_fk": { + "name": "post_counter_project_id_project_id_fk", + "tableFrom": "post_counter", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'image'" + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "variant_error": { + "name": "variant_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_project_created": { + "name": "media_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_project_id_project_id_fk": { + "name": "media_project_id_project_id_fk", + "tableFrom": "media", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_uploaded_by_user_id_fk": { + "name": "media_uploaded_by_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_type": { + "name": "post_type", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label_de": { + "name": "label_de", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label_en": { + "name": "label_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#191c20'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_type_sort": { + "name": "post_type_sort", + "columns": [ + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_type_key_unique": { + "name": "post_type_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block": { + "name": "block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "block_post_sort": { + "name": "block_post_sort", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_post_id_post_id_fk": { + "name": "block_post_id_post_id_fk", + "tableFrom": "block", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_from": { + "name": "period_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_to": { + "name": "period_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "issue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_project_id_project_id_fk": { + "name": "issue_project_id_project_id_fk", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tag": { + "name": "post_tag", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "post_tag_post_id_post_id_fk": { + "name": "post_tag_post_id_post_id_fk", + "tableFrom": "post_tag", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tag_tag_id_tag_id_fk": { + "name": "post_tag_tag_id_tag_id_fk", + "tableFrom": "post_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tag_post_id_tag_id_pk": { + "name": "post_tag_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teaser": { + "name": "teaser", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type_id": { + "name": "type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "post_type_default()" + }, + "audience": { + "name": "audience", + "type": "post_audience", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "locale": { + "name": "locale", + "type": "post_locale", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "translation_group": { + "name": "translation_group", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_publish_at": { + "name": "post_publish_at", + "columns": [ + { + "expression": "publish_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_project_status": { + "name": "post_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_project_id_project_id_fk": { + "name": "post_project_id_project_id_fk", + "tableFrom": "post", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_issue_id_issue_id_fk": { + "name": "post_issue_id_issue_id_fk", + "tableFrom": "post", + "tableTo": "issue", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_type_id_post_type_id_fk": { + "name": "post_type_id_post_type_id_fk", + "tableFrom": "post", + "tableTo": "post_type", + "columnsFrom": [ + "type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "post_cover_media_id_media_id_fk": { + "name": "post_cover_media_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_project_slug": { + "name": "post_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + }, + "post_project_number": { + "name": "post_project_number", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tag_project_id_project_id_fk": { + "name": "tag_project_id_project_id_fk", + "tableFrom": "tag", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_project_slug": { + "name": "tag_project_slug", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_client": { + "name": "api_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "client_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "scope": { + "name": "scope", + "type": "client_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "can_publish": { + "name": "can_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_client_project_id_project_id_fk": { + "name": "api_client_project_id_project_id_fk", + "tableFrom": "api_client", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_client_token_hash_unique": { + "name": "api_client_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_read": { + "name": "post_read", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_read_project_user": { + "name": "post_read_project_user", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_read_post_id_post_id_fk": { + "name": "post_read_post_id_post_id_fk", + "tableFrom": "post_read", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_read_project_id_project_id_fk": { + "name": "post_read_project_id_project_id_fk", + "tableFrom": "post_read", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_read_post_id_external_user_id_pk": { + "name": "post_read_post_id_external_user_id_pk", + "columns": [ + "post_id", + "external_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_idempotency_key": { + "name": "api_idempotency_key", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_idempotency_key_client_id_api_client_id_fk": { + "name": "api_idempotency_key_client_id_api_client_id_fk", + "tableFrom": "api_idempotency_key", + "tableTo": "api_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_idempotency_key_post_id_post_id_fk": { + "name": "api_idempotency_key_post_id_post_id_fk", + "tableFrom": "api_idempotency_key", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_idempotency_key_client_id_key_pk": { + "name": "api_idempotency_key_client_id_key_pk", + "columns": [ + "client_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_setting": { + "name": "app_setting", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.report_run": { + "name": "report_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "from_at": { + "name": "from_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "to_at": { + "name": "to_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "projects": { + "name": "projects", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "sent": { + "name": "sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.audit_actor_type": { + "name": "audit_actor_type", + "schema": "public", + "values": [ + "user", + "client" + ] + }, + "public.project_role": { + "name": "project_role", + "schema": "public", + "values": [ + "moderator" + ] + }, + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "image", + "video" + ] + }, + "public.issue_status": { + "name": "issue_status", + "schema": "public", + "values": [ + "draft", + "scheduled", + "published" + ] + }, + "public.post_audience": { + "name": "post_audience", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + }, + "public.post_locale": { + "name": "post_locale", + "schema": "public", + "values": [ + "de", + "en" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "review", + "scheduled", + "published", + "archived" + ] + }, + "public.client_mode": { + "name": "client_mode", + "schema": "public", + "values": [ + "read", + "write" + ] + }, + "public.client_scope": { + "name": "client_scope", + "schema": "public", + "values": [ + "internal", + "customer", + "public" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 163a177..ea02aa2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1785579010161, "tag": "0010_steady_wither", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1785751667390, + "tag": "0011_concerned_domino", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/de.json b/messages/de.json index fa94eee..c211731 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index c57d091..ea00cb8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..8aaa52d --- /dev/null +++ b/src/app/admin/settings/page.tsx @@ -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 { + 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 ( +
+ + +
+ {t('slack')} + +
+ +
+ {t('schedule')} +

{t('scheduleText')}

+
+ +
+ {t('historyMeta', { count: runs.length })}}> + {t('history')} + + + {runs.length === 0 ? ( + {t('historyEmpty')} + ) : ( + + + + + + + + + + + + + + {runs.map(run => ( + + + + + + + + + ))} + +
{t('columns.when')}{t('columns.period')}{t('columns.total')}{t('columns.projects')}{t('columns.result')}{t('columns.trigger')}
+ {format.dateTime(run.createdAt, longDateTimeFormat)} + + {format.dateTime(run.fromAt, longDateTimeFormat)} + + {run.total} + + {run.projects.length === 0 + ? '-' + : run.projects.map(project => `${project.name} (${project.count})`).join(', ')} + {t(`results.${resultKey(run)}`)}{run.trigger}
+
+ )} +
+
+ ) +} diff --git a/src/app/api/v1/weekly-report/route.ts b/src/app/api/v1/weekly-report/route.ts index da5966a..f6dbd6b 100644 --- a/src/app/api/v1/weekly-report/route.ts +++ b/src/app/api/v1/weekly-report/route.ts @@ -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 }) - - const meta = { - from: report.from.toISOString(), - to: report.to.toISOString(), - total: report.total, - projects: report.projects.map(project => ({ slug: project.slug, count: project.items.length })), - } - - if (!report.message) { - return Response.json({ sent: false, reason: 'nothing_published', meta }) - } - if (dry) { - return Response.json({ sent: false, reason: 'dry_run', meta, message: report.message }) + 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()) { - return problem(501, 'slack_not_configured', 'SLACK_WEBHOOK_URL fehlt.') + 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, + }, } - const result = await sendToSlack(report.message) - - if (!result.ok) { - return problem(502, 'slack_rejected', result.detail ?? 'Slack hat die Nachricht abgelehnt.') + if (outcome.reason === 'not_configured') { + return problem(501, 'slack_not_configured', 'Kein Webhook hinterlegt, weder in der Verwaltung noch als SLACK_WEBHOOK_URL.') } - 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) } diff --git a/src/components/admin/SlackForm.tsx b/src/components/admin/SlackForm.tsx new file mode 100644 index 0000000..27d98e3 --- /dev/null +++ b/src/components/admin/SlackForm.tsx @@ -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 ( +
+
+ + + {hasWebhook && !replace ? ( +
+ {t('fields.webhook')} + + {fromEnv ? t('webhookFromEnv') : t('webhookStored')} + + + + + +
+ ) : ( + + + + )} + + + + {t('save')} + + +
+ + + }> + {t('sendNow')} + + {t('hints.sendNow')} + + +
+ ) +} diff --git a/src/components/layout/AdminNav.tsx b/src/components/layout/AdminNav.tsx index 3bdb472..ebcdcea 100644 --- a/src/components/layout/AdminNav.tsx +++ b/src/components/layout/AdminNav.tsx @@ -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: }) manage.push({ href: adminUsersPath, label: t('nav.users'), icon: }) manage.push({ href: adminClientsPath, label: t('nav.clients'), icon: }) + manage.push({ href: adminSettingsPath, label: t('nav.settings'), icon: }) manage.push({ href: '/admin/api' as Route, label: t('nav.api'), icon: }) } diff --git a/src/data/repositories/settings.ts b/src/data/repositories/settings.ts new file mode 100644 index 0000000..4f3d424 --- /dev/null +++ b/src/data/repositories/settings.ts @@ -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> { + 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 { + 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 { + 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 { + const rows = await db.insert(reportRuns).values({ ...values, detail: values.detail ?? null }).returning() + + return rows[0]! +} + +export async function listReportRuns(limit: number): Promise { + return db.select().from(reportRuns).orderBy(desc(reportRuns.createdAt)).limit(limit) +} diff --git a/src/data/schema/index.ts b/src/data/schema/index.ts index d84f48c..e3f74a3 100644 --- a/src/data/schema/index.ts +++ b/src/data/schema/index.ts @@ -7,3 +7,4 @@ export * from './post-types' export * from './posts' export * from './clients' export * from './idempotency' +export * from './settings' diff --git a/src/data/schema/settings.ts b/src/data/schema/settings.ts new file mode 100644 index 0000000..be6b706 --- /dev/null +++ b/src/data/schema/settings.ts @@ -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 diff --git a/src/lib/admin-actions.ts b/src/lib/admin-actions.ts index 59f1c99..1e65a24 100644 --- a/src/lib/admin-actions.ts +++ b/src/lib/admin-actions.ts @@ -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 { + const viewer = await requireAdmin() + const result = await performSaveSlack(viewer, formData) + + revalidatePath(adminSettingsPath) + + return result +} + +export async function sendWeeklyReportNow(_state: AdminFormState): Promise { + const viewer = await requireAdmin() + const origin = process.env.BETTER_AUTH_URL ?? '' + const result = await performSendReport(viewer, origin) + + revalidatePath(adminSettingsPath) + + return result +} diff --git a/src/lib/admin-routes.ts b/src/lib/admin-routes.ts index 602a9aa..9c1d7a6 100644 --- a/src/lib/admin-routes.ts +++ b/src/lib/admin-routes.ts @@ -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 { diff --git a/src/lib/admin-settings.ts b/src/lib/admin-settings.ts new file mode 100644 index 0000000..20cd2a4 --- /dev/null +++ b/src/lib/admin-settings.ts @@ -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 { + 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 { + 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') +} diff --git a/src/lib/report-settings.ts b/src/lib/report-settings.ts new file mode 100644 index 0000000..301dc70 --- /dev/null +++ b/src/lib/report-settings.ts @@ -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 { + 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 { + 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' } +} diff --git a/tests/lib/report-settings.test.ts b/tests/lib/report-settings.test.ts new file mode 100644 index 0000000..87f12b7 --- /dev/null +++ b/tests/lib/report-settings.test.ts @@ -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' }) + }) +}) diff --git a/tests/setup.ts b/tests/setup.ts index af74e0e..c100cd4 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -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() })