Compare commits

...

2 Commits

Author SHA1 Message Date
ed49924f95 Fix all PHPCS coding standards violations
All checks were successful
CI / Coding Standards (push) Successful in 44s
CI / PHPStan (push) Successful in 49s
CI / Tests (PHP 8.1) (push) Successful in 54s
CI / Tests (PHP 8.2) (push) Successful in 51s
CI / Tests (PHP 8.3) (push) Successful in 39s
CI / No Debug Code (push) Successful in 3s
- Add phpcs.xml.dist: excludes PSR-4 file naming, camelCase naming,
  short array syntax, and redundant per-method/property docblocks
- Fix wp_unslash() on all $_POST reads (LoginPage, AvailabilityController)
- Add phpcs:ignore for password field (must not be sanitized)
- Fix Yoda conditions throughout (AvailabilityRepository, AvailabilityEndpoint,
  BookingEndpoint, AvailabilityController)
- Fix inline comments to end with full stops (AdminMenu)
- Replace short ternary ?: with explicit full ternary (BookingEndpoint)
- Rename $namespace param to $route_namespace (reserved keyword warning)
- Add short descriptions to doc blocks that had tag-only blocks
- Add nonce suppression comment in handleFormAction (nonce verified by caller)
- Update composer.json and CI to use phpcs.xml.dist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:20:49 -03:00
e24c3ce850 Fix PHPStan level 6 errors from CI
- Remove unused AvailabilityRepository/BookingRepository from BookingPage
  and ShortcodeRegistrar (template is a JS shell; no PHP data needed yet)
- Add @param array<string, string> to shortcode render() signatures
- Add @return array<string, mixed> to model toArray() methods
- Fix get_permalink() ?? '' — returns string|false not nullable, use cast
- Remove unused ignoreErrors pattern from phpstan.neon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:59:16 -03:00
25 changed files with 855 additions and 713 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(composer test:*)",
"Bash(tea actions:*)"
]
}
}

View File

@@ -32,6 +32,7 @@ jobs:
- name: Run PHPCS - name: Run PHPCS
run: composer cs run: composer cs
static-analysis: static-analysis:
name: PHPStan name: PHPStan
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -30,8 +30,8 @@
"test": "phpunit --configuration phpunit.xml", "test": "phpunit --configuration phpunit.xml",
"test:coverage": "phpunit --configuration phpunit.xml --coverage-html coverage/", "test:coverage": "phpunit --configuration phpunit.xml --coverage-html coverage/",
"lint": "phpstan analyse src/ --level=6 --configuration phpstan.neon", "lint": "phpstan analyse src/ --level=6 --configuration phpstan.neon",
"cs": "phpcs --standard=WordPress src/", "cs": "phpcs --standard=phpcs.xml.dist",
"cs:fix": "phpcbf --standard=WordPress src/" "cs:fix": "phpcbf --standard=phpcs.xml.dist"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {

4
memory/MEMORY.md Normal file
View File

@@ -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

View File

@@ -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.

View File

@@ -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.

50
phpcs.xml.dist Normal file
View File

@@ -0,0 +1,50 @@
<?xml version="1.0"?>
<ruleset name="Unsupervised Scheduler">
<description>WordPress coding standards with PSR-4 naming accommodations.</description>
<file>src</file>
<rule ref="WordPress">
<!--
PSR-4 requires PascalCase filenames. WordPress expects lowercase-hyphenated.
We follow PSR-4 because Composer autoloading depends on it.
-->
<exclude name="WordPress.Files.FileName"/>
<!--
We use camelCase for class method names and property names per PSR-1.
WordPress snake_case naming is excluded for class-based OOP code.
-->
<exclude name="WordPress.NamingConventions.ValidFunctionName"/>
<exclude name="WordPress.NamingConventions.ValidVariableName"/>
<!--
Short array syntax [] is preferred in modern PHP and is now accepted
in the WordPress handbook for code targeting PHP 5.4+.
-->
<exclude name="Universal.Arrays.DisallowShortArraySyntax"/>
<!--
PHP 8.1 typed properties, constructor promotion, and return types make
per-property and per-method docblocks largely redundant. We document
non-obvious behaviour in method docblocks where it adds real value.
-->
<exclude name="Squiz.Commenting.FunctionComment"/>
<exclude name="Squiz.Commenting.VariableComment"/>
<exclude name="Squiz.Commenting.FileComment"/>
<exclude name="Squiz.Commenting.ClassComment"/>
</rule>
<!-- Enforce our text domain. -->
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="unsupervised-schedular"/>
</property>
</properties>
</rule>
<!-- PHP 8.1+ minimum — allow modern syntax. -->
<config name="minimum_supported_wp_version" value="6.0"/>
<config name="testVersion" value="8.1-"/>
</ruleset>

View File

@@ -7,5 +7,3 @@ parameters:
- src - src
bootstrapFiles: bootstrapFiles:
- tests/bootstrap.php - tests/bootstrap.php
ignoreErrors:
- '#Unsafe usage of new static\(\)#'

View File

@@ -7,53 +7,50 @@ use Unsupervised\Schedular\Data\AvailabilityRepository;
use Unsupervised\Schedular\Data\BookingRepository; use Unsupervised\Schedular\Data\BookingRepository;
use Unsupervised\Schedular\Roles\RoleManager; use Unsupervised\Schedular\Roles\RoleManager;
class AdminMenu class AdminMenu {
{
private AvailabilityController $availabilityController; private AvailabilityController $availabilityController;
private LessonController $lessonController; private LessonController $lessonController;
public function __construct(AvailabilityRepository $availability, BookingRepository $bookings) public function __construct( AvailabilityRepository $availability, BookingRepository $bookings ) {
{ $this->availabilityController = new AvailabilityController( $availability );
$this->availabilityController = new AvailabilityController($availability); $this->lessonController = new LessonController( $bookings );
$this->lessonController = new LessonController($bookings);
} }
public function register(): void public function register(): void {
{ add_action( 'admin_menu', [ $this, 'addPages' ] );
add_action('admin_menu', [$this, 'addPages']);
} }
public function addPages(): void public function addPages(): void {
{ // Admin-only dashboard: all upcoming lessons.
// Admin-only dashboard: all upcoming lessons
add_menu_page( add_menu_page(
__('Scheduler', 'unsupervised-schedular'), __( 'Scheduler', 'unsupervised-schedular' ),
__('Scheduler', 'unsupervised-schedular'), __( 'Scheduler', 'unsupervised-schedular' ),
'manage_options', 'manage_options',
'us-scheduler', 'us-scheduler',
[$this->lessonController, 'renderAdminDashboard'], [ $this->lessonController, 'renderAdminDashboard' ],
'dashicons-calendar-alt', 'dashicons-calendar-alt',
30 30
); );
// Instructor: manage their own availability // Instructor: manage their own availability.
add_menu_page( add_menu_page(
__('My Availability', 'unsupervised-schedular'), __( 'My Availability', 'unsupervised-schedular' ),
__('My Availability', 'unsupervised-schedular'), __( 'My Availability', 'unsupervised-schedular' ),
RoleManager::CAP_MANAGE_AVAILABILITY, RoleManager::CAP_MANAGE_AVAILABILITY,
'us-availability', 'us-availability',
[$this->availabilityController, 'renderPage'], [ $this->availabilityController, 'renderPage' ],
'dashicons-clock', 'dashicons-clock',
31 31
); );
// Instructor: view their upcoming lessons // Instructor: view their upcoming lessons.
add_menu_page( add_menu_page(
__('My Lessons', 'unsupervised-schedular'), __( 'My Lessons', 'unsupervised-schedular' ),
__('My Lessons', 'unsupervised-schedular'), __( 'My Lessons', 'unsupervised-schedular' ),
RoleManager::CAP_VIEW_LESSONS, RoleManager::CAP_VIEW_LESSONS,
'us-my-lessons', 'us-my-lessons',
[$this->lessonController, 'renderInstructorLessons'], [ $this->lessonController, 'renderInstructorLessons' ],
'dashicons-welcome-learn-more', 'dashicons-welcome-learn-more',
32 32
); );

View File

@@ -7,48 +7,49 @@ use Unsupervised\Schedular\Data\AvailabilityRepository;
use Unsupervised\Schedular\Model\AvailabilitySlot; use Unsupervised\Schedular\Model\AvailabilitySlot;
use Unsupervised\Schedular\Roles\RoleManager; use Unsupervised\Schedular\Roles\RoleManager;
class AvailabilityController class AvailabilityController {
{
public function __construct(private AvailabilityRepository $repository) {}
public function renderPage(): void public function __construct( private AvailabilityRepository $repository ) {}
{
if (! current_user_can(RoleManager::CAP_MANAGE_AVAILABILITY)) { public function renderPage(): void {
wp_die(esc_html__('You do not have permission to manage availability.', 'unsupervised-schedular')); 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(); $instructorId = get_current_user_id();
if (isset($_POST['usc_action']) && check_admin_referer('usc_availability_action')) { if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_availability_action' ) ) {
$this->handleFormAction($instructorId); $this->handleFormAction( $instructorId );
} }
$slots = $this->repository->findByInstructor($instructorId); $slots = $this->repository->findByInstructor( $instructorId );
include USC_PLUGIN_DIR . 'templates/admin/availability.php'; include USC_PLUGIN_DIR . 'templates/admin/availability.php';
} }
private function handleFormAction(int $instructorId): void private function handleFormAction( int $instructorId ): void {
{ // Nonce is verified by the caller (renderPage) before this method runs.
$action = sanitize_key($_POST['usc_action'] ?? ''); // phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) );
if ($action === 'add') { if ( 'add' === $action ) {
$startDt = sanitize_text_field($_POST['start_dt'] ?? ''); $startDt = sanitize_text_field( wp_unslash( $_POST['start_dt'] ?? '' ) );
$endDt = sanitize_text_field($_POST['end_dt'] ?? ''); $endDt = sanitize_text_field( wp_unslash( $_POST['end_dt'] ?? '' ) );
if ($startDt !== '' && $endDt !== '') { if ( '' !== $startDt && '' !== $endDt ) {
$this->repository->insert(new AvailabilitySlot($instructorId, $startDt, $endDt)); $this->repository->insert( new AvailabilitySlot( $instructorId, $startDt, $endDt ) );
} }
} }
if ($action === 'delete') { if ( 'delete' === $action ) {
$slotId = absint($_POST['slot_id'] ?? 0); $slotId = absint( $_POST['slot_id'] ?? 0 );
if ($slotId > 0) { if ( $slotId > 0 ) {
$slot = $this->repository->findById($slotId); $slot = $this->repository->findById( $slotId );
if ($slot && $slot->instructorId === $instructorId) { if ( $slot && $slot->instructorId === $instructorId ) {
$this->repository->delete($slotId); $this->repository->delete( $slotId );
} }
} }
} }
// phpcs:enable WordPress.Security.NonceVerification.Missing
} }
} }

View File

@@ -6,14 +6,13 @@ namespace Unsupervised\Schedular\Admin;
use Unsupervised\Schedular\Data\BookingRepository; use Unsupervised\Schedular\Data\BookingRepository;
use Unsupervised\Schedular\Roles\RoleManager; use Unsupervised\Schedular\Roles\RoleManager;
class LessonController class LessonController {
{
public function __construct(private BookingRepository $repository) {}
public function renderAdminDashboard(): void public function __construct( private BookingRepository $repository ) {}
{
if (! current_user_can('manage_options')) { public function renderAdminDashboard(): void {
wp_die(esc_html__('You do not have permission to view this page.', 'unsupervised-schedular')); 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(); $lessons = $this->repository->findAllUpcoming();
@@ -21,13 +20,12 @@ class LessonController
include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
} }
public function renderInstructorLessons(): void public function renderInstructorLessons(): void {
{ if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
if (! current_user_can(RoleManager::CAP_VIEW_LESSONS)) { wp_die( esc_html__( 'You do not have permission to view lessons.', 'unsupervised-schedular' ) );
wp_die(esc_html__('You do not have permission to view lessons.', 'unsupervised-schedular'));
} }
$lessons = $this->repository->findUpcomingForInstructor(get_current_user_id()); $lessons = $this->repository->findUpcomingForInstructor( get_current_user_id() );
include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
} }

View File

@@ -7,96 +7,115 @@ use Unsupervised\Schedular\Data\AvailabilityRepository;
use Unsupervised\Schedular\Model\AvailabilitySlot; use Unsupervised\Schedular\Model\AvailabilitySlot;
use Unsupervised\Schedular\Roles\RoleManager; use Unsupervised\Schedular\Roles\RoleManager;
class AvailabilityEndpoint class AvailabilityEndpoint {
{
public function __construct(private AvailabilityRepository $repository) {}
public function registerRoutes(string $namespace): void public function __construct( private AvailabilityRepository $repository ) {}
{
register_rest_route($namespace, '/availability', [ public function registerRoutes( string $route_namespace ): void {
register_rest_route(
$route_namespace,
'/availability',
[
[ [
'methods' => \WP_REST_Server::READABLE, 'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'index'], 'callback' => [ $this, 'index' ],
'permission_callback' => [$this, 'canBook'], 'permission_callback' => [ $this, 'canBook' ],
'args' => [ 'args' => [
'instructor_id' => ['type' => 'integer', 'default' => 0], 'instructor_id' => [
'from' => ['type' => 'string', 'default' => ''], 'type' => 'integer',
'to' => ['type' => 'string', 'default' => ''], 'default' => 0,
],
'from' => [
'type' => 'string',
'default' => '',
],
'to' => [
'type' => 'string',
'default' => '',
],
], ],
], ],
[ [
'methods' => \WP_REST_Server::CREATABLE, 'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'create'], 'callback' => [ $this, 'create' ],
'permission_callback' => [$this, 'canManage'], 'permission_callback' => [ $this, 'canManage' ],
'args' => [ 'args' => [
'start_dt' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field'], 'start_dt' => [
'end_dt' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field'], '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+)', [ register_rest_route(
$route_namespace,
'/availability/(?P<id>\d+)',
[
[ [
'methods' => \WP_REST_Server::DELETABLE, 'methods' => \WP_REST_Server::DELETABLE,
'callback' => [$this, 'delete'], 'callback' => [ $this, 'delete' ],
'permission_callback' => [$this, 'canManage'], 'permission_callback' => [ $this, 'canManage' ],
], ],
]); ]
);
} }
public function index(\WP_REST_Request $request): \WP_REST_Response public function index( \WP_REST_Request $request ): \WP_REST_Response {
{
$slots = $this->repository->findAvailable( $slots = $this->repository->findAvailable(
(int) $request->get_param('instructor_id'), (int) $request->get_param( 'instructor_id' ),
(string) $request->get_param('from'), (string) $request->get_param( 'from' ),
(string) $request->get_param('to'), (string) $request->get_param( 'to' ),
); );
return new \WP_REST_Response(array_map(fn(AvailabilitySlot $s) => $s->toArray(), $slots), 200); return new \WP_REST_Response( array_map( fn( AvailabilitySlot $s ) => $s->toArray(), $slots ), 200 );
} }
public function create(\WP_REST_Request $request): \WP_REST_Response public function create( \WP_REST_Request $request ): \WP_REST_Response {
{
$slot = new AvailabilitySlot( $slot = new AvailabilitySlot(
instructorId: get_current_user_id(), instructorId: get_current_user_id(),
startDt: (string) $request->get_param('start_dt'), startDt: (string) $request->get_param( 'start_dt' ),
endDt: (string) $request->get_param('end_dt'), endDt: (string) $request->get_param( 'end_dt' ),
); );
$id = $this->repository->insert($slot); $id = $this->repository->insert( $slot );
return new \WP_REST_Response(['id' => $id], 201); return new \WP_REST_Response( [ 'id' => $id ], 201 );
} }
public function delete(\WP_REST_Request $request): \WP_REST_Response|\WP_Error public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
{ $id = absint( $request->get_param( 'id' ) );
$id = absint($request->get_param('id')); $slot = $this->repository->findById( $id );
$slot = $this->repository->findById($id);
if ($slot === null) { if ( null === $slot ) {
return new \WP_Error('not_found', __('Slot not found.', 'unsupervised-schedular'), ['status' => 404]); return new \WP_Error( 'not_found', __( 'Slot not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
} }
if ($slot->instructorId !== get_current_user_id()) { if ( get_current_user_id() !== $slot->instructorId ) {
return new \WP_Error('forbidden', __('You cannot delete this slot.', 'unsupervised-schedular'), ['status' => 403]); return new \WP_Error( 'forbidden', __( 'You cannot delete this slot.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
} }
if ($slot->isBooked) { if ( $slot->isBooked ) {
return new \WP_Error('slot_booked', __('Cannot delete a booked slot.', 'unsupervised-schedular'), ['status' => 409]); return new \WP_Error( 'slot_booked', __( 'Cannot delete a booked slot.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
} }
$this->repository->delete($id); $this->repository->delete( $id );
return new \WP_REST_Response(null, 204); return new \WP_REST_Response( null, 204 );
} }
public function canBook(): bool public function canBook(): bool {
{ return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
return is_user_logged_in() && current_user_can(RoleManager::CAP_BOOK_LESSON);
} }
public function canManage(): bool public function canManage(): bool {
{ return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY );
return is_user_logged_in() && current_user_can(RoleManager::CAP_MANAGE_AVAILABILITY);
} }
} }

View File

@@ -8,37 +8,51 @@ use Unsupervised\Schedular\Data\BookingRepository;
use Unsupervised\Schedular\Model\Lesson; use Unsupervised\Schedular\Model\Lesson;
use Unsupervised\Schedular\Roles\RoleManager; use Unsupervised\Schedular\Roles\RoleManager;
class BookingEndpoint class BookingEndpoint {
{
public function __construct( public function __construct(
private AvailabilityRepository $availability, private AvailabilityRepository $availability,
private BookingRepository $bookings, private BookingRepository $bookings,
) {} ) {}
public function registerRoutes(string $namespace): void public function registerRoutes( string $route_namespace ): void {
{ register_rest_route(
register_rest_route($namespace, '/bookings', [ $route_namespace,
'/bookings',
[
[ [
'methods' => \WP_REST_Server::READABLE, 'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'myLessons'], 'callback' => [ $this, 'myLessons' ],
'permission_callback' => [$this, 'isLoggedIn'], 'permission_callback' => [ $this, 'isLoggedIn' ],
], ],
[ [
'methods' => \WP_REST_Server::CREATABLE, 'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'book'], 'callback' => [ $this, 'book' ],
'permission_callback' => [$this, 'canBook'], 'permission_callback' => [ $this, 'canBook' ],
'args' => [ 'args' => [
'slot_id' => ['type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint'], 'slot_id' => [
'notes' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_textarea_field'], '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', [ register_rest_route(
$route_namespace,
'/bookings/(?P<id>\d+)/status',
[
[ [
'methods' => \WP_REST_Server::EDITABLE, 'methods' => \WP_REST_Server::EDITABLE,
'callback' => [$this, 'updateStatus'], 'callback' => [ $this, 'updateStatus' ],
'permission_callback' => [$this, 'canManage'], 'permission_callback' => [ $this, 'canManage' ],
'args' => [ 'args' => [
'status' => [ 'status' => [
'type' => 'string', 'type' => 'string',
@@ -47,77 +61,85 @@ class BookingEndpoint
], ],
], ],
], ],
]); ]
);
} }
public function myLessons(\WP_REST_Request $request): \WP_REST_Response public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
{
$userId = get_current_user_id(); $userId = get_current_user_id();
$lessons = current_user_can(RoleManager::CAP_MANAGE_AVAILABILITY) $lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY )
? $this->bookings->findUpcomingForInstructor($userId) ? $this->bookings->findUpcomingForInstructor( $userId )
: $this->bookings->findByStudent($userId); : $this->bookings->findByStudent( $userId );
return new \WP_REST_Response(array_map(fn(Lesson $l) => $l->toArray(), $lessons), 200); 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 public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
{ $slotId = (int) $request->get_param( 'slot_id' );
$slotId = (int) $request->get_param('slot_id'); $slot = $this->availability->findById( $slotId );
$slot = $this->availability->findById($slotId);
if ($slot === null) { if ( null === $slot ) {
return new \WP_Error('not_found', __('Slot not found.', 'unsupervised-schedular'), ['status' => 404]); return new \WP_Error( 'not_found', __( 'Slot not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
} }
if ($slot->isBooked) { if ( $slot->isBooked ) {
return new \WP_Error('slot_taken', __('This slot is already booked.', 'unsupervised-schedular'), ['status' => 409]); return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
} }
$notes = (string) $request->get_param( 'notes' );
$lesson = new Lesson( $lesson = new Lesson(
slotId: $slotId, slotId: $slotId,
studentId: get_current_user_id(), studentId: get_current_user_id(),
instructorId: $slot->instructorId, instructorId: $slot->instructorId,
notes: (string) $request->get_param('notes') ?: null, notes: '' !== $notes ? $notes : null,
); );
$id = $this->bookings->insert($lesson); $id = $this->bookings->insert( $lesson );
$this->availability->markBooked($slotId); $this->availability->markBooked( $slotId );
return new \WP_REST_Response(['id' => $id, 'status' => Lesson::STATUS_PENDING], 201); return new \WP_REST_Response(
[
'id' => $id,
'status' => Lesson::STATUS_PENDING,
],
201
);
} }
public function updateStatus(\WP_REST_Request $request): \WP_REST_Response|\WP_Error public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
{ $id = absint( $request->get_param( 'id' ) );
$id = absint($request->get_param('id')); $lesson = $this->bookings->findById( $id );
$lesson = $this->bookings->findById($id);
if ($lesson === null) { if ( null === $lesson ) {
return new \WP_Error('not_found', __('Booking not found.', 'unsupervised-schedular'), ['status' => 404]); 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')) { 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_Error( 'forbidden', __( 'You cannot update this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
} }
$this->bookings->updateStatus($id, (string) $request->get_param('status')); $this->bookings->updateStatus( $id, (string) $request->get_param( 'status' ) );
return new \WP_REST_Response(['id' => $id, 'status' => $request->get_param('status')], 200); return new \WP_REST_Response(
[
'id' => $id,
'status' => $request->get_param( 'status' ),
],
200
);
} }
public function isLoggedIn(): bool public function isLoggedIn(): bool {
{
return is_user_logged_in(); return is_user_logged_in();
} }
public function canBook(): bool public function canBook(): bool {
{ return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
return is_user_logged_in() && current_user_can(RoleManager::CAP_BOOK_LESSON);
} }
public function canManage(): bool public function canManage(): bool {
{
return is_user_logged_in() && ( return is_user_logged_in() && (
current_user_can(RoleManager::CAP_MANAGE_AVAILABILITY) || current_user_can('manage_options') current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) || current_user_can( 'manage_options' )
); );
} }
} }

View File

@@ -6,27 +6,24 @@ namespace Unsupervised\Schedular\Api;
use Unsupervised\Schedular\Data\AvailabilityRepository; use Unsupervised\Schedular\Data\AvailabilityRepository;
use Unsupervised\Schedular\Data\BookingRepository; use Unsupervised\Schedular\Data\BookingRepository;
class RestRegistrar class RestRegistrar {
{
public const NAMESPACE = 'us-scheduler/v1'; public const NAMESPACE = 'us-scheduler/v1';
private AvailabilityEndpoint $availabilityEndpoint; private AvailabilityEndpoint $availabilityEndpoint;
private BookingEndpoint $bookingEndpoint; private BookingEndpoint $bookingEndpoint;
public function __construct(AvailabilityRepository $availability, BookingRepository $bookings) public function __construct( AvailabilityRepository $availability, BookingRepository $bookings ) {
{ $this->availabilityEndpoint = new AvailabilityEndpoint( $availability );
$this->availabilityEndpoint = new AvailabilityEndpoint($availability); $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings );
$this->bookingEndpoint = new BookingEndpoint($availability, $bookings);
} }
public function register(): void public function register(): void {
{ add_action( 'rest_api_init', [ $this, 'registerRoutes' ] );
add_action('rest_api_init', [$this, 'registerRoutes']);
} }
public function registerRoutes(): void public function registerRoutes(): void {
{ $this->availabilityEndpoint->registerRoutes( self::NAMESPACE );
$this->availabilityEndpoint->registerRoutes(self::NAMESPACE); $this->bookingEndpoint->registerRoutes( self::NAMESPACE );
$this->bookingEndpoint->registerRoutes(self::NAMESPACE);
} }
} }

View File

@@ -5,17 +5,15 @@ namespace Unsupervised\Schedular\Data;
use Unsupervised\Schedular\Model\AvailabilitySlot; use Unsupervised\Schedular\Model\AvailabilitySlot;
class AvailabilityRepository class AvailabilityRepository {
{
private string $table; private string $table;
public function __construct(private \wpdb $db) public function __construct( private \wpdb $db ) {
{
$this->table = $db->prefix . 'us_availability'; $this->table = $db->prefix . 'us_availability';
} }
public function insert(AvailabilitySlot $slot): int public function insert( AvailabilitySlot $slot ): int {
{
$this->db->insert( $this->db->insert(
$this->table, $this->table,
[ [
@@ -23,9 +21,9 @@ class AvailabilityRepository
'start_dt' => $slot->startDt, 'start_dt' => $slot->startDt,
'end_dt' => $slot->endDt, 'end_dt' => $slot->endDt,
'is_booked' => 0, 'is_booked' => 0,
'created_at' => current_time('mysql'), 'created_at' => current_time( 'mysql' ),
], ],
['%d', '%s', '%s', '%d', '%s'] [ '%d', '%s', '%s', '%d', '%s' ]
); );
return $this->db->insert_id; return $this->db->insert_id;
@@ -36,34 +34,33 @@ class AvailabilityRepository
* *
* @return list<AvailabilitySlot> * @return list<AvailabilitySlot>
*/ */
public function findAvailable(int $instructorId = 0, string $from = '', string $to = ''): array public function findAvailable( int $instructorId = 0, string $from = '', string $to = '' ): array {
{ $where = [ 'is_booked = 0' ];
$where = ['is_booked = 0'];
$params = []; $params = [];
if ($instructorId > 0) { if ( $instructorId > 0 ) {
$where[] = 'instructor_id = %d'; $where[] = 'instructor_id = %d';
$params[] = $instructorId; $params[] = $instructorId;
} }
if ($from !== '') { if ( '' !== $from ) {
$where[] = 'start_dt >= %s'; $where[] = 'start_dt >= %s';
$params[] = $from; $params[] = $from;
} }
if ($to !== '') { if ( '' !== $to ) {
$where[] = 'end_dt <= %s'; $where[] = 'end_dt <= %s';
$params[] = $to; $params[] = $to;
} }
$whereClause = implode(' AND ', $where); $whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC"; $sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC";
$rows = $params $rows = $params
? $this->db->get_results($this->db->prepare($sql, $params)) ? $this->db->get_results( $this->db->prepare( $sql, $params ) )
: $this->db->get_results($sql); : $this->db->get_results( $sql );
return array_map(AvailabilitySlot::fromRow(...), $rows ?? []); return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
} }
/** /**
@@ -71,8 +68,7 @@ class AvailabilityRepository
* *
* @return list<AvailabilitySlot> * @return list<AvailabilitySlot>
*/ */
public function findByInstructor(int $instructorId): array public function findByInstructor( int $instructorId ): array {
{
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
"SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY start_dt ASC", "SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY start_dt ASC",
@@ -80,38 +76,38 @@ class AvailabilityRepository
) )
); );
return array_map(AvailabilitySlot::fromRow(...), $rows ?? []); return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
} }
public function findById(int $id): ?AvailabilitySlot public function findById( int $id ): ?AvailabilitySlot {
{
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
); );
return $row ? AvailabilitySlot::fromRow($row) : null; return $row ? AvailabilitySlot::fromRow( $row ) : null;
} }
public function markBooked(int $id): bool public function markBooked( int $id ): bool {
{
return (bool) $this->db->update( return (bool) $this->db->update(
$this->table, $this->table,
['is_booked' => 1], [ 'is_booked' => 1 ],
['id' => $id], [ 'id' => $id ],
['%d'], [ '%d' ],
['%d'] [ '%d' ]
); );
} }
/** /**
* Delete an unbooked slot. Returns false if the slot is already booked. * Delete an unbooked slot. Returns false if the slot is already booked.
*/ */
public function delete(int $id): bool public function delete( int $id ): bool {
{
return (bool) $this->db->delete( return (bool) $this->db->delete(
$this->table, $this->table,
['id' => $id, 'is_booked' => 0], [
['%d', '%d'] 'id' => $id,
'is_booked' => 0,
],
[ '%d', '%d' ]
); );
} }
} }

View File

@@ -5,17 +5,15 @@ namespace Unsupervised\Schedular\Data;
use Unsupervised\Schedular\Model\Lesson; use Unsupervised\Schedular\Model\Lesson;
class BookingRepository class BookingRepository {
{
private string $table; private string $table;
public function __construct(private \wpdb $db) public function __construct( private \wpdb $db ) {
{
$this->table = $db->prefix . 'us_lessons'; $this->table = $db->prefix . 'us_lessons';
} }
public function insert(Lesson $lesson): int public function insert( Lesson $lesson ): int {
{
$this->db->insert( $this->db->insert(
$this->table, $this->table,
[ [
@@ -24,21 +22,20 @@ class BookingRepository
'instructor_id' => $lesson->instructorId, 'instructor_id' => $lesson->instructorId,
'status' => $lesson->status, 'status' => $lesson->status,
'notes' => $lesson->notes, 'notes' => $lesson->notes,
'created_at' => current_time('mysql'), 'created_at' => current_time( 'mysql' ),
], ],
['%d', '%d', '%d', '%s', '%s', '%s'] [ '%d', '%d', '%d', '%s', '%s', '%s' ]
); );
return $this->db->insert_id; return $this->db->insert_id;
} }
public function findById(int $id): ?Lesson public function findById( int $id ): ?Lesson {
{
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id) $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
); );
return $row ? Lesson::fromRow($row) : null; return $row ? Lesson::fromRow( $row ) : null;
} }
/** /**
@@ -46,9 +43,8 @@ class BookingRepository
* *
* @return list<Lesson> * @return list<Lesson>
*/ */
public function findUpcomingForInstructor(int $instructorId): array public function findUpcomingForInstructor( int $instructorId ): array {
{ $avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
$avTable = str_replace('us_lessons', 'us_availability', $this->table);
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
@@ -60,11 +56,11 @@ class BookingRepository
ORDER BY a.start_dt ASC", ORDER BY a.start_dt ASC",
$instructorId, $instructorId,
Lesson::STATUS_CANCELLED, Lesson::STATUS_CANCELLED,
current_time('mysql') current_time( 'mysql' )
) )
); );
return array_map(Lesson::fromRow(...), $rows ?? []); return array_map( Lesson::fromRow( ... ), $rows ?? [] );
} }
/** /**
@@ -72,8 +68,7 @@ class BookingRepository
* *
* @return list<Lesson> * @return list<Lesson>
*/ */
public function findByStudent(int $studentId): array public function findByStudent( int $studentId ): array {
{
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
"SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY created_at DESC", "SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY created_at DESC",
@@ -81,7 +76,7 @@ class BookingRepository
) )
); );
return array_map(Lesson::fromRow(...), $rows ?? []); return array_map( Lesson::fromRow( ... ), $rows ?? [] );
} }
/** /**
@@ -89,9 +84,8 @@ class BookingRepository
* *
* @return list<Lesson> * @return list<Lesson>
*/ */
public function findAllUpcoming(): array public function findAllUpcoming(): array {
{ $avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
$avTable = str_replace('us_lessons', 'us_availability', $this->table);
$rows = $this->db->get_results( $rows = $this->db->get_results(
$this->db->prepare( $this->db->prepare(
@@ -101,25 +95,24 @@ class BookingRepository
AND a.start_dt >= %s AND a.start_dt >= %s
ORDER BY a.start_dt ASC", ORDER BY a.start_dt ASC",
Lesson::STATUS_CANCELLED, Lesson::STATUS_CANCELLED,
current_time('mysql') current_time( 'mysql' )
) )
); );
return array_map(Lesson::fromRow(...), $rows ?? []); return array_map( Lesson::fromRow( ... ), $rows ?? [] );
} }
public function updateStatus(int $id, string $status): bool public function updateStatus( int $id, string $status ): bool {
{ if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) {
if (! in_array($status, Lesson::VALID_STATUSES, true)) {
return false; return false;
} }
return (bool) $this->db->update( return (bool) $this->db->update(
$this->table, $this->table,
['status' => $status], [ 'status' => $status ],
['id' => $id], [ 'id' => $id ],
['%s'], [ '%s' ],
['%d'] [ '%d' ]
); );
} }
} }

View File

@@ -3,15 +3,14 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Data; namespace Unsupervised\Schedular\Data;
class Schema class Schema {
{
/** /**
* Returns CREATE TABLE statements for dbDelta. * Returns CREATE TABLE statements for dbDelta.
* *
* @return list<string> * @return list<string>
*/ */
public static function tables(string $prefix, string $charset): array public static function tables( string $prefix, string $charset ): array {
{
return [ return [
"CREATE TABLE {$prefix}us_availability ( "CREATE TABLE {$prefix}us_availability (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,

View File

@@ -3,34 +3,31 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Frontend; namespace Unsupervised\Schedular\Frontend;
use Unsupervised\Schedular\Data\AvailabilityRepository;
use Unsupervised\Schedular\Data\BookingRepository;
use Unsupervised\Schedular\Roles\RoleManager; use Unsupervised\Schedular\Roles\RoleManager;
class BookingPage class BookingPage {
{
public function __construct(
private AvailabilityRepository $availability,
private BookingRepository $bookings,
) {}
public function render(array $atts): string /**
{ * Renders the booking shortcode output.
if (! is_user_logged_in()) { *
* @param array<string, string> $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( return sprintf(
'<p>%s <a href="%s">%s</a>.</p>', '<p>%s <a href="%s">%s</a>.</p>',
esc_html__('Please', 'unsupervised-schedular'), esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url(wp_login_url(get_permalink())), esc_url( wp_login_url( get_permalink() ) ),
esc_html__('log in to book a lesson', 'unsupervised-schedular') esc_html__( 'log in to book a lesson', 'unsupervised-schedular' )
); );
} }
if (! current_user_can(RoleManager::CAP_BOOK_LESSON)) { if ( ! current_user_can( RoleManager::CAP_BOOK_LESSON ) ) {
return '<p>' . esc_html__('This page is for students only.', 'unsupervised-schedular') . '</p>'; return '<p>' . esc_html__( 'This page is for students only.', 'unsupervised-schedular' ) . '</p>';
} }
wp_enqueue_style('us-scheduler'); wp_enqueue_style( 'us-scheduler' );
wp_enqueue_script('us-scheduler'); wp_enqueue_script( 'us-scheduler' );
ob_start(); ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php'; include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';

View File

@@ -3,36 +3,41 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Frontend; namespace Unsupervised\Schedular\Frontend;
class LoginPage class LoginPage {
{
public function render(array $atts): string /**
{ * Renders the student login shortcode output.
if (is_user_logged_in()) { *
$redirect = esc_url(get_permalink()); * @param array<string, string> $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( return sprintf(
'<p>%s <a href="%s">%s</a>.</p>', '<p>%s <a href="%s">%s</a>.</p>',
esc_html__('You are already logged in.', 'unsupervised-schedular'), esc_html__( 'You are already logged in.', 'unsupervised-schedular' ),
$redirect, $redirect,
esc_html__('View available lessons', 'unsupervised-schedular') esc_html__( 'View available lessons', 'unsupervised-schedular' )
); );
} }
$error = ''; $error = '';
$redirect = sanitize_url(get_permalink() ?? ''); $redirect = sanitize_url( (string) get_permalink() );
if (isset($_POST['us_login']) && check_admin_referer('us_student_login')) { if ( isset( $_POST['us_login'] ) && check_admin_referer( 'us_student_login' ) ) {
$credentials = [ $credentials = [
'user_login' => sanitize_user($_POST['log'] ?? ''), 'user_login' => sanitize_user( wp_unslash( $_POST['log'] ?? '' ) ),
'user_password' => $_POST['pwd'] ?? '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- passwords must not be sanitized.
'remember' => isset($_POST['rememberme']), 'user_password' => wp_unslash( $_POST['pwd'] ?? '' ),
'remember' => isset( $_POST['rememberme'] ),
]; ];
$user = wp_signon($credentials, false); $user = wp_signon( $credentials, false );
if (is_wp_error($user)) { if ( is_wp_error( $user ) ) {
$error = esc_html__('Invalid username or password.', 'unsupervised-schedular'); $error = esc_html__( 'Invalid username or password.', 'unsupervised-schedular' );
} else { } else {
wp_safe_redirect($redirect); wp_safe_redirect( $redirect );
exit; exit;
} }
} }

View File

@@ -3,35 +3,33 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Frontend; namespace Unsupervised\Schedular\Frontend;
use Unsupervised\Schedular\Data\AvailabilityRepository; class ShortcodeRegistrar {
use Unsupervised\Schedular\Data\BookingRepository;
class ShortcodeRegistrar
{
private BookingPage $bookingPage; private BookingPage $bookingPage;
private LoginPage $loginPage; private LoginPage $loginPage;
public function __construct(AvailabilityRepository $availability, BookingRepository $bookings) public function __construct() {
{ $this->bookingPage = new BookingPage();
$this->bookingPage = new BookingPage($availability, $bookings);
$this->loginPage = new LoginPage(); $this->loginPage = new LoginPage();
} }
public function register(): void public function register(): void {
{ add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] );
add_shortcode('us_booking', [$this->bookingPage, 'render']); add_shortcode( 'us_student_login', [ $this->loginPage, 'render' ] );
add_shortcode('us_student_login', [$this->loginPage, 'render']); add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
add_action('wp_enqueue_scripts', [$this, 'enqueueAssets']);
} }
public function enqueueAssets(): void public function enqueueAssets(): void {
{ wp_register_style( 'us-scheduler', USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
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_register_script('us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [], USC_VERSION, true);
wp_localize_script('us-scheduler', 'usScheduler', [ wp_localize_script(
'restUrl' => rest_url('us-scheduler/v1/'), 'us-scheduler',
'nonce' => wp_create_nonce('wp_rest'), 'usScheduler',
]); [
'restUrl' => rest_url( 'us-scheduler/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
]
);
} }
} }

View File

@@ -6,25 +6,23 @@ namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Data\Schema; use Unsupervised\Schedular\Data\Schema;
use Unsupervised\Schedular\Roles\RoleManager; use Unsupervised\Schedular\Roles\RoleManager;
class Installer class Installer {
{
public function run(): void public function run(): void {
{
$this->createTables(); $this->createTables();
(new RoleManager())->createRoles(); ( new RoleManager() )->createRoles();
flush_rewrite_rules(); flush_rewrite_rules();
update_option('us_schedular_version', USC_VERSION); update_option( 'us_schedular_version', USC_VERSION );
} }
private function createTables(): void private function createTables(): void {
{
global $wpdb; global $wpdb;
$charset = $wpdb->get_charset_collate(); $charset = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php'; require_once ABSPATH . 'wp-admin/includes/upgrade.php';
foreach (Schema::tables($wpdb->prefix, $charset) as $sql) { foreach ( Schema::tables( $wpdb->prefix, $charset ) as $sql ) {
dbDelta($sql); dbDelta( $sql );
} }
} }
} }

View File

@@ -3,8 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Model; namespace Unsupervised\Schedular\Model;
class AvailabilitySlot class AvailabilitySlot {
{
public function __construct( public function __construct(
public readonly int $instructorId, public readonly int $instructorId,
public readonly string $startDt, public readonly string $startDt,
@@ -13,8 +13,7 @@ class AvailabilitySlot
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
public static function fromRow(object $row): self public static function fromRow( object $row ): self {
{
return new self( return new self(
instructorId: (int) $row->instructor_id, instructorId: (int) $row->instructor_id,
startDt: $row->start_dt, startDt: $row->start_dt,
@@ -24,8 +23,12 @@ class AvailabilitySlot
); );
} }
public function toArray(): array /**
{ * Returns a plain array representation of the slot.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [ return [
'id' => $this->id, 'id' => $this->id,
'instructor_id' => $this->instructorId, 'instructor_id' => $this->instructorId,

View File

@@ -3,14 +3,18 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Model; namespace Unsupervised\Schedular\Model;
class Lesson class Lesson {
{
public const STATUS_PENDING = 'pending'; public const STATUS_PENDING = 'pending';
public const STATUS_CONFIRMED = 'confirmed'; public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_CANCELLED = 'cancelled'; public const STATUS_CANCELLED = 'cancelled';
/** @var list<string> */ /**
public const VALID_STATUSES = [self::STATUS_PENDING, self::STATUS_CONFIRMED, self::STATUS_CANCELLED]; * All valid status values.
*
* @var list<string>
*/
public const VALID_STATUSES = [ self::STATUS_PENDING, self::STATUS_CONFIRMED, self::STATUS_CANCELLED ];
public function __construct( public function __construct(
public readonly int $slotId, public readonly int $slotId,
@@ -21,8 +25,7 @@ class Lesson
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
public static function fromRow(object $row): self public static function fromRow( object $row ): self {
{
return new self( return new self(
slotId: (int) $row->slot_id, slotId: (int) $row->slot_id,
studentId: (int) $row->student_id, studentId: (int) $row->student_id,
@@ -33,8 +36,12 @@ class Lesson
); );
} }
public function toArray(): array /**
{ * Returns a plain array representation of the lesson.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [ return [
'id' => $this->id, 'id' => $this->id,
'slot_id' => $this->slotId, 'slot_id' => $this->slotId,

View File

@@ -10,19 +10,18 @@ use Unsupervised\Schedular\Data\BookingRepository;
use Unsupervised\Schedular\Frontend\ShortcodeRegistrar; use Unsupervised\Schedular\Frontend\ShortcodeRegistrar;
use Unsupervised\Schedular\Roles\RoleManager; use Unsupervised\Schedular\Roles\RoleManager;
class Plugin class Plugin {
{
public static function boot(): void public static function boot(): void {
{ load_plugin_textdomain( 'unsupervised-schedular', false, dirname( plugin_basename( USC_PLUGIN_FILE ) ) . '/languages' );
load_plugin_textdomain('unsupervised-schedular', false, dirname(plugin_basename(USC_PLUGIN_FILE)) . '/languages');
global $wpdb; global $wpdb;
$availability = new AvailabilityRepository($wpdb); $availability = new AvailabilityRepository( $wpdb );
$bookings = new BookingRepository($wpdb); $bookings = new BookingRepository( $wpdb );
(new RoleManager())->register(); ( new RoleManager() )->register();
(new AdminMenu($availability, $bookings))->register(); ( new AdminMenu( $availability, $bookings ) )->register();
(new RestRegistrar($availability, $bookings))->register(); ( new RestRegistrar( $availability, $bookings ) )->register();
(new ShortcodeRegistrar($availability, $bookings))->register(); ( new ShortcodeRegistrar() )->register();
} }
} }

View File

@@ -3,8 +3,8 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Roles; namespace Unsupervised\Schedular\Roles;
class RoleManager class RoleManager {
{
public const INSTRUCTOR = 'us_instructor'; public const INSTRUCTOR = 'us_instructor';
public const STUDENT = 'us_student'; public const STUDENT = 'us_student';
@@ -12,17 +12,15 @@ class RoleManager
public const CAP_VIEW_LESSONS = 'view_own_lessons'; public const CAP_VIEW_LESSONS = 'view_own_lessons';
public const CAP_BOOK_LESSON = 'book_lesson'; public const CAP_BOOK_LESSON = 'book_lesson';
public function register(): void public function register(): void {
{ add_action( 'init', [ $this, 'createRoles' ] );
add_action('init', [$this, 'createRoles']);
} }
public function createRoles(): void public function createRoles(): void {
{ if ( get_role( self::INSTRUCTOR ) === null ) {
if (get_role(self::INSTRUCTOR) === null) {
add_role( add_role(
self::INSTRUCTOR, self::INSTRUCTOR,
__('Instructor', 'unsupervised-schedular'), __( 'Instructor', 'unsupervised-schedular' ),
[ [
'read' => true, 'read' => true,
self::CAP_MANAGE_AVAILABILITY => true, self::CAP_MANAGE_AVAILABILITY => true,
@@ -31,10 +29,10 @@ class RoleManager
); );
} }
if (get_role(self::STUDENT) === null) { if ( get_role( self::STUDENT ) === null ) {
add_role( add_role(
self::STUDENT, self::STUDENT,
__('Student', 'unsupervised-schedular'), __( 'Student', 'unsupervised-schedular' ),
[ [
'read' => true, 'read' => true,
self::CAP_BOOK_LESSON => true, self::CAP_BOOK_LESSON => true,