diff --git a/docs/api.md b/docs/api.md index b64d6c2..ed7c7b6 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 nach Slack. Den Webhook nimmt Logbuch aus der Verwaltung unter Einstellungen, sonst aus `SLACK_WEBHOOK_URL`. Verlangt ebenfalls `CRON_SECRET`. Jeder Lauf landet im Verlauf auf derselben Seite. +Sammelt alles, was in den letzten sieben Tagen veröffentlicht wurde, gruppiert es nach Projekt und schickt es als eine Nachricht nach Slack. Den Weg nach Slack nimmt Logbuch aus der Verwaltung unter Einstellungen: Bot-Token mit Kanal, sonst Webhook, sonst `SLACK_WEBHOOK_URL`. Nur beim Bot-Token merkt sich Logbuch die Nachrichtenkennung und kann sie später wieder löschen. Verlangt ebenfalls `CRON_SECRET`. Jeder Lauf landet im Verlauf auf derselben Seite. | Parameter | Wirkung | |---|---| diff --git a/drizzle/0012_solid_tarantula.sql b/drizzle/0012_solid_tarantula.sql new file mode 100644 index 0000000..afd4e01 --- /dev/null +++ b/drizzle/0012_solid_tarantula.sql @@ -0,0 +1,3 @@ +ALTER TABLE "report_run" ADD COLUMN "channel" text;--> statement-breakpoint +ALTER TABLE "report_run" ADD COLUMN "message_ts" text;--> statement-breakpoint +ALTER TABLE "report_run" ADD COLUMN "deleted_at" timestamp with time zone; \ No newline at end of file diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..74a678f --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,2020 @@ +{ + "id": "1ed40941-c388-4ab8-92ff-c3afddcd7c1a", + "prevId": "cbf1058e-f11a-4856-8aa8-185452f3a8e0", + "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 + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "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 ea02aa2..4d0ef4d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1785751667390, "tag": "0011_concerned_domino", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1785753181183, + "tag": "0012_solid_tarantula", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/de.json b/messages/de.json index c211731..0f99089 100644 --- a/messages/de.json +++ b/messages/de.json @@ -289,7 +289,13 @@ "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." + "reportRejected": "Slack hat die Nachricht abgelehnt.", + "messageDeleted": "Die Nachricht ist in Slack gelöscht.", + "runUnknown": "Diesen Lauf gibt es nicht.", + "runNotDeletable": "Dieser Lauf ging über den Webhook, dafür gibt es keine Nachrichtenkennung.", + "runNoToken": "Ohne Bot-Token lässt sich in Slack nichts löschen.", + "runDeleteFailed": "Slack hat das Löschen abgelehnt.", + "tokenInvalid": "Ein Bot-Token beginnt mit xoxb oder xoxp." }, "errors": { "nameRequired": "Trag einen Namen ein.", @@ -726,7 +732,8 @@ "total": "Einträge", "projects": "Projekte", "result": "Ergebnis", - "trigger": "Ausgelöst von" + "trigger": "Ausgelöst von", + "message": "Nachricht" }, "results": { "sent": "Verschickt", @@ -736,16 +743,30 @@ }, "fields": { "webhook": "Incoming Webhook", - "includeInternal": "Interne Beiträge mitschicken" + "includeInternal": "Interne Beiträge mitschicken", + "botToken": "Bot-Token", + "channel": "Kanal" }, "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." + "sendNow": "Schickt den Stand der letzten sieben Tage sofort in den Kanal.", + "botToken": "Aus der Slack-App unter OAuth & Permissions, beginnt mit xoxb. Mit Bot-Token lassen sich Nachrichten später wieder löschen.", + "channel": "Name oder Kennung des Kanals, etwa logbuch oder C01234567." }, "errors": { - "webhookInvalid": "Das sieht nicht nach einer Slack-Webhook-Adresse aus." - } + "webhookInvalid": "Das sieht nicht nach einer Slack-Webhook-Adresse aus.", + "tokenInvalid": "Ein Bot-Token beginnt mit xoxb oder xoxp." + }, + "tokenStored": "Hinterlegt, wird nicht mehr angezeigt.", + "replaceToken": "Anderen Token eintragen", + "messageDelete": "In Slack löschen", + "messageDeleting": "Wird gelöscht", + "messageDeleted": "Gelöscht", + "messageWebhook": "Über Webhook, nicht löschbar", + "pathBot": "Versand über den Bot-Token. Nachrichten lassen sich hier wieder löschen.", + "pathWebhook": "Versand über den Webhook. Nachrichten lassen sich danach nicht mehr löschen, dafür braucht es einen Bot-Token.", + "pathNone": "Noch kein Weg hinterlegt, es geht nichts raus." } }, "filter": { diff --git a/messages/en.json b/messages/en.json index ea00cb8..b56c918 100644 --- a/messages/en.json +++ b/messages/en.json @@ -289,7 +289,13 @@ "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." + "reportRejected": "Slack rejected the message.", + "messageDeleted": "The message is deleted in Slack.", + "runUnknown": "That run does not exist.", + "runNotDeletable": "That run went through the webhook, so there is no message id.", + "runNoToken": "Without a bot token nothing can be deleted in Slack.", + "runDeleteFailed": "Slack refused the deletion.", + "tokenInvalid": "A bot token starts with xoxb or xoxp." }, "errors": { "nameRequired": "Enter a name.", @@ -726,7 +732,8 @@ "total": "Entries", "projects": "Projects", "result": "Result", - "trigger": "Triggered by" + "trigger": "Triggered by", + "message": "Message" }, "results": { "sent": "Sent", @@ -736,16 +743,30 @@ }, "fields": { "webhook": "Incoming webhook", - "includeInternal": "Include internal entries" + "includeInternal": "Include internal entries", + "botToken": "Bot token", + "channel": "Channel" }, "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." + "sendNow": "Sends the last seven days into the channel right away.", + "botToken": "From the Slack app under OAuth & Permissions, starts with xoxb. A bot token lets messages be deleted later.", + "channel": "Channel name or id, for example logbuch or C01234567." }, "errors": { - "webhookInvalid": "That does not look like a Slack webhook address." - } + "webhookInvalid": "That does not look like a Slack webhook address.", + "tokenInvalid": "A bot token starts with xoxb or xoxp." + }, + "tokenStored": "Stored, no longer shown.", + "replaceToken": "Enter a different token", + "messageDelete": "Delete in Slack", + "messageDeleting": "Deleting", + "messageDeleted": "Deleted", + "messageWebhook": "Sent by webhook, cannot be deleted", + "pathBot": "Sending through the bot token. Messages can be deleted from here.", + "pathWebhook": "Sending through the webhook. Messages cannot be deleted afterwards, that needs a bot token.", + "pathNone": "No route stored yet, nothing goes out." } }, "filter": { diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 8aaa52d..826bbd1 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -2,6 +2,7 @@ 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 { MessageDeleteButton } from '~/components/admin/MessageDeleteButton' import { SlackForm } from '~/components/admin/SlackForm' import { cellClass, headCellClass } from '~/components/admin/styles' import { SectionLabel } from '~/components/ui/SectionLabel' @@ -47,7 +48,9 @@ export default async function AdminSettingsPage() {
{t('slack')} @@ -76,6 +79,7 @@ export default async function AdminSettingsPage() { {t('columns.projects')} {t('columns.result')} {t('columns.trigger')} + {t('columns.message')} @@ -97,6 +101,13 @@ export default async function AdminSettingsPage() { {t(`results.${resultKey(run)}`)} {run.trigger} + + {run.deletedAt + ? t('messageDeleted') + : run.messageTs + ? + : run.sent ? t('messageWebhook') : '-'} + ))} diff --git a/src/components/admin/MessageDeleteButton.tsx b/src/components/admin/MessageDeleteButton.tsx new file mode 100644 index 0000000..56d8622 --- /dev/null +++ b/src/components/admin/MessageDeleteButton.tsx @@ -0,0 +1,35 @@ +'use client' + +import { useActionState } from 'react' +import { useTranslations } from 'next-intl' +import { TbTrash } from 'react-icons/tb' +import { AdminSubmit } from './AdminSubmit' +import { errorClass } from './styles' +import { deleteSlackMessage } from '~/lib/admin-actions' +import { emptyAdminFormState } from '~/lib/admin-forms' + +export type MessageDeleteButtonProps = { + runId: string +} + +export function MessageDeleteButton({ runId }: MessageDeleteButtonProps) { + const t = useTranslations('admin.settings') + const messages = useTranslations('admin.messages') + const [state, formAction] = useActionState(deleteSlackMessage, emptyAdminFormState) + + if (state.status === 'ok') { + return {t('messageDeleted')} + } + + return ( +
+ + }> + {t('messageDelete')} + + {state.status === 'error' && state.message ? ( + {messages(state.message)} + ) : null} +
+ ) +} diff --git a/src/components/admin/SlackForm.tsx b/src/components/admin/SlackForm.tsx index 27d98e3..f173586 100644 --- a/src/components/admin/SlackForm.tsx +++ b/src/components/admin/SlackForm.tsx @@ -12,21 +12,70 @@ import { emptyAdminFormState } from '~/lib/admin-forms' export type SlackFormProps = { hasWebhook: boolean + hasBot: boolean + channel: string fromEnv: boolean includeInternal: boolean } -export function SlackForm({ hasWebhook, fromEnv, includeInternal }: SlackFormProps) { +export function SlackForm({ hasWebhook, hasBot, channel, fromEnv, includeInternal }: SlackFormProps) { const t = useTranslations('admin.settings') const [state, formAction] = useActionState(saveSlackSettings, emptyAdminFormState) const [report, reportAction] = useActionState(sendWeeklyReportNow, emptyAdminFormState) const [replace, setReplace] = useState(!hasWebhook) + const [replaceToken, setReplaceToken] = useState(!hasBot) + const path = hasBot ? 'pathBot' : hasWebhook ? 'pathWebhook' : 'pathNone' return (
+

{t(path)}

+ + {hasBot && !replaceToken ? ( +
+ {t('fields.botToken')} + {t('tokenStored')} + + +
+ ) : ( + + + + )} + + + + + {hasWebhook && !replace ? (
{t('fields.webhook')} diff --git a/src/data/repositories/settings.ts b/src/data/repositories/settings.ts index 4f3d424..3de727f 100644 --- a/src/data/repositories/settings.ts +++ b/src/data/repositories/settings.ts @@ -2,7 +2,7 @@ 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 type SettingKey = 'slack.webhook' | 'slack.botToken' | 'slack.channel' | 'slack.includeInternal' export async function readSettings(keys: SettingKey[]): Promise> { const rows = await db.select().from(appSettings).where(inArray(appSettings.key, keys)) @@ -29,6 +29,8 @@ export type ReportRunValues = { sent: boolean trigger: string detail?: string | null + channel?: string | null + messageTs?: string | null } export async function recordReportRun(values: ReportRunValues): Promise { @@ -37,6 +39,16 @@ export async function recordReportRun(values: ReportRunValues): Promise { + const rows = await db.select().from(reportRuns).where(eq(reportRuns.id, id)).limit(1) + + return rows[0] +} + +export async function markReportRunDeleted(id: string): Promise { + await db.update(reportRuns).set({ deletedAt: new Date() }).where(eq(reportRuns.id, id)) +} + export async function listReportRuns(limit: number): Promise { return db.select().from(reportRuns).orderBy(desc(reportRuns.createdAt)).limit(limit) } diff --git a/src/data/schema/settings.ts b/src/data/schema/settings.ts index be6b706..1b1de46 100644 --- a/src/data/schema/settings.ts +++ b/src/data/schema/settings.ts @@ -16,6 +16,9 @@ export const reportRuns = pgTable('report_run', { sent: boolean('sent').notNull().default(false), trigger: text('trigger').notNull(), detail: text('detail'), + channel: text('channel'), + messageTs: text('message_ts'), + deletedAt: timestamp('deleted_at', { withTimezone: true }), }) export type AppSetting = typeof appSettings.$inferSelect diff --git a/src/lib/admin-actions.ts b/src/lib/admin-actions.ts index 1e65a24..4f53877 100644 --- a/src/lib/admin-actions.ts +++ b/src/lib/admin-actions.ts @@ -6,7 +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 { performDeleteMessage, performSaveSlack, performSendReport } from './admin-settings' import { performGrantRole, performRevokeRole, performSetUserAdmin } from './admin-users' import type { AdminFormState } from './admin-forms' import { @@ -159,3 +159,12 @@ export async function sendWeeklyReportNow(_state: AdminFormState): Promise { + const viewer = await requireAdmin() + const result = await performDeleteMessage(viewer, formData) + + revalidatePath(adminSettingsPath) + + return result +} diff --git a/src/lib/admin-settings.ts b/src/lib/admin-settings.ts index 20cd2a4..aa7dbf1 100644 --- a/src/lib/admin-settings.ts +++ b/src/lib/admin-settings.ts @@ -1,22 +1,40 @@ import { recordAudit } from '~/data/repositories/audit' -import { clearSetting, writeSetting } from '~/data/repositories/settings' -import { invalid, readField, readFlag, type AdminFormState } from './admin-forms' +import { + clearSetting, + findReportRun, + markReportRunDeleted, + readSettings, + writeSetting, +} from '~/data/repositories/settings' +import { invalid, isUuid, readField, readFlag, type AdminFormState } from './admin-forms' import { runWeeklyReport } from './report-settings' +import { deleteFromSlack } from './slack' import type { Viewer } from './auth-access' function looksLikeWebhook(value: string): boolean { return value === '' || /^https:\/\/hooks\.slack\.com\/services\/[\w/+-]+$/u.test(value) } +function looksLikeToken(value: string): boolean { + return value === '' || /^xox[bp]-[\w-]+$/u.test(value) +} + export async function performSaveSlack(viewer: Viewer, formData: FormData): Promise { const webhook = readField(formData, 'webhook') + const token = readField(formData, 'botToken') + const channel = readField(formData, 'channel') const keep = readFlag(formData, 'keep') + const keepToken = readFlag(formData, 'keepToken') const includeInternal = readFlag(formData, 'includeInternal') if (!keep && !looksLikeWebhook(webhook)) { return invalid('webhookInvalid', undefined, { webhook: 'webhookInvalid' }) } + if (!keepToken && !looksLikeToken(token)) { + return invalid('tokenInvalid', undefined, { botToken: 'tokenInvalid' }) + } + if (!keep) { if (webhook === '') { await clearSetting('slack.webhook') @@ -25,6 +43,20 @@ export async function performSaveSlack(viewer: Viewer, formData: FormData): Prom } } + if (!keepToken) { + if (token === '') { + await clearSetting('slack.botToken') + } else { + await writeSetting('slack.botToken', token) + } + } + + if (channel === '') { + await clearSetting('slack.channel') + } else { + await writeSetting('slack.channel', channel) + } + await writeSetting('slack.includeInternal', includeInternal ? 'true' : 'false') await recordAudit({ @@ -33,7 +65,12 @@ export async function performSaveSlack(viewer: Viewer, formData: FormData): Prom action: 'settings.slack', entity: 'setting', entityId: 'slack', - data: { webhookSet: keep ? 'unchanged' : webhook !== '', includeInternal }, + data: { + webhookSet: keep ? 'unchanged' : webhook !== '', + botTokenSet: keepToken ? 'unchanged' : token !== '', + channel, + includeInternal, + }, }) return { status: 'ok', message: 'slackSaved' } @@ -57,3 +94,47 @@ export async function performSendReport(viewer: Viewer, origin: string): Promise return invalid(outcome.reason === 'nothing_published' ? 'reportEmpty' : outcome.reason === 'not_configured' ? 'reportNoWebhook' : 'reportRejected') } + +export async function performDeleteMessage(viewer: Viewer, formData: FormData): Promise { + const id = readField(formData, 'runId') + + if (!isUuid(id)) { + return invalid('runUnknown') + } + + const run = await findReportRun(id) + + if (!run) { + return invalid('runUnknown') + } + + if (!run.channel || !run.messageTs) { + return invalid('runNotDeletable') + } + + const values = await readSettings(['slack.botToken']) + const token = values.get('slack.botToken')?.trim() + + if (!token) { + return invalid('runNoToken') + } + + const result = await deleteFromSlack(token, run.channel, run.messageTs) + + if (!result.ok) { + return invalid('runDeleteFailed') + } + + await markReportRunDeleted(run.id) + + await recordAudit({ + actorId: viewer.id, + actorLabel: viewer.email, + action: 'settings.reportDelete', + entity: 'setting', + entityId: run.id, + data: { channel: run.channel, messageTs: run.messageTs }, + }) + + return { status: 'ok', message: 'messageDeleted' } +} diff --git a/src/lib/report-settings.ts b/src/lib/report-settings.ts index 301dc70..7190399 100644 --- a/src/lib/report-settings.ts +++ b/src/lib/report-settings.ts @@ -1,12 +1,14 @@ import { readSettings, recordReportRun } from '~/data/repositories/settings' -import { sendToSlack } from './slack' +import { sendToSlack, slackWebhook, type SlackTarget } from './slack' import { buildWeeklyReport, reportDays } from './weekly-report' import type { ReportRun } from '~/data/schema' import type { ViewerScope } from '~/domain/types' export type SlackConfig = { - webhook: string | undefined + target: SlackTarget | undefined fromEnv: boolean + hasBot: boolean + channel: string includeInternal: boolean } @@ -17,13 +19,24 @@ export type ReportOutcome = { } export async function readSlackConfig(): Promise { - const values = await readSettings(['slack.webhook', 'slack.includeInternal']) + const values = await readSettings(['slack.webhook', 'slack.botToken', 'slack.channel', 'slack.includeInternal']) + const token = values.get('slack.botToken')?.trim() + const channel = values.get('slack.channel')?.trim() ?? '' const stored = values.get('slack.webhook')?.trim() - const env = process.env.SLACK_WEBHOOK_URL?.trim() + const env = slackWebhook() + const webhook = stored || env + + const target: SlackTarget | undefined = token && channel !== '' + ? { kind: 'bot', token, channel } + : webhook + ? { kind: 'webhook', url: webhook } + : undefined return { - webhook: stored || env || undefined, - fromEnv: !stored && Boolean(env), + target, + fromEnv: target?.kind === 'webhook' && !stored, + hasBot: Boolean(token), + channel, includeInternal: values.get('slack.includeInternal') !== 'false', } } @@ -65,24 +78,30 @@ export async function runWeeklyReport(args: { } } - if (!config.webhook) { + if (!config.target) { return { run: await recordReportRun({ ...base, sent: false, detail: 'not_configured' }), reason: 'not_configured', } } - const result = await sendToSlack(report.message, config.webhook) + const result = await sendToSlack(report.message, config.target) if (!result.ok) { - const detail = result.detail ?? result.reason - return { - run: await recordReportRun({ ...base, sent: false, detail }), + run: await recordReportRun({ ...base, sent: false, detail: result.detail }), reason: 'rejected', - detail, + detail: result.detail, } } - return { run: await recordReportRun({ ...base, sent: true }), reason: 'sent' } + return { + run: await recordReportRun({ + ...base, + sent: true, + channel: result.sent.channel, + messageTs: result.sent.messageTs, + }), + reason: 'sent', + } } diff --git a/src/lib/slack.ts b/src/lib/slack.ts index 92003f8..24c4a4a 100644 --- a/src/lib/slack.ts +++ b/src/lib/slack.ts @@ -1,8 +1,17 @@ import type { SlackMessage } from './weekly-report' +export type SlackTarget = + | { kind: 'bot', token: string, channel: string } + | { kind: 'webhook', url: string } + +export type SlackSent = { + channel: string | null + messageTs: string | null +} + export type SlackResult = - | { ok: true } - | { ok: false, reason: 'not_configured' | 'rejected', detail?: string } + | { ok: true, sent: SlackSent } + | { ok: false, detail: string } export function slackWebhook(): string | undefined { const url = process.env.SLACK_WEBHOOK_URL?.trim() @@ -10,20 +19,62 @@ export function slackWebhook(): string | undefined { return url === '' ? undefined : url } -export async function sendToSlack(message: SlackMessage, webhook = slackWebhook()): Promise { - if (!webhook) { - return { ok: false, reason: 'not_configured' } +async function callApi(token: string, method: string, body: unknown): Promise<{ ok: boolean, data: Record }> { + const response = await fetch(`https://slack.com/api/${method}`, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json; charset=utf-8', + }, + body: JSON.stringify(body), + }) + + const data = await response.json().catch(() => ({})) as Record + + return { ok: response.ok && data.ok === true, data } +} + +export async function sendToSlack(message: SlackMessage, target: SlackTarget): Promise { + if (target.kind === 'bot') { + const { ok, data } = await callApi(target.token, 'chat.postMessage', { + channel: target.channel, + text: message.text, + blocks: message.blocks, + unfurl_links: false, + }) + + if (!ok) { + return { ok: false, detail: String(data.error ?? 'unknown_error') } + } + + return { + ok: true, + sent: { + channel: typeof data.channel === 'string' ? data.channel : target.channel, + messageTs: typeof data.ts === 'string' ? data.ts : null, + }, + } } - const response = await fetch(webhook, { + const response = await fetch(target.url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(message), }) if (!response.ok) { - return { ok: false, reason: 'rejected', detail: `${response.status} ${await response.text()}` } + return { ok: false, detail: `${response.status} ${await response.text()}` } } - return { ok: true } + return { ok: true, sent: { channel: null, messageTs: null } } +} + +export async function deleteFromSlack(token: string, channel: string, messageTs: string): Promise { + const { ok, data } = await callApi(token, 'chat.delete', { channel, ts: messageTs }) + + if (!ok) { + return { ok: false, detail: String(data.error ?? 'unknown_error') } + } + + return { ok: true, sent: { channel, messageTs } } } diff --git a/tests/lib/report-settings.test.ts b/tests/lib/report-settings.test.ts index 87f12b7..2ce1081 100644 --- a/tests/lib/report-settings.test.ts +++ b/tests/lib/report-settings.test.ts @@ -3,6 +3,8 @@ 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' +import { makeBrand, makeProject } from '../support/admin' +import { makePost } from '../support/entries' const origin = 'https://logbuch.nyo.de' @@ -19,7 +21,8 @@ describe('readSlackConfig', () => { const config = await readSlackConfig() - expect(config.webhook).toContain('verwaltung') + expect(config.target).toMatchObject({ kind: 'webhook' }) + expect(config.target && 'url' in config.target ? config.target.url : '').toContain('verwaltung') expect(config.fromEnv).toBe(false) }) @@ -55,3 +58,66 @@ describe('runWeeklyReport', () => { expect(runs[0]).toMatchObject({ sent: false, total: 0, trigger: 'test', detail: 'nothing_published' }) }) }) + +describe('Bot-Token', () => { + it('nimmt Bot-Token und Kanal vor dem Webhook', async () => { + await writeSetting('slack.webhook', 'https://hooks.slack.com/services/a/b/c') + await writeSetting('slack.botToken', 'xoxb-1234') + await writeSetting('slack.channel', 'logbuch') + + const config = await readSlackConfig() + + expect(config.target).toEqual({ kind: 'bot', token: 'xoxb-1234', channel: 'logbuch' }) + expect(config.hasBot).toBe(true) + }) + + it('braucht zum Bot-Token einen Kanal', async () => { + await writeSetting('slack.botToken', 'xoxb-1234') + + const config = await readSlackConfig() + + expect(config.target).toBeUndefined() + expect(config.hasBot).toBe(true) + }) + + it('merkt sich Kanal und Nachrichtenkennung eines Versands', async () => { + const brand = await makeBrand() + const project = await makeProject(brand.id) + const post = await makePost(project.id, { number: 1, slug: 'eins', status: 'published', publishAt: new Date() }) + + expect(post.id).toBeDefined() + + await writeSetting('slack.botToken', 'xoxb-1234') + await writeSetting('slack.channel', 'logbuch') + + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: true, channel: 'C999', ts: '1785600000.000100' }), + { headers: { 'content-type': 'application/json' } }, + ))) + + const outcome = await runWeeklyReport({ origin, trigger: 'test' }) + + expect(outcome.reason).toBe('sent') + expect(outcome.run).toMatchObject({ sent: true, channel: 'C999', messageTs: '1785600000.000100' }) + }) + + it('vermerkt eine Ablehnung mit dem Grund von Slack', async () => { + const brand = await makeBrand() + const project = await makeProject(brand.id) + + await makePost(project.id, { number: 1, slug: 'eins', status: 'published', publishAt: new Date() }) + + await writeSetting('slack.botToken', 'xoxb-1234') + await writeSetting('slack.channel', 'logbuch') + + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: false, error: 'channel_not_found' }), + { headers: { 'content-type': 'application/json' } }, + ))) + + const outcome = await runWeeklyReport({ origin, trigger: 'test' }) + + expect(outcome.reason).toBe('rejected') + expect(outcome.run).toMatchObject({ sent: false, detail: 'channel_not_found' }) + }) +})