Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/Entities/Controllers/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -171,6 +179,7 @@ public function show(string $bookSlug, string $pageSlug)
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($page),
'isCompleted' => $isCompleted,
]);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('page_completions', function (Blueprint $table) {
$table->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');
}
};
18 changes: 9 additions & 9 deletions dev/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion dev/docker/entrypoint.app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 0 additions & 1 deletion dev/docker/entrypoint.node.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@
set -e

npm install
npm rebuild node-sass

SHELL=/bin/sh exec npm run watch
7 changes: 4 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 58 additions & 0 deletions public/custom_slack_notify.js
Original file line number Diff line number Diff line change
@@ -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";
});
});
})();
3 changes: 3 additions & 0 deletions themes/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
*
!.gitignore

!my-org-theme/
!my-org-theme/**
127 changes: 127 additions & 0 deletions themes/my-org-theme/functions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

use Illuminate\Support\Facades\Route;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

Route::middleware(['web', 'auth'])->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);
}

});
Loading