Initial plugin scaffold: lesson scheduling WordPress plugin
Some checks failed
CI / Coding Standards (push) Failing after 2m31s
CI / PHPStan (push) Failing after 50s
CI / Tests (PHP 8.1) (push) Successful in 50s
CI / Tests (PHP 8.2) (push) Successful in 48s
CI / Tests (PHP 8.3) (push) Successful in 40s
CI / No Debug Code (push) Successful in 2s
Some checks failed
CI / Coding Standards (push) Failing after 2m31s
CI / PHPStan (push) Failing after 50s
CI / Tests (PHP 8.1) (push) Successful in 50s
CI / Tests (PHP 8.2) (push) Successful in 48s
CI / Tests (PHP 8.3) (push) Successful in 40s
CI / No Debug Code (push) Successful in 2s
- Custom DB tables for availability slots and lesson bookings - Instructor (wp-admin) and student (front-end) roles with custom capabilities - REST API under us-scheduler/v1 for availability CRUD and booking - [us_booking] and [us_student_login] shortcodes for student front end - PHPUnit + Brain\Monkey unit test suite (29 tests) - Gitea Actions CI: lint, PHPStan, tests on PHP 8.1/8.2/8.3, no-debug check - Feature docs under docs/features/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
61
src/Admin/AdminMenu.php
Normal file
61
src/Admin/AdminMenu.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Admin;
|
||||
|
||||
use Unsupervised\Schedular\Data\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Data\BookingRepository;
|
||||
use Unsupervised\Schedular\Roles\RoleManager;
|
||||
|
||||
class AdminMenu
|
||||
{
|
||||
private AvailabilityController $availabilityController;
|
||||
private LessonController $lessonController;
|
||||
|
||||
public function __construct(AvailabilityRepository $availability, BookingRepository $bookings)
|
||||
{
|
||||
$this->availabilityController = new AvailabilityController($availability);
|
||||
$this->lessonController = new LessonController($bookings);
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
add_action('admin_menu', [$this, 'addPages']);
|
||||
}
|
||||
|
||||
public function addPages(): void
|
||||
{
|
||||
// Admin-only dashboard: all upcoming lessons
|
||||
add_menu_page(
|
||||
__('Scheduler', 'unsupervised-schedular'),
|
||||
__('Scheduler', 'unsupervised-schedular'),
|
||||
'manage_options',
|
||||
'us-scheduler',
|
||||
[$this->lessonController, 'renderAdminDashboard'],
|
||||
'dashicons-calendar-alt',
|
||||
30
|
||||
);
|
||||
|
||||
// Instructor: manage their own availability
|
||||
add_menu_page(
|
||||
__('My Availability', 'unsupervised-schedular'),
|
||||
__('My Availability', 'unsupervised-schedular'),
|
||||
RoleManager::CAP_MANAGE_AVAILABILITY,
|
||||
'us-availability',
|
||||
[$this->availabilityController, 'renderPage'],
|
||||
'dashicons-clock',
|
||||
31
|
||||
);
|
||||
|
||||
// Instructor: view their upcoming lessons
|
||||
add_menu_page(
|
||||
__('My Lessons', 'unsupervised-schedular'),
|
||||
__('My Lessons', 'unsupervised-schedular'),
|
||||
RoleManager::CAP_VIEW_LESSONS,
|
||||
'us-my-lessons',
|
||||
[$this->lessonController, 'renderInstructorLessons'],
|
||||
'dashicons-welcome-learn-more',
|
||||
32
|
||||
);
|
||||
}
|
||||
}
|
||||
54
src/Admin/AvailabilityController.php
Normal file
54
src/Admin/AvailabilityController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Admin;
|
||||
|
||||
use Unsupervised\Schedular\Data\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Model\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Roles\RoleManager;
|
||||
|
||||
class AvailabilityController
|
||||
{
|
||||
public function __construct(private AvailabilityRepository $repository) {}
|
||||
|
||||
public function renderPage(): void
|
||||
{
|
||||
if (! current_user_can(RoleManager::CAP_MANAGE_AVAILABILITY)) {
|
||||
wp_die(esc_html__('You do not have permission to manage availability.', 'unsupervised-schedular'));
|
||||
}
|
||||
|
||||
$instructorId = get_current_user_id();
|
||||
|
||||
if (isset($_POST['usc_action']) && check_admin_referer('usc_availability_action')) {
|
||||
$this->handleFormAction($instructorId);
|
||||
}
|
||||
|
||||
$slots = $this->repository->findByInstructor($instructorId);
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/availability.php';
|
||||
}
|
||||
|
||||
private function handleFormAction(int $instructorId): void
|
||||
{
|
||||
$action = sanitize_key($_POST['usc_action'] ?? '');
|
||||
|
||||
if ($action === 'add') {
|
||||
$startDt = sanitize_text_field($_POST['start_dt'] ?? '');
|
||||
$endDt = sanitize_text_field($_POST['end_dt'] ?? '');
|
||||
|
||||
if ($startDt !== '' && $endDt !== '') {
|
||||
$this->repository->insert(new AvailabilitySlot($instructorId, $startDt, $endDt));
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$slotId = absint($_POST['slot_id'] ?? 0);
|
||||
if ($slotId > 0) {
|
||||
$slot = $this->repository->findById($slotId);
|
||||
if ($slot && $slot->instructorId === $instructorId) {
|
||||
$this->repository->delete($slotId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/Admin/LessonController.php
Normal file
34
src/Admin/LessonController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Admin;
|
||||
|
||||
use Unsupervised\Schedular\Data\BookingRepository;
|
||||
use Unsupervised\Schedular\Roles\RoleManager;
|
||||
|
||||
class LessonController
|
||||
{
|
||||
public function __construct(private BookingRepository $repository) {}
|
||||
|
||||
public function renderAdminDashboard(): void
|
||||
{
|
||||
if (! current_user_can('manage_options')) {
|
||||
wp_die(esc_html__('You do not have permission to view this page.', 'unsupervised-schedular'));
|
||||
}
|
||||
|
||||
$lessons = $this->repository->findAllUpcoming();
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
|
||||
}
|
||||
|
||||
public function renderInstructorLessons(): void
|
||||
{
|
||||
if (! current_user_can(RoleManager::CAP_VIEW_LESSONS)) {
|
||||
wp_die(esc_html__('You do not have permission to view lessons.', 'unsupervised-schedular'));
|
||||
}
|
||||
|
||||
$lessons = $this->repository->findUpcomingForInstructor(get_current_user_id());
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
|
||||
}
|
||||
}
|
||||
102
src/Api/AvailabilityEndpoint.php
Normal file
102
src/Api/AvailabilityEndpoint.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Api;
|
||||
|
||||
use Unsupervised\Schedular\Data\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Model\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Roles\RoleManager;
|
||||
|
||||
class AvailabilityEndpoint
|
||||
{
|
||||
public function __construct(private AvailabilityRepository $repository) {}
|
||||
|
||||
public function registerRoutes(string $namespace): void
|
||||
{
|
||||
register_rest_route($namespace, '/availability', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'index'],
|
||||
'permission_callback' => [$this, 'canBook'],
|
||||
'args' => [
|
||||
'instructor_id' => ['type' => 'integer', 'default' => 0],
|
||||
'from' => ['type' => 'string', 'default' => ''],
|
||||
'to' => ['type' => 'string', 'default' => ''],
|
||||
],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create'],
|
||||
'permission_callback' => [$this, 'canManage'],
|
||||
'args' => [
|
||||
'start_dt' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'end_dt' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/availability/(?P<id>\d+)', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::DELETABLE,
|
||||
'callback' => [$this, 'delete'],
|
||||
'permission_callback' => [$this, 'canManage'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(\WP_REST_Request $request): \WP_REST_Response
|
||||
{
|
||||
$slots = $this->repository->findAvailable(
|
||||
(int) $request->get_param('instructor_id'),
|
||||
(string) $request->get_param('from'),
|
||||
(string) $request->get_param('to'),
|
||||
);
|
||||
|
||||
return new \WP_REST_Response(array_map(fn(AvailabilitySlot $s) => $s->toArray(), $slots), 200);
|
||||
}
|
||||
|
||||
public function create(\WP_REST_Request $request): \WP_REST_Response
|
||||
{
|
||||
$slot = new AvailabilitySlot(
|
||||
instructorId: get_current_user_id(),
|
||||
startDt: (string) $request->get_param('start_dt'),
|
||||
endDt: (string) $request->get_param('end_dt'),
|
||||
);
|
||||
|
||||
$id = $this->repository->insert($slot);
|
||||
|
||||
return new \WP_REST_Response(['id' => $id], 201);
|
||||
}
|
||||
|
||||
public function delete(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
|
||||
{
|
||||
$id = absint($request->get_param('id'));
|
||||
$slot = $this->repository->findById($id);
|
||||
|
||||
if ($slot === null) {
|
||||
return new \WP_Error('not_found', __('Slot not found.', 'unsupervised-schedular'), ['status' => 404]);
|
||||
}
|
||||
|
||||
if ($slot->instructorId !== get_current_user_id()) {
|
||||
return new \WP_Error('forbidden', __('You cannot delete this slot.', 'unsupervised-schedular'), ['status' => 403]);
|
||||
}
|
||||
|
||||
if ($slot->isBooked) {
|
||||
return new \WP_Error('slot_booked', __('Cannot delete a booked slot.', 'unsupervised-schedular'), ['status' => 409]);
|
||||
}
|
||||
|
||||
$this->repository->delete($id);
|
||||
|
||||
return new \WP_REST_Response(null, 204);
|
||||
}
|
||||
|
||||
public function canBook(): bool
|
||||
{
|
||||
return is_user_logged_in() && current_user_can(RoleManager::CAP_BOOK_LESSON);
|
||||
}
|
||||
|
||||
public function canManage(): bool
|
||||
{
|
||||
return is_user_logged_in() && current_user_can(RoleManager::CAP_MANAGE_AVAILABILITY);
|
||||
}
|
||||
}
|
||||
123
src/Api/BookingEndpoint.php
Normal file
123
src/Api/BookingEndpoint.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Api;
|
||||
|
||||
use Unsupervised\Schedular\Data\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Data\BookingRepository;
|
||||
use Unsupervised\Schedular\Model\Lesson;
|
||||
use Unsupervised\Schedular\Roles\RoleManager;
|
||||
|
||||
class BookingEndpoint
|
||||
{
|
||||
public function __construct(
|
||||
private AvailabilityRepository $availability,
|
||||
private BookingRepository $bookings,
|
||||
) {}
|
||||
|
||||
public function registerRoutes(string $namespace): void
|
||||
{
|
||||
register_rest_route($namespace, '/bookings', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'myLessons'],
|
||||
'permission_callback' => [$this, 'isLoggedIn'],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'book'],
|
||||
'permission_callback' => [$this, 'canBook'],
|
||||
'args' => [
|
||||
'slot_id' => ['type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint'],
|
||||
'notes' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/bookings/(?P<id>\d+)/status', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => [$this, 'updateStatus'],
|
||||
'permission_callback' => [$this, 'canManage'],
|
||||
'args' => [
|
||||
'status' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'enum' => Lesson::VALID_STATUSES,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function myLessons(\WP_REST_Request $request): \WP_REST_Response
|
||||
{
|
||||
$userId = get_current_user_id();
|
||||
$lessons = current_user_can(RoleManager::CAP_MANAGE_AVAILABILITY)
|
||||
? $this->bookings->findUpcomingForInstructor($userId)
|
||||
: $this->bookings->findByStudent($userId);
|
||||
|
||||
return new \WP_REST_Response(array_map(fn(Lesson $l) => $l->toArray(), $lessons), 200);
|
||||
}
|
||||
|
||||
public function book(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
|
||||
{
|
||||
$slotId = (int) $request->get_param('slot_id');
|
||||
$slot = $this->availability->findById($slotId);
|
||||
|
||||
if ($slot === null) {
|
||||
return new \WP_Error('not_found', __('Slot not found.', 'unsupervised-schedular'), ['status' => 404]);
|
||||
}
|
||||
|
||||
if ($slot->isBooked) {
|
||||
return new \WP_Error('slot_taken', __('This slot is already booked.', 'unsupervised-schedular'), ['status' => 409]);
|
||||
}
|
||||
|
||||
$lesson = new Lesson(
|
||||
slotId: $slotId,
|
||||
studentId: get_current_user_id(),
|
||||
instructorId: $slot->instructorId,
|
||||
notes: (string) $request->get_param('notes') ?: null,
|
||||
);
|
||||
|
||||
$id = $this->bookings->insert($lesson);
|
||||
$this->availability->markBooked($slotId);
|
||||
|
||||
return new \WP_REST_Response(['id' => $id, 'status' => Lesson::STATUS_PENDING], 201);
|
||||
}
|
||||
|
||||
public function updateStatus(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
|
||||
{
|
||||
$id = absint($request->get_param('id'));
|
||||
$lesson = $this->bookings->findById($id);
|
||||
|
||||
if ($lesson === null) {
|
||||
return new \WP_Error('not_found', __('Booking not found.', 'unsupervised-schedular'), ['status' => 404]);
|
||||
}
|
||||
|
||||
if ($lesson->instructorId !== get_current_user_id() && ! current_user_can('manage_options')) {
|
||||
return new \WP_Error('forbidden', __('You cannot update this booking.', 'unsupervised-schedular'), ['status' => 403]);
|
||||
}
|
||||
|
||||
$this->bookings->updateStatus($id, (string) $request->get_param('status'));
|
||||
|
||||
return new \WP_REST_Response(['id' => $id, 'status' => $request->get_param('status')], 200);
|
||||
}
|
||||
|
||||
public function isLoggedIn(): bool
|
||||
{
|
||||
return is_user_logged_in();
|
||||
}
|
||||
|
||||
public function canBook(): bool
|
||||
{
|
||||
return is_user_logged_in() && current_user_can(RoleManager::CAP_BOOK_LESSON);
|
||||
}
|
||||
|
||||
public function canManage(): bool
|
||||
{
|
||||
return is_user_logged_in() && (
|
||||
current_user_can(RoleManager::CAP_MANAGE_AVAILABILITY) || current_user_can('manage_options')
|
||||
);
|
||||
}
|
||||
}
|
||||
32
src/Api/RestRegistrar.php
Normal file
32
src/Api/RestRegistrar.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Api;
|
||||
|
||||
use Unsupervised\Schedular\Data\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Data\BookingRepository;
|
||||
|
||||
class RestRegistrar
|
||||
{
|
||||
public const NAMESPACE = 'us-scheduler/v1';
|
||||
|
||||
private AvailabilityEndpoint $availabilityEndpoint;
|
||||
private BookingEndpoint $bookingEndpoint;
|
||||
|
||||
public function __construct(AvailabilityRepository $availability, BookingRepository $bookings)
|
||||
{
|
||||
$this->availabilityEndpoint = new AvailabilityEndpoint($availability);
|
||||
$this->bookingEndpoint = new BookingEndpoint($availability, $bookings);
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
add_action('rest_api_init', [$this, 'registerRoutes']);
|
||||
}
|
||||
|
||||
public function registerRoutes(): void
|
||||
{
|
||||
$this->availabilityEndpoint->registerRoutes(self::NAMESPACE);
|
||||
$this->bookingEndpoint->registerRoutes(self::NAMESPACE);
|
||||
}
|
||||
}
|
||||
117
src/Data/AvailabilityRepository.php
Normal file
117
src/Data/AvailabilityRepository.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Data;
|
||||
|
||||
use Unsupervised\Schedular\Model\AvailabilitySlot;
|
||||
|
||||
class AvailabilityRepository
|
||||
{
|
||||
private string $table;
|
||||
|
||||
public function __construct(private \wpdb $db)
|
||||
{
|
||||
$this->table = $db->prefix . 'us_availability';
|
||||
}
|
||||
|
||||
public function insert(AvailabilitySlot $slot): int
|
||||
{
|
||||
$this->db->insert(
|
||||
$this->table,
|
||||
[
|
||||
'instructor_id' => $slot->instructorId,
|
||||
'start_dt' => $slot->startDt,
|
||||
'end_dt' => $slot->endDt,
|
||||
'is_booked' => 0,
|
||||
'created_at' => current_time('mysql'),
|
||||
],
|
||||
['%d', '%s', '%s', '%d', '%s']
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find unbooked slots, optionally filtered by instructor and date range.
|
||||
*
|
||||
* @return list<AvailabilitySlot>
|
||||
*/
|
||||
public function findAvailable(int $instructorId = 0, string $from = '', string $to = ''): array
|
||||
{
|
||||
$where = ['is_booked = 0'];
|
||||
$params = [];
|
||||
|
||||
if ($instructorId > 0) {
|
||||
$where[] = 'instructor_id = %d';
|
||||
$params[] = $instructorId;
|
||||
}
|
||||
|
||||
if ($from !== '') {
|
||||
$where[] = 'start_dt >= %s';
|
||||
$params[] = $from;
|
||||
}
|
||||
|
||||
if ($to !== '') {
|
||||
$where[] = 'end_dt <= %s';
|
||||
$params[] = $to;
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $where);
|
||||
$sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC";
|
||||
|
||||
$rows = $params
|
||||
? $this->db->get_results($this->db->prepare($sql, $params))
|
||||
: $this->db->get_results($sql);
|
||||
|
||||
return array_map(AvailabilitySlot::fromRow(...), $rows ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all slots for an instructor (booked and unbooked).
|
||||
*
|
||||
* @return list<AvailabilitySlot>
|
||||
*/
|
||||
public function findByInstructor(int $instructorId): array
|
||||
{
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY start_dt ASC",
|
||||
$instructorId
|
||||
)
|
||||
);
|
||||
|
||||
return array_map(AvailabilitySlot::fromRow(...), $rows ?? []);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?AvailabilitySlot
|
||||
{
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id)
|
||||
);
|
||||
|
||||
return $row ? AvailabilitySlot::fromRow($row) : null;
|
||||
}
|
||||
|
||||
public function markBooked(int $id): bool
|
||||
{
|
||||
return (bool) $this->db->update(
|
||||
$this->table,
|
||||
['is_booked' => 1],
|
||||
['id' => $id],
|
||||
['%d'],
|
||||
['%d']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an unbooked slot. Returns false if the slot is already booked.
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return (bool) $this->db->delete(
|
||||
$this->table,
|
||||
['id' => $id, 'is_booked' => 0],
|
||||
['%d', '%d']
|
||||
);
|
||||
}
|
||||
}
|
||||
125
src/Data/BookingRepository.php
Normal file
125
src/Data/BookingRepository.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Data;
|
||||
|
||||
use Unsupervised\Schedular\Model\Lesson;
|
||||
|
||||
class BookingRepository
|
||||
{
|
||||
private string $table;
|
||||
|
||||
public function __construct(private \wpdb $db)
|
||||
{
|
||||
$this->table = $db->prefix . 'us_lessons';
|
||||
}
|
||||
|
||||
public function insert(Lesson $lesson): int
|
||||
{
|
||||
$this->db->insert(
|
||||
$this->table,
|
||||
[
|
||||
'slot_id' => $lesson->slotId,
|
||||
'student_id' => $lesson->studentId,
|
||||
'instructor_id' => $lesson->instructorId,
|
||||
'status' => $lesson->status,
|
||||
'notes' => $lesson->notes,
|
||||
'created_at' => current_time('mysql'),
|
||||
],
|
||||
['%d', '%d', '%d', '%s', '%s', '%s']
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Lesson
|
||||
{
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id)
|
||||
);
|
||||
|
||||
return $row ? Lesson::fromRow($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming lessons for an instructor (status != cancelled, slot in the future).
|
||||
*
|
||||
* @return list<Lesson>
|
||||
*/
|
||||
public function findUpcomingForInstructor(int $instructorId): array
|
||||
{
|
||||
$avTable = str_replace('us_lessons', 'us_availability', $this->table);
|
||||
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
"SELECT l.* FROM {$this->table} l
|
||||
JOIN {$avTable} a ON a.id = l.slot_id
|
||||
WHERE l.instructor_id = %d
|
||||
AND l.status != %s
|
||||
AND a.start_dt >= %s
|
||||
ORDER BY a.start_dt ASC",
|
||||
$instructorId,
|
||||
Lesson::STATUS_CANCELLED,
|
||||
current_time('mysql')
|
||||
)
|
||||
);
|
||||
|
||||
return array_map(Lesson::fromRow(...), $rows ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* All lessons for a student.
|
||||
*
|
||||
* @return list<Lesson>
|
||||
*/
|
||||
public function findByStudent(int $studentId): array
|
||||
{
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY created_at DESC",
|
||||
$studentId
|
||||
)
|
||||
);
|
||||
|
||||
return array_map(Lesson::fromRow(...), $rows ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* All upcoming lessons across all instructors (admin view).
|
||||
*
|
||||
* @return list<Lesson>
|
||||
*/
|
||||
public function findAllUpcoming(): array
|
||||
{
|
||||
$avTable = str_replace('us_lessons', 'us_availability', $this->table);
|
||||
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
"SELECT l.* FROM {$this->table} l
|
||||
JOIN {$avTable} a ON a.id = l.slot_id
|
||||
WHERE l.status != %s
|
||||
AND a.start_dt >= %s
|
||||
ORDER BY a.start_dt ASC",
|
||||
Lesson::STATUS_CANCELLED,
|
||||
current_time('mysql')
|
||||
)
|
||||
);
|
||||
|
||||
return array_map(Lesson::fromRow(...), $rows ?? []);
|
||||
}
|
||||
|
||||
public function updateStatus(int $id, string $status): bool
|
||||
{
|
||||
if (! in_array($status, Lesson::VALID_STATUSES, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $this->db->update(
|
||||
$this->table,
|
||||
['status' => $status],
|
||||
['id' => $id],
|
||||
['%s'],
|
||||
['%d']
|
||||
);
|
||||
}
|
||||
}
|
||||
43
src/Data/Schema.php
Normal file
43
src/Data/Schema.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Data;
|
||||
|
||||
class Schema
|
||||
{
|
||||
/**
|
||||
* Returns CREATE TABLE statements for dbDelta.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function tables(string $prefix, string $charset): array
|
||||
{
|
||||
return [
|
||||
"CREATE TABLE {$prefix}us_availability (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
instructor_id BIGINT UNSIGNED NOT NULL,
|
||||
start_dt DATETIME NOT NULL,
|
||||
end_dt DATETIME NOT NULL,
|
||||
is_booked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY instructor_id (instructor_id),
|
||||
KEY start_dt (start_dt)
|
||||
) {$charset};",
|
||||
|
||||
"CREATE TABLE {$prefix}us_lessons (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
slot_id BIGINT UNSIGNED NOT NULL,
|
||||
student_id BIGINT UNSIGNED NOT NULL,
|
||||
instructor_id BIGINT UNSIGNED NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY slot_id (slot_id),
|
||||
KEY student_id (student_id),
|
||||
KEY instructor_id (instructor_id)
|
||||
) {$charset};",
|
||||
];
|
||||
}
|
||||
}
|
||||
39
src/Frontend/BookingPage.php
Normal file
39
src/Frontend/BookingPage.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Frontend;
|
||||
|
||||
use Unsupervised\Schedular\Data\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Data\BookingRepository;
|
||||
use Unsupervised\Schedular\Roles\RoleManager;
|
||||
|
||||
class BookingPage
|
||||
{
|
||||
public function __construct(
|
||||
private AvailabilityRepository $availability,
|
||||
private BookingRepository $bookings,
|
||||
) {}
|
||||
|
||||
public function render(array $atts): string
|
||||
{
|
||||
if (! is_user_logged_in()) {
|
||||
return sprintf(
|
||||
'<p>%s <a href="%s">%s</a>.</p>',
|
||||
esc_html__('Please', 'unsupervised-schedular'),
|
||||
esc_url(wp_login_url(get_permalink())),
|
||||
esc_html__('log in to book a lesson', 'unsupervised-schedular')
|
||||
);
|
||||
}
|
||||
|
||||
if (! current_user_can(RoleManager::CAP_BOOK_LESSON)) {
|
||||
return '<p>' . esc_html__('This page is for students only.', 'unsupervised-schedular') . '</p>';
|
||||
}
|
||||
|
||||
wp_enqueue_style('us-scheduler');
|
||||
wp_enqueue_script('us-scheduler');
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
44
src/Frontend/LoginPage.php
Normal file
44
src/Frontend/LoginPage.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Frontend;
|
||||
|
||||
class LoginPage
|
||||
{
|
||||
public function render(array $atts): string
|
||||
{
|
||||
if (is_user_logged_in()) {
|
||||
$redirect = esc_url(get_permalink());
|
||||
return sprintf(
|
||||
'<p>%s <a href="%s">%s</a>.</p>',
|
||||
esc_html__('You are already logged in.', 'unsupervised-schedular'),
|
||||
$redirect,
|
||||
esc_html__('View available lessons', 'unsupervised-schedular')
|
||||
);
|
||||
}
|
||||
|
||||
$error = '';
|
||||
$redirect = sanitize_url(get_permalink() ?? '');
|
||||
|
||||
if (isset($_POST['us_login']) && check_admin_referer('us_student_login')) {
|
||||
$credentials = [
|
||||
'user_login' => sanitize_user($_POST['log'] ?? ''),
|
||||
'user_password' => $_POST['pwd'] ?? '',
|
||||
'remember' => isset($_POST['rememberme']),
|
||||
];
|
||||
|
||||
$user = wp_signon($credentials, false);
|
||||
|
||||
if (is_wp_error($user)) {
|
||||
$error = esc_html__('Invalid username or password.', 'unsupervised-schedular');
|
||||
} else {
|
||||
wp_safe_redirect($redirect);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/login-page.php';
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
37
src/Frontend/ShortcodeRegistrar.php
Normal file
37
src/Frontend/ShortcodeRegistrar.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Frontend;
|
||||
|
||||
use Unsupervised\Schedular\Data\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Data\BookingRepository;
|
||||
|
||||
class ShortcodeRegistrar
|
||||
{
|
||||
private BookingPage $bookingPage;
|
||||
private LoginPage $loginPage;
|
||||
|
||||
public function __construct(AvailabilityRepository $availability, BookingRepository $bookings)
|
||||
{
|
||||
$this->bookingPage = new BookingPage($availability, $bookings);
|
||||
$this->loginPage = new LoginPage();
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
add_shortcode('us_booking', [$this->bookingPage, 'render']);
|
||||
add_shortcode('us_student_login', [$this->loginPage, 'render']);
|
||||
add_action('wp_enqueue_scripts', [$this, 'enqueueAssets']);
|
||||
}
|
||||
|
||||
public function enqueueAssets(): void
|
||||
{
|
||||
wp_register_style('us-scheduler', USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION);
|
||||
wp_register_script('us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [], USC_VERSION, true);
|
||||
|
||||
wp_localize_script('us-scheduler', 'usScheduler', [
|
||||
'restUrl' => rest_url('us-scheduler/v1/'),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
src/Installer.php
Normal file
30
src/Installer.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Data\Schema;
|
||||
use Unsupervised\Schedular\Roles\RoleManager;
|
||||
|
||||
class Installer
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$this->createTables();
|
||||
(new RoleManager())->createRoles();
|
||||
flush_rewrite_rules();
|
||||
update_option('us_schedular_version', USC_VERSION);
|
||||
}
|
||||
|
||||
private function createTables(): void
|
||||
{
|
||||
global $wpdb;
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
foreach (Schema::tables($wpdb->prefix, $charset) as $sql) {
|
||||
dbDelta($sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/Model/AvailabilitySlot.php
Normal file
37
src/Model/AvailabilitySlot.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Model;
|
||||
|
||||
class AvailabilitySlot
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $instructorId,
|
||||
public readonly string $startDt,
|
||||
public readonly string $endDt,
|
||||
public readonly bool $isBooked = false,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
public static function fromRow(object $row): self
|
||||
{
|
||||
return new self(
|
||||
instructorId: (int) $row->instructor_id,
|
||||
startDt: $row->start_dt,
|
||||
endDt: $row->end_dt,
|
||||
isBooked: (bool) $row->is_booked,
|
||||
id: (int) $row->id,
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'instructor_id' => $this->instructorId,
|
||||
'start_dt' => $this->startDt,
|
||||
'end_dt' => $this->endDt,
|
||||
'is_booked' => $this->isBooked,
|
||||
];
|
||||
}
|
||||
}
|
||||
47
src/Model/Lesson.php
Normal file
47
src/Model/Lesson.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Model;
|
||||
|
||||
class Lesson
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
/** @var list<string> */
|
||||
public const VALID_STATUSES = [self::STATUS_PENDING, self::STATUS_CONFIRMED, self::STATUS_CANCELLED];
|
||||
|
||||
public function __construct(
|
||||
public readonly int $slotId,
|
||||
public readonly int $studentId,
|
||||
public readonly int $instructorId,
|
||||
public readonly string $status = self::STATUS_PENDING,
|
||||
public readonly ?string $notes = null,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
public static function fromRow(object $row): self
|
||||
{
|
||||
return new self(
|
||||
slotId: (int) $row->slot_id,
|
||||
studentId: (int) $row->student_id,
|
||||
instructorId: (int) $row->instructor_id,
|
||||
status: $row->status,
|
||||
notes: $row->notes,
|
||||
id: (int) $row->id,
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slot_id' => $this->slotId,
|
||||
'student_id' => $this->studentId,
|
||||
'instructor_id' => $this->instructorId,
|
||||
'status' => $this->status,
|
||||
'notes' => $this->notes,
|
||||
];
|
||||
}
|
||||
}
|
||||
28
src/Plugin.php
Normal file
28
src/Plugin.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Admin\AdminMenu;
|
||||
use Unsupervised\Schedular\Api\RestRegistrar;
|
||||
use Unsupervised\Schedular\Data\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Data\BookingRepository;
|
||||
use Unsupervised\Schedular\Frontend\ShortcodeRegistrar;
|
||||
use Unsupervised\Schedular\Roles\RoleManager;
|
||||
|
||||
class Plugin
|
||||
{
|
||||
public static function boot(): void
|
||||
{
|
||||
load_plugin_textdomain('unsupervised-schedular', false, dirname(plugin_basename(USC_PLUGIN_FILE)) . '/languages');
|
||||
|
||||
global $wpdb;
|
||||
$availability = new AvailabilityRepository($wpdb);
|
||||
$bookings = new BookingRepository($wpdb);
|
||||
|
||||
(new RoleManager())->register();
|
||||
(new AdminMenu($availability, $bookings))->register();
|
||||
(new RestRegistrar($availability, $bookings))->register();
|
||||
(new ShortcodeRegistrar($availability, $bookings))->register();
|
||||
}
|
||||
}
|
||||
46
src/Roles/RoleManager.php
Normal file
46
src/Roles/RoleManager.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Roles;
|
||||
|
||||
class RoleManager
|
||||
{
|
||||
public const INSTRUCTOR = 'us_instructor';
|
||||
public const STUDENT = 'us_student';
|
||||
|
||||
public const CAP_MANAGE_AVAILABILITY = 'manage_availability';
|
||||
public const CAP_VIEW_LESSONS = 'view_own_lessons';
|
||||
public const CAP_BOOK_LESSON = 'book_lesson';
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
add_action('init', [$this, 'createRoles']);
|
||||
}
|
||||
|
||||
public function createRoles(): void
|
||||
{
|
||||
if (get_role(self::INSTRUCTOR) === null) {
|
||||
add_role(
|
||||
self::INSTRUCTOR,
|
||||
__('Instructor', 'unsupervised-schedular'),
|
||||
[
|
||||
'read' => true,
|
||||
self::CAP_MANAGE_AVAILABILITY => true,
|
||||
self::CAP_VIEW_LESSONS => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (get_role(self::STUDENT) === null) {
|
||||
add_role(
|
||||
self::STUDENT,
|
||||
__('Student', 'unsupervised-schedular'),
|
||||
[
|
||||
'read' => true,
|
||||
self::CAP_BOOK_LESSON => true,
|
||||
self::CAP_VIEW_LESSONS => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user