diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6374142 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(composer test:*)", + "Bash(tea actions:*)" + ] + } +} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b5ba3c9..327b9a9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: - name: Run PHPCS run: composer cs + static-analysis: name: PHPStan runs-on: ubuntu-latest diff --git a/composer.json b/composer.json index b8eedad..15db79b 100644 --- a/composer.json +++ b/composer.json @@ -30,8 +30,8 @@ "test": "phpunit --configuration phpunit.xml", "test:coverage": "phpunit --configuration phpunit.xml --coverage-html coverage/", "lint": "phpstan analyse src/ --level=6 --configuration phpstan.neon", - "cs": "phpcs --standard=WordPress src/", - "cs:fix": "phpcbf --standard=WordPress src/" + "cs": "phpcs --standard=phpcs.xml.dist", + "cs:fix": "phpcbf --standard=phpcs.xml.dist" }, "config": { "allow-plugins": { diff --git a/memory/MEMORY.md b/memory/MEMORY.md new file mode 100644 index 0000000..c57c5df --- /dev/null +++ b/memory/MEMORY.md @@ -0,0 +1,4 @@ +# Memory Index + +- [Project: unsupervised-schedular](project_schedular.md) — WordPress lesson scheduling plugin; initial scaffold created March 2026 +- [Feedback: Brain\Monkey testing patterns](feedback_brainmonkey.md) — specific API quirks discovered during test setup diff --git a/memory/feedback_brainmonkey.md b/memory/feedback_brainmonkey.md new file mode 100644 index 0000000..d950cfe --- /dev/null +++ b/memory/feedback_brainmonkey.md @@ -0,0 +1,35 @@ +--- +name: Brain\Monkey testing patterns +description: Specific Brain\Monkey 2.x API quirks that caused test failures — use these patterns to avoid repeating mistakes +type: feedback +--- + +Use `Functions\when('fn')->alias(fn() => ...)` for closure-based stubs. NOT `returnUsing()` (doesn't exist). + +**Why:** Discovered during initial test scaffold — `returnUsing()` throws "Call to undefined method". + +**How to apply:** Any time a WP function needs to return different values based on arguments (e.g. `get_role` returning different values per role), use `Functions\when()->alias()`. + +--- + +Use `Functions\when()` instead of `Functions\expect()` when routing by argument. + +**Why:** Chaining two `Functions\expect('get_role')->with(A)` / `Functions\expect('get_role')->with(B)` caused the second expectation to silently override the first rather than adding an alternative route, leading to unexpected "0 calls" failures. + +**How to apply:** When a function needs to return different values for different args, use `Functions\when()->alias(fn($arg) => match($arg) { ... })`. Use `Functions\expect()` only when asserting call count/args. + +--- + +Mockery matchers don't work inside plain PHP arrays in `with()`. + +**Why:** `->with('init', [\Mockery::type(Foo::class), 'method'])` never matched because Mockery can't evaluate matchers nested in arrays this way. + +**How to apply:** Use `\Mockery::any()` or `\Mockery::on(fn($arr) => ...)` for the entire array argument instead. + +--- + +`TestCase::setUp()` must call `Monkey\Functions\stubTranslationFunctions()` and `Monkey\Functions\stubEscapeFunctions()`. + +**Why:** WP i18n functions (`__`, `_e`, etc.) are not auto-stubbed — they don't exist in the test environment. Without explicit stubs, PHP throws "Call to undefined function" as soon as any WP code path hits `__()`. + +**How to apply:** Already done in `tests/Unit/TestCase.php`. Don't remove these calls. diff --git a/memory/project_schedular.md b/memory/project_schedular.md new file mode 100644 index 0000000..0372c82 --- /dev/null +++ b/memory/project_schedular.md @@ -0,0 +1,19 @@ +--- +name: unsupervised-schedular project context +description: WordPress lesson scheduling plugin — stack, architecture decisions, conventions +type: project +--- + +WordPress plugin for instructor/student lesson scheduling. Full scaffold created 2026-03-30. + +**Stack:** PHP 8.1+, WordPress 6.0+, Composer, PHPUnit 10, Brain\Monkey 2.7, Mockery, PHPStan, PHPCS/WPCS, Gitea Actions CI. + +**Why:** New greenfield project for unsupervised.ca. + +**Key decisions:** +- Custom DB tables (`us_availability`, `us_lessons`) over CPTs — relational data, conflict detection, fast queries +- REST API (`us-scheduler/v1`) for all front-end interactions; templates are minimal shell divs, JS (vanilla) takes over +- Instructors use wp-admin login; students use front-end `[us_student_login]` shortcode calling `wp_signon()` +- PSR-4 namespace `Unsupervised\Schedular\` from `src/` + +**How to apply:** When adding features, follow the docs/features/ + src/ + tests/Unit/ pattern. Always run `composer test` after changes. diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..5fda56d --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,50 @@ + + + WordPress coding standards with PSR-4 naming accommodations. + + src + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Admin/AdminMenu.php b/src/Admin/AdminMenu.php index 849f8f9..bce8c4f 100644 --- a/src/Admin/AdminMenu.php +++ b/src/Admin/AdminMenu.php @@ -7,55 +7,52 @@ use Unsupervised\Schedular\Data\AvailabilityRepository; use Unsupervised\Schedular\Data\BookingRepository; use Unsupervised\Schedular\Roles\RoleManager; -class AdminMenu -{ - private AvailabilityController $availabilityController; - private LessonController $lessonController; +class AdminMenu { - public function __construct(AvailabilityRepository $availability, BookingRepository $bookings) - { - $this->availabilityController = new AvailabilityController($availability); - $this->lessonController = new LessonController($bookings); - } + private AvailabilityController $availabilityController; + private LessonController $lessonController; - public function register(): void - { - add_action('admin_menu', [$this, 'addPages']); - } + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings ) { + $this->availabilityController = new AvailabilityController( $availability ); + $this->lessonController = new LessonController( $bookings ); + } - 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 - ); + public function register(): void { + add_action( 'admin_menu', [ $this, 'addPages' ] ); + } - // 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 - ); + 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: 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 - ); - } + // 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 + ); + } } diff --git a/src/Admin/AvailabilityController.php b/src/Admin/AvailabilityController.php index 1e3a3a2..8c06b9a 100644 --- a/src/Admin/AvailabilityController.php +++ b/src/Admin/AvailabilityController.php @@ -7,48 +7,49 @@ use Unsupervised\Schedular\Data\AvailabilityRepository; use Unsupervised\Schedular\Model\AvailabilitySlot; use Unsupervised\Schedular\Roles\RoleManager; -class AvailabilityController -{ - public function __construct(private AvailabilityRepository $repository) {} +class AvailabilityController { - 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')); - } + public function __construct( private AvailabilityRepository $repository ) {} - $instructorId = get_current_user_id(); + 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' ) ); + } - if (isset($_POST['usc_action']) && check_admin_referer('usc_availability_action')) { - $this->handleFormAction($instructorId); - } + $instructorId = get_current_user_id(); - $slots = $this->repository->findByInstructor($instructorId); + if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_availability_action' ) ) { + $this->handleFormAction( $instructorId ); + } - include USC_PLUGIN_DIR . 'templates/admin/availability.php'; - } + $slots = $this->repository->findByInstructor( $instructorId ); - private function handleFormAction(int $instructorId): void - { - $action = sanitize_key($_POST['usc_action'] ?? ''); + include USC_PLUGIN_DIR . 'templates/admin/availability.php'; + } - if ($action === 'add') { - $startDt = sanitize_text_field($_POST['start_dt'] ?? ''); - $endDt = sanitize_text_field($_POST['end_dt'] ?? ''); + private function handleFormAction( int $instructorId ): void { + // Nonce is verified by the caller (renderPage) before this method runs. + // phpcs:disable WordPress.Security.NonceVerification.Missing + $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ); - if ($startDt !== '' && $endDt !== '') { - $this->repository->insert(new AvailabilitySlot($instructorId, $startDt, $endDt)); - } - } + if ( 'add' === $action ) { + $startDt = sanitize_text_field( wp_unslash( $_POST['start_dt'] ?? '' ) ); + $endDt = sanitize_text_field( wp_unslash( $_POST['end_dt'] ?? '' ) ); - 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); - } - } - } - } + if ( '' !== $startDt && '' !== $endDt ) { + $this->repository->insert( new AvailabilitySlot( $instructorId, $startDt, $endDt ) ); + } + } + + if ( 'delete' === $action ) { + $slotId = absint( $_POST['slot_id'] ?? 0 ); + if ( $slotId > 0 ) { + $slot = $this->repository->findById( $slotId ); + if ( $slot && $slot->instructorId === $instructorId ) { + $this->repository->delete( $slotId ); + } + } + } + // phpcs:enable WordPress.Security.NonceVerification.Missing + } } diff --git a/src/Admin/LessonController.php b/src/Admin/LessonController.php index 9a20543..a6bacd0 100644 --- a/src/Admin/LessonController.php +++ b/src/Admin/LessonController.php @@ -6,29 +6,27 @@ namespace Unsupervised\Schedular\Admin; use Unsupervised\Schedular\Data\BookingRepository; use Unsupervised\Schedular\Roles\RoleManager; -class LessonController -{ - public function __construct(private BookingRepository $repository) {} +class LessonController { - 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')); - } + public function __construct( private BookingRepository $repository ) {} - $lessons = $this->repository->findAllUpcoming(); + 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' ) ); + } - include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; - } + $lessons = $this->repository->findAllUpcoming(); - 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')); - } + include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; + } - $lessons = $this->repository->findUpcomingForInstructor(get_current_user_id()); + 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' ) ); + } - include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; - } + $lessons = $this->repository->findUpcomingForInstructor( get_current_user_id() ); + + include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; + } } diff --git a/src/Api/AvailabilityEndpoint.php b/src/Api/AvailabilityEndpoint.php index ef737be..91cfddd 100644 --- a/src/Api/AvailabilityEndpoint.php +++ b/src/Api/AvailabilityEndpoint.php @@ -7,96 +7,115 @@ use Unsupervised\Schedular\Data\AvailabilityRepository; use Unsupervised\Schedular\Model\AvailabilitySlot; use Unsupervised\Schedular\Roles\RoleManager; -class AvailabilityEndpoint -{ - public function __construct(private AvailabilityRepository $repository) {} +class AvailabilityEndpoint { - 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'], - ], - ], - ]); + public function __construct( private AvailabilityRepository $repository ) {} - register_rest_route($namespace, '/availability/(?P\d+)', [ - [ - 'methods' => \WP_REST_Server::DELETABLE, - 'callback' => [$this, 'delete'], - 'permission_callback' => [$this, 'canManage'], - ], - ]); - } + public function registerRoutes( string $route_namespace ): void { + register_rest_route( + $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', + ], + ], + ], + ] + ); - 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'), - ); + register_rest_route( + $route_namespace, + '/availability/(?P\d+)', + [ + [ + 'methods' => \WP_REST_Server::DELETABLE, + 'callback' => [ $this, 'delete' ], + 'permission_callback' => [ $this, 'canManage' ], + ], + ] + ); + } - return new \WP_REST_Response(array_map(fn(AvailabilitySlot $s) => $s->toArray(), $slots), 200); - } + 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' ), + ); - 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'), - ); + return new \WP_REST_Response( array_map( fn( AvailabilitySlot $s ) => $s->toArray(), $slots ), 200 ); + } - $id = $this->repository->insert($slot); + 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' ), + ); - return new \WP_REST_Response(['id' => $id], 201); - } + $id = $this->repository->insert( $slot ); - public function delete(\WP_REST_Request $request): \WP_REST_Response|\WP_Error - { - $id = absint($request->get_param('id')); - $slot = $this->repository->findById($id); + return new \WP_REST_Response( [ 'id' => $id ], 201 ); + } - if ($slot === null) { - return new \WP_Error('not_found', __('Slot not found.', 'unsupervised-schedular'), ['status' => 404]); - } + 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->instructorId !== get_current_user_id()) { - return new \WP_Error('forbidden', __('You cannot delete this slot.', 'unsupervised-schedular'), ['status' => 403]); - } + if ( null === $slot ) { + return new \WP_Error( 'not_found', __( 'Slot not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); + } - if ($slot->isBooked) { - return new \WP_Error('slot_booked', __('Cannot delete a booked slot.', 'unsupervised-schedular'), ['status' => 409]); - } + if ( get_current_user_id() !== $slot->instructorId ) { + return new \WP_Error( 'forbidden', __( 'You cannot delete this slot.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); + } - $this->repository->delete($id); + if ( $slot->isBooked ) { + return new \WP_Error( 'slot_booked', __( 'Cannot delete a booked slot.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); + } - return new \WP_REST_Response(null, 204); - } + $this->repository->delete( $id ); - public function canBook(): bool - { - return is_user_logged_in() && current_user_can(RoleManager::CAP_BOOK_LESSON); - } + return new \WP_REST_Response( null, 204 ); + } - public function canManage(): bool - { - return is_user_logged_in() && current_user_can(RoleManager::CAP_MANAGE_AVAILABILITY); - } + 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 ); + } } diff --git a/src/Api/BookingEndpoint.php b/src/Api/BookingEndpoint.php index 5cd9a04..cec44ad 100644 --- a/src/Api/BookingEndpoint.php +++ b/src/Api/BookingEndpoint.php @@ -8,116 +8,138 @@ 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, - ) {} +class BookingEndpoint { - 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'], - ], - ], - ]); + public function __construct( + private AvailabilityRepository $availability, + private BookingRepository $bookings, + ) {} - register_rest_route($namespace, '/bookings/(?P\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 registerRoutes( string $route_namespace ): void { + register_rest_route( + $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', + ], + ], + ], + ] + ); - 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); + register_rest_route( + $route_namespace, + '/bookings/(?P\d+)/status', + [ + [ + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => [ $this, 'updateStatus' ], + 'permission_callback' => [ $this, 'canManage' ], + 'args' => [ + 'status' => [ + 'type' => 'string', + 'required' => true, + 'enum' => Lesson::VALID_STATUSES, + ], + ], + ], + ] + ); + } - return new \WP_REST_Response(array_map(fn(Lesson $l) => $l->toArray(), $lessons), 200); - } + public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + $userId = get_current_user_id(); + $lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) + ? $this->bookings->findUpcomingForInstructor( $userId ) + : $this->bookings->findByStudent( $userId ); - public function book(\WP_REST_Request $request): \WP_REST_Response|\WP_Error - { - $slotId = (int) $request->get_param('slot_id'); - $slot = $this->availability->findById($slotId); + return new \WP_REST_Response( array_map( fn( Lesson $l ) => $l->toArray(), $lessons ), 200 ); + } - if ($slot === null) { - return new \WP_Error('not_found', __('Slot not found.', 'unsupervised-schedular'), ['status' => 404]); - } + 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->isBooked) { - return new \WP_Error('slot_taken', __('This slot is already booked.', 'unsupervised-schedular'), ['status' => 409]); - } + if ( null === $slot ) { + return new \WP_Error( 'not_found', __( 'Slot not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); + } - $lesson = new Lesson( - slotId: $slotId, - studentId: get_current_user_id(), - instructorId: $slot->instructorId, - notes: (string) $request->get_param('notes') ?: null, - ); + if ( $slot->isBooked ) { + return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); + } - $id = $this->bookings->insert($lesson); - $this->availability->markBooked($slotId); + $notes = (string) $request->get_param( 'notes' ); + $lesson = new Lesson( + slotId: $slotId, + studentId: get_current_user_id(), + instructorId: $slot->instructorId, + notes: '' !== $notes ? $notes : null, + ); - return new \WP_REST_Response(['id' => $id, 'status' => Lesson::STATUS_PENDING], 201); - } + $id = $this->bookings->insert( $lesson ); + $this->availability->markBooked( $slotId ); - public function updateStatus(\WP_REST_Request $request): \WP_REST_Response|\WP_Error - { - $id = absint($request->get_param('id')); - $lesson = $this->bookings->findById($id); + return new \WP_REST_Response( + [ + 'id' => $id, + 'status' => Lesson::STATUS_PENDING, + ], + 201 + ); + } - if ($lesson === null) { - return new \WP_Error('not_found', __('Booking not found.', 'unsupervised-schedular'), ['status' => 404]); - } + 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->instructorId !== get_current_user_id() && ! current_user_can('manage_options')) { - return new \WP_Error('forbidden', __('You cannot update this booking.', 'unsupervised-schedular'), ['status' => 403]); - } + if ( null === $lesson ) { + return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); + } - $this->bookings->updateStatus($id, (string) $request->get_param('status')); + if ( get_current_user_id() !== $lesson->instructorId && ! current_user_can( 'manage_options' ) ) { + return new \WP_Error( 'forbidden', __( 'You cannot update this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); + } - return new \WP_REST_Response(['id' => $id, 'status' => $request->get_param('status')], 200); - } + $this->bookings->updateStatus( $id, (string) $request->get_param( 'status' ) ); - public function isLoggedIn(): bool - { - return is_user_logged_in(); - } + return new \WP_REST_Response( + [ + 'id' => $id, + 'status' => $request->get_param( 'status' ), + ], + 200 + ); + } - public function canBook(): bool - { - return is_user_logged_in() && current_user_can(RoleManager::CAP_BOOK_LESSON); - } + public function isLoggedIn(): bool { + return is_user_logged_in(); + } - public function canManage(): bool - { - return is_user_logged_in() && ( - current_user_can(RoleManager::CAP_MANAGE_AVAILABILITY) || current_user_can('manage_options') - ); - } + 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' ) + ); + } } diff --git a/src/Api/RestRegistrar.php b/src/Api/RestRegistrar.php index ea1d39e..8eb3a24 100644 --- a/src/Api/RestRegistrar.php +++ b/src/Api/RestRegistrar.php @@ -6,27 +6,24 @@ namespace Unsupervised\Schedular\Api; use Unsupervised\Schedular\Data\AvailabilityRepository; use Unsupervised\Schedular\Data\BookingRepository; -class RestRegistrar -{ - public const NAMESPACE = 'us-scheduler/v1'; +class RestRegistrar { - private AvailabilityEndpoint $availabilityEndpoint; - private BookingEndpoint $bookingEndpoint; + public const NAMESPACE = 'us-scheduler/v1'; - public function __construct(AvailabilityRepository $availability, BookingRepository $bookings) - { - $this->availabilityEndpoint = new AvailabilityEndpoint($availability); - $this->bookingEndpoint = new BookingEndpoint($availability, $bookings); - } + private AvailabilityEndpoint $availabilityEndpoint; + private BookingEndpoint $bookingEndpoint; - public function register(): void - { - add_action('rest_api_init', [$this, 'registerRoutes']); - } + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings ) { + $this->availabilityEndpoint = new AvailabilityEndpoint( $availability ); + $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings ); + } - public function registerRoutes(): void - { - $this->availabilityEndpoint->registerRoutes(self::NAMESPACE); - $this->bookingEndpoint->registerRoutes(self::NAMESPACE); - } + 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 ); + } } diff --git a/src/Data/AvailabilityRepository.php b/src/Data/AvailabilityRepository.php index 3f45bd2..e23e0ce 100644 --- a/src/Data/AvailabilityRepository.php +++ b/src/Data/AvailabilityRepository.php @@ -5,113 +5,109 @@ namespace Unsupervised\Schedular\Data; use Unsupervised\Schedular\Model\AvailabilitySlot; -class AvailabilityRepository -{ - private string $table; +class AvailabilityRepository { - public function __construct(private \wpdb $db) - { - $this->table = $db->prefix . 'us_availability'; - } + private string $table; - 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'] - ); + public function __construct( private \wpdb $db ) { + $this->table = $db->prefix . 'us_availability'; + } - return $this->db->insert_id; - } + 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' ] + ); - /** - * Find unbooked slots, optionally filtered by instructor and date range. - * - * @return list - */ - public function findAvailable(int $instructorId = 0, string $from = '', string $to = ''): array - { - $where = ['is_booked = 0']; - $params = []; + return $this->db->insert_id; + } - if ($instructorId > 0) { - $where[] = 'instructor_id = %d'; - $params[] = $instructorId; - } + /** + * Find unbooked slots, optionally filtered by instructor and date range. + * + * @return list + */ + public function findAvailable( int $instructorId = 0, string $from = '', string $to = '' ): array { + $where = [ 'is_booked = 0' ]; + $params = []; - if ($from !== '') { - $where[] = 'start_dt >= %s'; - $params[] = $from; - } + if ( $instructorId > 0 ) { + $where[] = 'instructor_id = %d'; + $params[] = $instructorId; + } - if ($to !== '') { - $where[] = 'end_dt <= %s'; - $params[] = $to; - } + if ( '' !== $from ) { + $where[] = 'start_dt >= %s'; + $params[] = $from; + } - $whereClause = implode(' AND ', $where); - $sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC"; + if ( '' !== $to ) { + $where[] = 'end_dt <= %s'; + $params[] = $to; + } - $rows = $params - ? $this->db->get_results($this->db->prepare($sql, $params)) - : $this->db->get_results($sql); + $whereClause = implode( ' AND ', $where ); + $sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC"; - return array_map(AvailabilitySlot::fromRow(...), $rows ?? []); - } + $rows = $params + ? $this->db->get_results( $this->db->prepare( $sql, $params ) ) + : $this->db->get_results( $sql ); - /** - * Find all slots for an instructor (booked and unbooked). - * - * @return list - */ - 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 ?? [] ); + } - return array_map(AvailabilitySlot::fromRow(...), $rows ?? []); - } + /** + * Find all slots for an instructor (booked and unbooked). + * + * @return list + */ + 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 + ) + ); - public function findById(int $id): ?AvailabilitySlot - { - $row = $this->db->get_row( - $this->db->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id) - ); + return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] ); + } - return $row ? AvailabilitySlot::fromRow($row) : null; - } + public function findById( int $id ): ?AvailabilitySlot { + $row = $this->db->get_row( + $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + ); - public function markBooked(int $id): bool - { - return (bool) $this->db->update( - $this->table, - ['is_booked' => 1], - ['id' => $id], - ['%d'], - ['%d'] - ); - } + return $row ? AvailabilitySlot::fromRow( $row ) : null; + } - /** - * 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'] - ); - } + 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' ] + ); + } } diff --git a/src/Data/BookingRepository.php b/src/Data/BookingRepository.php index 57b4dda..5832594 100644 --- a/src/Data/BookingRepository.php +++ b/src/Data/BookingRepository.php @@ -5,121 +5,114 @@ namespace Unsupervised\Schedular\Data; use Unsupervised\Schedular\Model\Lesson; -class BookingRepository -{ - private string $table; +class BookingRepository { - public function __construct(private \wpdb $db) - { - $this->table = $db->prefix . 'us_lessons'; - } + private string $table; - 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'] - ); + public function __construct( private \wpdb $db ) { + $this->table = $db->prefix . 'us_lessons'; + } - return $this->db->insert_id; - } + 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' ] + ); - public function findById(int $id): ?Lesson - { - $row = $this->db->get_row( - $this->db->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id) - ); + return $this->db->insert_id; + } - return $row ? Lesson::fromRow($row) : null; - } + public function findById( int $id ): ?Lesson { + $row = $this->db->get_row( + $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) + ); - /** - * Upcoming lessons for an instructor (status != cancelled, slot in the future). - * - * @return list - */ - public function findUpcomingForInstructor(int $instructorId): array - { - $avTable = str_replace('us_lessons', 'us_availability', $this->table); + return $row ? Lesson::fromRow( $row ) : null; + } - $rows = $this->db->get_results( - $this->db->prepare( - "SELECT l.* FROM {$this->table} l + /** + * Upcoming lessons for an instructor (status != cancelled, slot in the future). + * + * @return list + */ + 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') - ) - ); + $instructorId, + Lesson::STATUS_CANCELLED, + current_time( 'mysql' ) + ) + ); - return array_map(Lesson::fromRow(...), $rows ?? []); - } + return array_map( Lesson::fromRow( ... ), $rows ?? [] ); + } - /** - * All lessons for a student. - * - * @return list - */ - 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 - ) - ); + /** + * All lessons for a student. + * + * @return list + */ + 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 ?? []); - } + return array_map( Lesson::fromRow( ... ), $rows ?? [] ); + } - /** - * All upcoming lessons across all instructors (admin view). - * - * @return list - */ - public function findAllUpcoming(): array - { - $avTable = str_replace('us_lessons', 'us_availability', $this->table); + /** + * All upcoming lessons across all instructors (admin view). + * + * @return list + */ + 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 + $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') - ) - ); + Lesson::STATUS_CANCELLED, + current_time( 'mysql' ) + ) + ); - return array_map(Lesson::fromRow(...), $rows ?? []); - } + return array_map( Lesson::fromRow( ... ), $rows ?? [] ); + } - public function updateStatus(int $id, string $status): bool - { - if (! in_array($status, Lesson::VALID_STATUSES, true)) { - return false; - } + 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'] - ); - } + return (bool) $this->db->update( + $this->table, + [ 'status' => $status ], + [ 'id' => $id ], + [ '%s' ], + [ '%d' ] + ); + } } diff --git a/src/Data/Schema.php b/src/Data/Schema.php index 2536467..3a2050d 100644 --- a/src/Data/Schema.php +++ b/src/Data/Schema.php @@ -3,17 +3,16 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Data; -class Schema -{ - /** - * Returns CREATE TABLE statements for dbDelta. - * - * @return list - */ - public static function tables(string $prefix, string $charset): array - { - return [ - "CREATE TABLE {$prefix}us_availability ( +class Schema { + + /** + * Returns CREATE TABLE statements for dbDelta. + * + * @return list + */ + 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, @@ -25,7 +24,7 @@ class Schema KEY start_dt (start_dt) ) {$charset};", - "CREATE TABLE {$prefix}us_lessons ( + "CREATE TABLE {$prefix}us_lessons ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, slot_id BIGINT UNSIGNED NOT NULL, student_id BIGINT UNSIGNED NOT NULL, @@ -38,6 +37,6 @@ class Schema KEY student_id (student_id), KEY instructor_id (instructor_id) ) {$charset};", - ]; - } + ]; + } } diff --git a/src/Frontend/BookingPage.php b/src/Frontend/BookingPage.php index f0c0c01..4a61a96 100644 --- a/src/Frontend/BookingPage.php +++ b/src/Frontend/BookingPage.php @@ -5,31 +5,32 @@ namespace Unsupervised\Schedular\Frontend; use Unsupervised\Schedular\Roles\RoleManager; -class BookingPage -{ - /** - * @param array $atts - */ - public function render(array $atts): string - { - if (! is_user_logged_in()) { - return sprintf( - '

%s %s.

', - esc_html__('Please', 'unsupervised-schedular'), - esc_url(wp_login_url(get_permalink())), - esc_html__('log in to book a lesson', 'unsupervised-schedular') - ); - } +class BookingPage { - if (! current_user_can(RoleManager::CAP_BOOK_LESSON)) { - return '

' . esc_html__('This page is for students only.', 'unsupervised-schedular') . '

'; - } + /** + * Renders the booking shortcode output. + * + * @param array $atts Shortcode attributes (unused — reserved for future options). + */ + public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + if ( ! is_user_logged_in() ) { + return sprintf( + '

%s %s.

', + esc_html__( 'Please', 'unsupervised-schedular' ), + esc_url( wp_login_url( get_permalink() ) ), + esc_html__( 'log in to book a lesson', 'unsupervised-schedular' ) + ); + } - wp_enqueue_style('us-scheduler'); - wp_enqueue_script('us-scheduler'); + if ( ! current_user_can( RoleManager::CAP_BOOK_LESSON ) ) { + return '

' . esc_html__( 'This page is for students only.', 'unsupervised-schedular' ) . '

'; + } - ob_start(); - include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php'; - return (string) ob_get_clean(); - } + 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(); + } } diff --git a/src/Frontend/LoginPage.php b/src/Frontend/LoginPage.php index d779753..5258e95 100644 --- a/src/Frontend/LoginPage.php +++ b/src/Frontend/LoginPage.php @@ -3,45 +3,47 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Frontend; -class LoginPage -{ - /** - * @param array $atts - */ - public function render(array $atts): string - { - if (is_user_logged_in()) { - $redirect = esc_url((string) get_permalink()); - return sprintf( - '

%s %s.

', - esc_html__('You are already logged in.', 'unsupervised-schedular'), - $redirect, - esc_html__('View available lessons', 'unsupervised-schedular') - ); - } +class LoginPage { - $error = ''; - $redirect = sanitize_url((string) get_permalink()); + /** + * Renders the student login shortcode output. + * + * @param array $atts Shortcode attributes (unused — reserved for future options). + */ + public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + if ( is_user_logged_in() ) { + $redirect = esc_url( (string) get_permalink() ); + return sprintf( + '

%s %s.

', + esc_html__( 'You are already logged in.', 'unsupervised-schedular' ), + $redirect, + esc_html__( 'View available lessons', 'unsupervised-schedular' ) + ); + } - 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']), - ]; + $error = ''; + $redirect = sanitize_url( (string) get_permalink() ); - $user = wp_signon($credentials, false); + if ( isset( $_POST['us_login'] ) && check_admin_referer( 'us_student_login' ) ) { + $credentials = [ + 'user_login' => sanitize_user( wp_unslash( $_POST['log'] ?? '' ) ), + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized. + 'user_password' => wp_unslash( $_POST['pwd'] ?? '' ), + 'remember' => isset( $_POST['rememberme'] ), + ]; - if (is_wp_error($user)) { - $error = esc_html__('Invalid username or password.', 'unsupervised-schedular'); - } else { - wp_safe_redirect($redirect); - exit; - } - } + $user = wp_signon( $credentials, false ); - ob_start(); - include USC_PLUGIN_DIR . 'templates/frontend/login-page.php'; - return (string) ob_get_clean(); - } + 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(); + } } diff --git a/src/Frontend/ShortcodeRegistrar.php b/src/Frontend/ShortcodeRegistrar.php index 4eaaf2c..a6d9acd 100644 --- a/src/Frontend/ShortcodeRegistrar.php +++ b/src/Frontend/ShortcodeRegistrar.php @@ -3,32 +3,33 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Frontend; -class ShortcodeRegistrar -{ - private BookingPage $bookingPage; - private LoginPage $loginPage; +class ShortcodeRegistrar { - public function __construct() - { - $this->bookingPage = new BookingPage(); - $this->loginPage = new LoginPage(); - } + private BookingPage $bookingPage; + private LoginPage $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 __construct() { + $this->bookingPage = new BookingPage(); + $this->loginPage = new LoginPage(); + } - 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); + 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' ] ); + } - wp_localize_script('us-scheduler', 'usScheduler', [ - 'restUrl' => rest_url('us-scheduler/v1/'), - 'nonce' => wp_create_nonce('wp_rest'), - ]); - } + 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' ), + ] + ); + } } diff --git a/src/Installer.php b/src/Installer.php index b83bc97..ec97793 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -6,25 +6,23 @@ 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); - } +class Installer { - private function createTables(): void - { - global $wpdb; - $charset = $wpdb->get_charset_collate(); + public function run(): void { + $this->createTables(); + ( new RoleManager() )->createRoles(); + flush_rewrite_rules(); + update_option( 'us_schedular_version', USC_VERSION ); + } - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + private function createTables(): void { + global $wpdb; + $charset = $wpdb->get_charset_collate(); - foreach (Schema::tables($wpdb->prefix, $charset) as $sql) { - dbDelta($sql); - } - } + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + foreach ( Schema::tables( $wpdb->prefix, $charset ) as $sql ) { + dbDelta( $sql ); + } + } } diff --git a/src/Model/AvailabilitySlot.php b/src/Model/AvailabilitySlot.php index f994e9f..c6f8364 100644 --- a/src/Model/AvailabilitySlot.php +++ b/src/Model/AvailabilitySlot.php @@ -3,38 +3,38 @@ 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, - ) {} +class AvailabilitySlot { - 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 __construct( + public readonly int $instructorId, + public readonly string $startDt, + public readonly string $endDt, + public readonly bool $isBooked = false, + public readonly ?int $id = null, + ) {} - /** - * @return array - */ - public function toArray(): array - { - return [ - 'id' => $this->id, - 'instructor_id' => $this->instructorId, - 'start_dt' => $this->startDt, - 'end_dt' => $this->endDt, - 'is_booked' => $this->isBooked, - ]; - } + 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, + ); + } + + /** + * Returns a plain array representation of the slot. + * + * @return array + */ + public function toArray(): array { + return [ + 'id' => $this->id, + 'instructor_id' => $this->instructorId, + 'start_dt' => $this->startDt, + 'end_dt' => $this->endDt, + 'is_booked' => $this->isBooked, + ]; + } } diff --git a/src/Model/Lesson.php b/src/Model/Lesson.php index 37d630e..50db7bd 100644 --- a/src/Model/Lesson.php +++ b/src/Model/Lesson.php @@ -3,48 +3,52 @@ 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'; +class Lesson { - /** @var list */ - public const VALID_STATUSES = [self::STATUS_PENDING, self::STATUS_CONFIRMED, self::STATUS_CANCELLED]; + public const STATUS_PENDING = 'pending'; + public const STATUS_CONFIRMED = 'confirmed'; + public const STATUS_CANCELLED = '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, - ) {} + /** + * All valid status values. + * + * @var list + */ + public const VALID_STATUSES = [ self::STATUS_PENDING, self::STATUS_CONFIRMED, self::STATUS_CANCELLED ]; - 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 __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, + ) {} - /** - * @return array - */ - 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, - ]; - } + 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, + ); + } + + /** + * Returns a plain array representation of the lesson. + * + * @return array + */ + 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, + ]; + } } diff --git a/src/Plugin.php b/src/Plugin.php index e9131d5..a0fab0e 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -10,19 +10,18 @@ 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'); +class Plugin { - global $wpdb; - $availability = new AvailabilityRepository($wpdb); - $bookings = new BookingRepository($wpdb); + public static function boot(): void { + load_plugin_textdomain( 'unsupervised-schedular', false, dirname( plugin_basename( USC_PLUGIN_FILE ) ) . '/languages' ); - (new RoleManager())->register(); - (new AdminMenu($availability, $bookings))->register(); - (new RestRegistrar($availability, $bookings))->register(); - (new ShortcodeRegistrar())->register(); - } + 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() )->register(); + } } diff --git a/src/Roles/RoleManager.php b/src/Roles/RoleManager.php index 0d8710c..257961e 100644 --- a/src/Roles/RoleManager.php +++ b/src/Roles/RoleManager.php @@ -3,44 +3,42 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Roles; -class RoleManager -{ - public const INSTRUCTOR = 'us_instructor'; - public const STUDENT = 'us_student'; +class RoleManager { - public const CAP_MANAGE_AVAILABILITY = 'manage_availability'; - public const CAP_VIEW_LESSONS = 'view_own_lessons'; - public const CAP_BOOK_LESSON = 'book_lesson'; + public const INSTRUCTOR = 'us_instructor'; + public const STUDENT = 'us_student'; - public function register(): void - { - add_action('init', [$this, 'createRoles']); - } + public const CAP_MANAGE_AVAILABILITY = 'manage_availability'; + public const CAP_VIEW_LESSONS = 'view_own_lessons'; + public const CAP_BOOK_LESSON = 'book_lesson'; - 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, - ] - ); - } + public function register(): void { + add_action( 'init', [ $this, 'createRoles' ] ); + } - 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, - ] - ); - } - } + 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, + ] + ); + } + } }