From 3e5fa10717465c66b7f566d628876d2228c72599 Mon Sep 17 00:00:00 2001 From: Shubham Gupta Date: Thu, 23 Jul 2026 17:36:23 +0530 Subject: [PATCH 1/2] Implement custom Slack notification trigger on Page views and addded new table for user's completion data --- app/Entities/Controllers/PageController.php | 9 +++ ...1_113753_create_page_completions_table.php | 35 +++++++++++ dev/docker/Dockerfile | 18 +++--- dev/docker/entrypoint.app.sh | 5 +- dev/docker/entrypoint.node.sh | 1 - docker-compose.yml | 7 ++- package-lock.json | 2 +- public/custom_slack_notify.js | 58 +++++++++++++++++++ 8 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 database/migrations/2026_07_21_113753_create_page_completions_table.php create mode 100644 public/custom_slack_notify.js diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index 8778560e275..bb40d3a70f8 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -160,6 +160,14 @@ public function show(string $bookSlug, string $pageSlug) View::incrementFor($page); $this->setPageTitle($page->getShortName()); + $isCompleted = false; + if (auth()->check()) { + $isCompleted = \DB::table('page_completions') + ->where('user_id', auth()->id()) + ->where('page_id', $page->id) + ->exists(); + } + return view('pages.show', [ 'page' => $page, 'book' => $page->book, @@ -171,6 +179,7 @@ public function show(string $bookSlug, string $pageSlug) 'next' => $nextPreviousLocator->getNext(), 'previous' => $nextPreviousLocator->getPrevious(), 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($page), + 'isCompleted' => $isCompleted, ]); } diff --git a/database/migrations/2026_07_21_113753_create_page_completions_table.php b/database/migrations/2026_07_21_113753_create_page_completions_table.php new file mode 100644 index 00000000000..ee93e6b6778 --- /dev/null +++ b/database/migrations/2026_07_21_113753_create_page_completions_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->unsignedBigInteger('page_id'); + $table->timestamps(); + + $table->unique(['user_id', 'page_id']); + }); + } + + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('page_completions'); + } +}; \ No newline at end of file diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile index b64899f797f..a9d4bbda4af 100644 --- a/dev/docker/Dockerfile +++ b/dev/docker/Dockerfile @@ -3,15 +3,15 @@ FROM php:8.3-apache # Install additional dependencies RUN apt-get update && \ apt-get install -y \ - git \ - zip \ - unzip \ - libfreetype-dev \ - libjpeg62-turbo-dev \ - libldap2-dev \ - libpng-dev \ - libzip-dev \ - wait-for-it && \ + git \ + zip \ + unzip \ + libfreetype-dev \ + libjpeg62-turbo-dev \ + libldap2-dev \ + libpng-dev \ + libzip-dev \ + wait-for-it && \ rm -rf /var/lib/apt/lists/* # Mark /app as safe for Git >= 2.35.2 diff --git a/dev/docker/entrypoint.app.sh b/dev/docker/entrypoint.app.sh index b09edda8863..729a0893ae0 100755 --- a/dev/docker/entrypoint.app.sh +++ b/dev/docker/entrypoint.app.sh @@ -7,9 +7,12 @@ env if [[ -n "$1" ]]; then exec "$@" else + mkdir -p /app/storage/framework/{cache/data,sessions,testing,views} + chown -R www-data /app/storage/framework || true + chown -R www-data /app/bootstrap/cache || true + chown -R www-data /app/public/uploads || true composer install wait-for-it db:3306 -t 45 php artisan migrate --database=mysql --force - chown -R www-data storage public/uploads bootstrap/cache exec apache2-foreground fi diff --git a/dev/docker/entrypoint.node.sh b/dev/docker/entrypoint.node.sh index a8f33fd3d93..c2e8f51e231 100755 --- a/dev/docker/entrypoint.node.sh +++ b/dev/docker/entrypoint.node.sh @@ -3,6 +3,5 @@ set -e npm install -npm rebuild node-sass SHELL=/bin/sh exec npm run watch diff --git a/docker-compose.yml b/docker-compose.yml index f4c3a64c664..b5d93acf70f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,10 +3,11 @@ volumes: db: {} + storage_framework: {} services: db: - image: mysql:8.4 + image: mysql:8.0 environment: MYSQL_DATABASE: bookstack-dev MYSQL_USER: bookstack-test @@ -35,14 +36,14 @@ services: - ${DEV_PORT:-8080}:80 volumes: - ./:/app + - storage_framework:/app/storage/framework - ./dev/docker/php/conf.d/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini entrypoint: /app/dev/docker/entrypoint.app.sh extra_hosts: - - "host.docker.internal:host-gateway" + - "host.docker.internal:host-gateway" node: image: node:22-alpine working_dir: /app - user: node volumes: - ./:/app entrypoint: /app/dev/docker/entrypoint.node.sh diff --git a/package-lock.json b/package-lock.json index e8a1493d42f..042b3bda020 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "bookstack", + "name": "app", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/public/custom_slack_notify.js b/public/custom_slack_notify.js new file mode 100644 index 00000000000..181267cf076 --- /dev/null +++ b/public/custom_slack_notify.js @@ -0,0 +1,58 @@ +(function () { + + document.addEventListener("click", function (event) { + const notifyButton = event.target.closest("#slack-notify-button"); + + if (!notifyButton) { + console.log("Slack notify button not found for this click target:", event.target); + return; + } + + event.preventDefault(); + + const pageId = notifyButton.getAttribute("data-page-id"); + const pageName = notifyButton.getAttribute("data-page-name"); + const pageUrl = notifyButton.getAttribute("data-page-url"); + const tokenMeta = document.querySelector('meta[name="token"]'); + const csrfToken = tokenMeta ? tokenMeta.content : ""; + + notifyButton.disabled = true; + notifyButton.innerText = "Completing..."; + + fetch("/ajax/training-complete", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-TOKEN": csrfToken, + }, + body: JSON.stringify({ + page_id: pageId, + page_name: pageName, + page_url: pageUrl, + }), + }) + .then((response) => { + if (!response.ok) { + throw new Error("HTTP Status " + response.status); + } + return response.json(); + }) + .then((data) => { + if (data.status === "ok" || data.status === "already_completed") { + notifyButton.innerText = "Completed"; + notifyButton.disabled = true; + notifyButton.style.backgroundColor = "#2e7d32"; + notifyButton.style.cursor = "not-allowed"; + } else { + alert("Error: " + (data.message || "Failed to send notification.")); + notifyButton.disabled = false; + notifyButton.innerText = "Mark as Complete"; + } + }) + .catch((error) => { + console.error("Slack Notification Error:", error); + notifyButton.disabled = false; + notifyButton.innerText = "Mark as Complete"; + }); + }); +})(); \ No newline at end of file From f39482bebc04b34cf871806c2784ef5c601c1606 Mon Sep 17 00:00:00 2001 From: Shubham Gupta Date: Thu, 23 Jul 2026 17:42:23 +0530 Subject: [PATCH 2/2] add training completion tracking with Slack notifications to my-org-theme --- themes/.gitignore | 3 + themes/my-org-theme/functions.php | 127 ++++++++++++ themes/my-org-theme/pages/show.blade.php | 241 +++++++++++++++++++++++ 3 files changed, 371 insertions(+) create mode 100644 themes/my-org-theme/functions.php create mode 100644 themes/my-org-theme/pages/show.blade.php diff --git a/themes/.gitignore b/themes/.gitignore index d6b7ef32c84..c158701f48b 100755 --- a/themes/.gitignore +++ b/themes/.gitignore @@ -1,2 +1,5 @@ * !.gitignore + +!my-org-theme/ +!my-org-theme/** \ No newline at end of file diff --git a/themes/my-org-theme/functions.php b/themes/my-org-theme/functions.php new file mode 100644 index 00000000000..c93a3e26f9a --- /dev/null +++ b/themes/my-org-theme/functions.php @@ -0,0 +1,127 @@ +post('/ajax/training-complete', function (Request $request) { +$functionName = '[TrainingCompleteHandler]'; + +try { + + $userId = auth()->id(); + if (!$userId) { + Log::error("{$functionName} Unauthenticated access attempt blocked."); + return response()->json([ + 'status' => 'error', + 'message' => 'Unauthorized: Valid user session required.' + ], 401); + } + + + try { + $validated = $request->validate([ + 'page_name' => 'required|string|max:255', + 'page_url' => 'required|url', + 'page_id' => 'required|integer|min:1', + ]); + } catch (ValidationException $e) { + Log::error("{$functionName} Validation failed for User ID {$userId}: " . json_encode($e->errors())); + return response()->json([ + 'status' => 'error', + 'message' => 'Invalid input data.', + 'errors' => $e->errors() + ], 422); + } + + $pageId = (int) $validated['page_id']; + + try { + $alreadyCompleted = DB::table('page_completions') + ->where('user_id', $userId) + ->where('page_id', $pageId) + ->exists(); + + if ($alreadyCompleted) { + return response()->json([ + 'status' => 'already_completed', + 'message' => 'You have already completed this training!' + ]); + } + } catch (Throwable $e) { + Log::error("{$functionName} Database read query failed for User ID {$userId}, Page ID {$pageId}: " . $e->getMessage()); + return response()->json([ + 'status' => 'error', + 'message' => 'Database operation failed.' + ], 500); + } + + $webhookUrl = config('services.slack.webhook_url') ?: env('SLACK_WEBHOOK_URL'); + if (empty($webhookUrl)) { + Log::error("{$functionName} Slack Webhook URL is missing from configuration or .env file."); + return response()->json([ + 'status' => 'error', + 'message' => 'Slack notification service is not configured.' + ], 500); + } + + $user = auth()->user(); + $userName = $user ? $user->name : 'Unknown User'; + + $payload = [ + "text" => ":blue_book: *Training Completed!* :blue_book:\n" . + "*User:* " . $userName . "\n" . + "*Page:* <" . $validated['page_url'] . "|" . $validated['page_name'] . ">\n" . + "*Time:* " . now()->setTimezone('Asia/Kolkata')->format('Y-m-d h:i:s A') + ]; + + try { + $response = Http::timeout(100)->withHeaders([ + 'Content-Type' => 'application/json' + ])->post($webhookUrl, $payload); + } catch (Throwable $e) { + Log::error("{$functionName} Network connection to Slack failed for User ID {$userId}: " . $e->getMessage()); + return response()->json([ + 'status' => 'error', + 'message' => 'Unable to connect to notification service.' + ], 504); + } + + if (!$response->successful()) { + Log::error("{$functionName} Slack Webhook API failed with Status {$response->status()}: " . $response->body()); + return response()->json([ + 'status' => 'error', + 'message' => 'Failed to deliver notification to Slack.' + ], 502); + } + + try { + DB::table('page_completions')->insert([ + 'user_id' => $userId, + 'page_id' => $pageId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return response()->json(['status' => 'ok']); + + } catch (Throwable $e) { + Log::error("{$functionName} Database insert failed for User ID {$userId}, Page ID {$pageId}: " . $e->getMessage()); + return response()->json([ + 'status' => 'error', + 'message' => 'Failed to save completion status.' + ], 500); + } + + } catch (Throwable $e) { + Log::error("{$functionName} Unexpected System Error: " . $e->getMessage() . "\n" . $e->getTraceAsString()); + return response()->json([ + 'status' => 'error', + 'message' => 'An unexpected server error occurred.' + ], 500); +} + +}); \ No newline at end of file diff --git a/themes/my-org-theme/pages/show.blade.php b/themes/my-org-theme/pages/show.blade.php new file mode 100644 index 00000000000..1bf8659831f --- /dev/null +++ b/themes/my-org-theme/pages/show.blade.php @@ -0,0 +1,241 @@ +@extends('layouts.tri') + +@push('social-meta') + +@endpush + +@include('entities.body-tag-classes', ['entity' => $page]) + +@section('body') + + + +
+
+ @include('pages.parts.page-display') +
+ + @if(auth()->check()) +
+ @if($isCompleted) + + @else + + @endif +
+ + + + +@endif + + @include('pages.parts.pointer', ['page' => $page]) +
+ + @include('entities.sibling-navigation', ['next' => $next, 'previous' => $previous]) + + @if ($commentTree->enabled()) + @if(($previous || $next)) + + @endif + + + @endif +@stop + +@section('left') + @if($page->tags->count() > 0) +
+ @include('entities.tag-list', ['entity' => $page]) +
+ @endif + + @if ($page->attachments->count() > 0) +
+
{{ trans('entities.pages_attachments') }}
+
+ @include('attachments.list', ['attachments' => $page->attachments]) +
+
+ @endif + + @if (isset($pageNav) && count($pageNav)) + + @endif + + @include('entities.book-tree', ['book' => $book, 'sidebarTree' => $sidebarTree]) +@stop + +@section('right') +
+
{{ trans('common.details') }}
+ +
+ +
+
{{ trans('common.actions') }}
+ + +
+@stop + + + + +@push('body-end') + +@endpush \ No newline at end of file