Compare commits

...

3 Commits

Author SHA1 Message Date
2fb2ca392d Restructure src/ and tests/ from package-by-type to package-by-domain
All checks were successful
CI / Coding Standards (push) Successful in 43s
CI / PHPStan (push) Successful in 52s
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.2) (push) Successful in 49s
CI / Tests (PHP 8.3) (push) Successful in 37s
CI / No Debug Code (push) Successful in 2s
All classes are now organised by domain (Availability, Booking, Auth).
Each domain package contains its value object, repository, admin controller,
REST endpoint, and any shortcode pages under a matching sub-namespace.
Cross-cutting wiring (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar,
Schema) lives at src/ root. Tests mirror the domain structure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:37:30 -03:00
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
46 changed files with 1160 additions and 993 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
run: composer cs
static-analysis:
name: PHPStan
runs-on: ubuntu-latest

View File

@@ -13,7 +13,7 @@ composer cs # PHPCS coding standards check
composer cs:fix # Auto-fix coding standards
# Run a single test file
./vendor/bin/phpunit tests/Unit/Data/AvailabilityRepositoryTest.php
./vendor/bin/phpunit tests/Unit/Availability/AvailabilityRepositoryTest.php
# Run a single test by name
./vendor/bin/phpunit --filter testInsertCallsWpdbInsertAndReturnsId
@@ -29,18 +29,32 @@ composer cs:fix # Auto-fix coding standards
### Directory Structure
```
src/ — All plugin PHP (PSR-4 namespace: Unsupervised\Schedular\)
Availability/ — Availability slots: value object, repository, controller, REST endpoint
Booking/ — Lessons/bookings: value object, repository, controller, REST endpoint, shortcode page
Auth/ — Roles, capabilities, login page
Plugin.php — Wires all components together on plugins_loaded
Installer.php — Creates DB tables and roles on activation
Schema.php — CREATE TABLE SQL for dbDelta
AdminMenu.php — Registers wp-admin menu pages
RestRegistrar.php — Registers all REST routes under us-scheduler/v1
ShortcodeRegistrar.php — Registers [us_booking] and [us_student_login] shortcodes
templates/ — PHP view files included by controllers/shortcodes
assets/ — CSS and JS (vanilla JS, no build step)
tests/Unit/ — PHPUnit unit tests (PSR-4: Unsupervised\Schedular\Tests\)
Availability/ — Tests for src/Availability/
Booking/ — Tests for src/Booking/
Auth/ — Tests for src/Auth/
docs/features/ — One markdown file per feature describing data model, API, and test locations
```
**Code is organised package-by-domain** (Availability, Booking, Auth). Each domain package contains everything related to that domain: value objects, repositories, controllers, REST endpoints, and shortcode pages. Cross-cutting wiring classes (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) live directly under `src/`.
### Data Storage
Two custom database tables (created via `dbDelta` on activation):
- `{prefix}us_availability` — instructor availability windows
- `{prefix}us_lessons` — booked lessons
All database access goes through repository classes in `src/Data/`. No direct `$wpdb` calls outside repositories.
All database access goes through repository classes within their domain package. No direct `$wpdb` calls outside repositories.
### Key Classes
@@ -48,20 +62,21 @@ All database access goes through repository classes in `src/Data/`. No direct `$
|---|---|
| `Plugin` | Wires all components together on `plugins_loaded` |
| `Installer` | Creates DB tables and roles on activation |
| `Roles\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps |
| `Data\AvailabilityRepository` | CRUD for availability slots |
| `Data\BookingRepository` | CRUD for lesson bookings |
| `Model\AvailabilitySlot` | Immutable value object for a slot row |
| `Model\Lesson` | Immutable value object for a lesson row |
| `Admin\AdminMenu` | Registers wp-admin menu pages |
| `Admin\AvailabilityController` | Instructor availability management page |
| `Admin\LessonController` | Admin and instructor lesson list pages |
| `Api\RestRegistrar` | Registers all REST routes under `us-scheduler/v1` |
| `Api\AvailabilityEndpoint` | REST handlers for availability CRUD |
| `Api\BookingEndpoint` | REST handlers for booking and status updates |
| `Frontend\ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes |
| `Frontend\BookingPage` | Renders student booking UI shell (JS takes over) |
| `Frontend\LoginPage` | Renders front-end student login form |
| `Schema` | CREATE TABLE SQL strings for dbDelta |
| `AdminMenu` | Registers wp-admin menu pages |
| `RestRegistrar` | Registers all REST routes under `us-scheduler/v1` |
| `ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes |
| `Auth\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps |
| `Auth\LoginPage` | Renders front-end student login form |
| `Availability\AvailabilitySlot` | Immutable value object for a slot row |
| `Availability\AvailabilityRepository` | CRUD for availability slots |
| `Availability\AvailabilityController` | Instructor availability management page |
| `Availability\AvailabilityEndpoint` | REST handlers for availability CRUD |
| `Booking\Lesson` | Immutable value object for a lesson row |
| `Booking\BookingRepository` | CRUD for lesson bookings |
| `Booking\BookingEndpoint` | REST handlers for booking and status updates |
| `Booking\BookingPage` | Renders student booking UI shell (JS takes over) |
| `Booking\LessonController` | Admin and instructor lesson list pages |
### REST API Namespace
All endpoints live under `/wp-json/us-scheduler/v1/`. Permissions are enforced via `permission_callback` using capability checks (`manage_availability`, `book_lesson`), never role name checks.
@@ -81,9 +96,9 @@ All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()
### Adding a Feature
1. Write the feature doc in `docs/features/<feature-name>.md` (data model, API, classes, test paths).
2. Implement the classes under `src/`.
2. Create a domain package under `src/<Domain>/` containing all classes for that feature.
3. Add template(s) under `templates/` if needed.
4. Write unit tests under `tests/Unit/` mirroring the `src/` directory structure.
4. Write unit tests under `tests/Unit/<Domain>/` mirroring the `src/<Domain>/` structure.
5. Run `composer test` — all tests must pass before the feature is complete.
### CI

View File

@@ -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": {

5
memory/MEMORY.md Normal file
View File

@@ -0,0 +1,5 @@
# 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
- [Feedback: Package-by-domain architecture](feedback_architecture.md) — all new code must be organised by domain, never by type

View File

@@ -0,0 +1,16 @@
---
name: Package-by-domain architecture
description: New features must be organised as domain packages, not by type (no Admin/, Api/, Data/ etc.)
type: feedback
---
Always organise code package-by-domain, not package-by-type.
**Why:** User explicitly requested the restructure on 2026-03-30. Package-by-type (Admin/, Api/, Data/, Frontend/, Model/, Roles/) was the initial scaffold but was replaced before any real features shipped.
**How to apply:**
- New domain → new directory under `src/<Domain>/` (e.g. `src/Availability/`, `src/Booking/`, `src/Auth/`)
- All classes for a domain (value objects, repositories, admin controllers, REST endpoints, shortcode pages) live inside that directory under the sub-namespace `Unsupervised\Schedular\<Domain>\`
- Cross-cutting wiring that glues domains together (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) lives at `src/` root with namespace `Unsupervised\Schedular\`
- Tests mirror the domain structure: `tests/Unit/<Domain>/`
- Never create `src/Admin/`, `src/Api/`, `src/Data/`, `src/Frontend/`, `src/Model/`, or `src/Roles/` directories

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,20 @@
---
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/`
- **Package-by-domain architecture** (restructured 2026-03-30): `src/Availability/`, `src/Booking/`, `src/Auth/` — each domain contains its value object, repository, controller, REST endpoint, and any shortcode pages. Cross-cutting wiring (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) lives at `src/` root. Tests mirror the domain structure under `tests/Unit/<Domain>/`.
**How to apply:** When adding features, create a domain package under `src/<Domain>/` with all related classes, mirror it in `tests/Unit/<Domain>/`, write a feature doc in `docs/features/`, then run `composer test`.

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
bootstrapFiles:
- tests/bootstrap.php
ignoreErrors:
- '#Unsafe usage of new static\(\)#'

View File

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

View File

@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Admin;
use Unsupervised\Schedular\Data\AvailabilityRepository;
use Unsupervised\Schedular\Model\AvailabilitySlot;
use Unsupervised\Schedular\Roles\RoleManager;
class AvailabilityController
{
public function __construct(private AvailabilityRepository $repository) {}
public function renderPage(): void
{
if (! current_user_can(RoleManager::CAP_MANAGE_AVAILABILITY)) {
wp_die(esc_html__('You do not have permission to manage availability.', 'unsupervised-schedular'));
}
$instructorId = get_current_user_id();
if (isset($_POST['usc_action']) && check_admin_referer('usc_availability_action')) {
$this->handleFormAction($instructorId);
}
$slots = $this->repository->findByInstructor($instructorId);
include USC_PLUGIN_DIR . 'templates/admin/availability.php';
}
private function handleFormAction(int $instructorId): void
{
$action = sanitize_key($_POST['usc_action'] ?? '');
if ($action === 'add') {
$startDt = sanitize_text_field($_POST['start_dt'] ?? '');
$endDt = sanitize_text_field($_POST['end_dt'] ?? '');
if ($startDt !== '' && $endDt !== '') {
$this->repository->insert(new AvailabilitySlot($instructorId, $startDt, $endDt));
}
}
if ($action === 'delete') {
$slotId = absint($_POST['slot_id'] ?? 0);
if ($slotId > 0) {
$slot = $this->repository->findById($slotId);
if ($slot && $slot->instructorId === $instructorId) {
$this->repository->delete($slotId);
}
}
}
}
}

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Admin;
use Unsupervised\Schedular\Data\BookingRepository;
use Unsupervised\Schedular\Roles\RoleManager;
class LessonController
{
public function __construct(private BookingRepository $repository) {}
public function renderAdminDashboard(): void
{
if (! current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to view this page.', 'unsupervised-schedular'));
}
$lessons = $this->repository->findAllUpcoming();
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
}
public function renderInstructorLessons(): void
{
if (! current_user_can(RoleManager::CAP_VIEW_LESSONS)) {
wp_die(esc_html__('You do not have permission to view lessons.', 'unsupervised-schedular'));
}
$lessons = $this->repository->findUpcomingForInstructor(get_current_user_id());
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
}
}

60
src/AdminMenu.php Normal file
View File

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

View File

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

View File

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

View File

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

49
src/Auth/LoginPage.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
class LoginPage {
/**
* Renders the student login shortcode output.
*
* @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(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'You are already logged in.', 'unsupervised-schedular' ),
$redirect,
esc_html__( 'View available lessons', 'unsupervised-schedular' )
);
}
$error = '';
$redirect = sanitize_url( (string) get_permalink() );
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'] ),
];
$user = wp_signon( $credentials, false );
if ( is_wp_error( $user ) ) {
$error = esc_html__( 'Invalid username or password.', 'unsupervised-schedular' );
} else {
wp_safe_redirect( $redirect );
exit;
}
}
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/login-page.php';
return (string) ob_get_clean();
}
}

44
src/Auth/RoleManager.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
class RoleManager {
public const INSTRUCTOR = 'us_instructor';
public const STUDENT = 'us_student';
public const CAP_MANAGE_AVAILABILITY = 'manage_availability';
public const CAP_VIEW_LESSONS = 'view_own_lessons';
public const CAP_BOOK_LESSON = 'book_lesson';
public function register(): void {
add_action( 'init', [ $this, 'createRoles' ] );
}
public function createRoles(): void {
if ( get_role( self::INSTRUCTOR ) === null ) {
add_role(
self::INSTRUCTOR,
__( 'Instructor', 'unsupervised-schedular' ),
[
'read' => true,
self::CAP_MANAGE_AVAILABILITY => true,
self::CAP_VIEW_LESSONS => true,
]
);
}
if ( get_role( self::STUDENT ) === null ) {
add_role(
self::STUDENT,
__( 'Student', 'unsupervised-schedular' ),
[
'read' => true,
self::CAP_BOOK_LESSON => true,
self::CAP_VIEW_LESSONS => true,
]
);
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Auth\RoleManager;
class AvailabilityController {
public function __construct( private AvailabilityRepository $repository ) {}
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
wp_die( esc_html__( 'You do not have permission to manage availability.', 'unsupervised-schedular' ) );
}
$instructorId = get_current_user_id();
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_availability_action' ) ) {
$this->handleFormAction( $instructorId );
}
$slots = $this->repository->findByInstructor( $instructorId );
include USC_PLUGIN_DIR . 'templates/admin/availability.php';
}
private function handleFormAction( int $instructorId ): void {
// 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 ( 'add' === $action ) {
$startDt = sanitize_text_field( wp_unslash( $_POST['start_dt'] ?? '' ) );
$endDt = sanitize_text_field( wp_unslash( $_POST['end_dt'] ?? '' ) );
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
}
}

View File

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

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
class AvailabilityRepository {
private string $table;
public function __construct( private \wpdb $db ) {
$this->table = $db->prefix . 'us_availability';
}
public function insert( AvailabilitySlot $slot ): int {
$this->db->insert(
$this->table,
[
'instructor_id' => $slot->instructorId,
'start_dt' => $slot->startDt,
'end_dt' => $slot->endDt,
'is_booked' => 0,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%s', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
}
/**
* Find unbooked slots, optionally filtered by instructor and date range.
*
* @return list<AvailabilitySlot>
*/
public function findAvailable( int $instructorId = 0, string $from = '', string $to = '' ): array {
$where = [ 'is_booked = 0' ];
$params = [];
if ( $instructorId > 0 ) {
$where[] = 'instructor_id = %d';
$params[] = $instructorId;
}
if ( '' !== $from ) {
$where[] = 'start_dt >= %s';
$params[] = $from;
}
if ( '' !== $to ) {
$where[] = 'end_dt <= %s';
$params[] = $to;
}
$whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC";
$rows = $params
? $this->db->get_results( $this->db->prepare( $sql, $params ) )
: $this->db->get_results( $sql );
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
/**
* Find all slots for an instructor (booked and unbooked).
*
* @return list<AvailabilitySlot>
*/
public function findByInstructor( int $instructorId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY start_dt ASC",
$instructorId
)
);
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
public function findById( int $id ): ?AvailabilitySlot {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
);
return $row ? AvailabilitySlot::fromRow( $row ) : null;
}
public function markBooked( int $id ): bool {
return (bool) $this->db->update(
$this->table,
[ 'is_booked' => 1 ],
[ 'id' => $id ],
[ '%d' ],
[ '%d' ]
);
}
/**
* Delete an unbooked slot. Returns false if the slot is already booked.
*/
public function delete( int $id ): bool {
return (bool) $this->db->delete(
$this->table,
[
'id' => $id,
'is_booked' => 0,
],
[ '%d', '%d' ]
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
class AvailabilitySlot {
public function __construct(
public readonly int $instructorId,
public readonly string $startDt,
public readonly string $endDt,
public readonly bool $isBooked = false,
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
return new self(
instructorId: (int) $row->instructor_id,
startDt: $row->start_dt,
endDt: $row->end_dt,
isBooked: (bool) $row->is_booked,
id: (int) $row->id,
);
}
/**
* Returns a plain array representation of the slot.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'instructor_id' => $this->instructorId,
'start_dt' => $this->startDt,
'end_dt' => $this->endDt,
'is_booked' => $this->isBooked,
];
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\RoleManager;
class BookingEndpoint {
public function __construct(
private AvailabilityRepository $availability,
private BookingRepository $bookings,
) {}
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',
],
],
],
]
);
register_rest_route(
$route_namespace,
'/bookings/(?P<id>\d+)/status',
[
[
'methods' => \WP_REST_Server::EDITABLE,
'callback' => [ $this, 'updateStatus' ],
'permission_callback' => [ $this, 'canManage' ],
'args' => [
'status' => [
'type' => 'string',
'required' => true,
'enum' => Lesson::VALID_STATUSES,
],
],
],
]
);
}
public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // 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 );
return new \WP_REST_Response( array_map( fn( Lesson $l ) => $l->toArray(), $lessons ), 200 );
}
public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$slotId = (int) $request->get_param( 'slot_id' );
$slot = $this->availability->findById( $slotId );
if ( null === $slot ) {
return new \WP_Error( 'not_found', __( 'Slot not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( $slot->isBooked ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$notes = (string) $request->get_param( 'notes' );
$lesson = new Lesson(
slotId: $slotId,
studentId: get_current_user_id(),
instructorId: $slot->instructorId,
notes: '' !== $notes ? $notes : null,
);
$id = $this->bookings->insert( $lesson );
$this->availability->markBooked( $slotId );
return new \WP_REST_Response(
[
'id' => $id,
'status' => Lesson::STATUS_PENDING,
],
201
);
}
public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$lesson = $this->bookings->findById( $id );
if ( null === $lesson ) {
return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
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 ] );
}
$this->bookings->updateStatus( $id, (string) $request->get_param( 'status' ) );
return new \WP_REST_Response(
[
'id' => $id,
'status' => $request->get_param( 'status' ),
],
200
);
}
public function isLoggedIn(): bool {
return is_user_logged_in();
}
public function canBook(): bool {
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
}
public function canManage(): bool {
return is_user_logged_in() && (
current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) || current_user_can( 'manage_options' )
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager;
class BookingPage {
/**
* Renders the booking shortcode output.
*
* @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(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url( wp_login_url( get_permalink() ) ),
esc_html__( 'log in to book a lesson', 'unsupervised-schedular' )
);
}
if ( ! current_user_can( RoleManager::CAP_BOOK_LESSON ) ) {
return '<p>' . esc_html__( 'This page is for students only.', 'unsupervised-schedular' ) . '</p>';
}
wp_enqueue_style( 'us-scheduler' );
wp_enqueue_script( 'us-scheduler' );
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
return (string) ob_get_clean();
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
class BookingRepository {
private string $table;
public function __construct( private \wpdb $db ) {
$this->table = $db->prefix . 'us_lessons';
}
public function insert( Lesson $lesson ): int {
$this->db->insert(
$this->table,
[
'slot_id' => $lesson->slotId,
'student_id' => $lesson->studentId,
'instructor_id' => $lesson->instructorId,
'status' => $lesson->status,
'notes' => $lesson->notes,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%d', '%s', '%s', '%s' ]
);
return $this->db->insert_id;
}
public function findById( int $id ): ?Lesson {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
);
return $row ? Lesson::fromRow( $row ) : null;
}
/**
* Upcoming lessons for an instructor (status != cancelled, slot in the future).
*
* @return list<Lesson>
*/
public function findUpcomingForInstructor( int $instructorId ): array {
$avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT l.* FROM {$this->table} l
JOIN {$avTable} a ON a.id = l.slot_id
WHERE l.instructor_id = %d
AND l.status != %s
AND a.start_dt >= %s
ORDER BY a.start_dt ASC",
$instructorId,
Lesson::STATUS_CANCELLED,
current_time( 'mysql' )
)
);
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
}
/**
* All lessons for a student.
*
* @return list<Lesson>
*/
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY created_at DESC",
$studentId
)
);
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
}
/**
* All upcoming lessons across all instructors (admin view).
*
* @return list<Lesson>
*/
public function findAllUpcoming(): array {
$avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT l.* FROM {$this->table} l
JOIN {$avTable} a ON a.id = l.slot_id
WHERE l.status != %s
AND a.start_dt >= %s
ORDER BY a.start_dt ASC",
Lesson::STATUS_CANCELLED,
current_time( 'mysql' )
)
);
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
}
public function updateStatus( int $id, string $status ): bool {
if ( ! in_array( $status, Lesson::VALID_STATUSES, true ) ) {
return false;
}
return (bool) $this->db->update(
$this->table,
[ 'status' => $status ],
[ 'id' => $id ],
[ '%s' ],
[ '%d' ]
);
}
}

54
src/Booking/Lesson.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
class Lesson {
public const STATUS_PENDING = 'pending';
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_CANCELLED = '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 readonly int $slotId,
public readonly int $studentId,
public readonly int $instructorId,
public readonly string $status = self::STATUS_PENDING,
public readonly ?string $notes = null,
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
return new self(
slotId: (int) $row->slot_id,
studentId: (int) $row->student_id,
instructorId: (int) $row->instructor_id,
status: $row->status,
notes: $row->notes,
id: (int) $row->id,
);
}
/**
* Returns a plain array representation of the lesson.
*
* @return array<string, mixed>
*/
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,
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager;
class LessonController {
public function __construct( private BookingRepository $repository ) {}
public function renderAdminDashboard(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to view this page.', 'unsupervised-schedular' ) );
}
$lessons = $this->repository->findAllUpcoming();
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
}
public function renderInstructorLessons(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
wp_die( esc_html__( 'You do not have permission to view lessons.', 'unsupervised-schedular' ) );
}
$lessons = $this->repository->findUpcomingForInstructor( get_current_user_id() );
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
}
}

View File

@@ -1,117 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Data;
use Unsupervised\Schedular\Model\AvailabilitySlot;
class AvailabilityRepository
{
private string $table;
public function __construct(private \wpdb $db)
{
$this->table = $db->prefix . 'us_availability';
}
public function insert(AvailabilitySlot $slot): int
{
$this->db->insert(
$this->table,
[
'instructor_id' => $slot->instructorId,
'start_dt' => $slot->startDt,
'end_dt' => $slot->endDt,
'is_booked' => 0,
'created_at' => current_time('mysql'),
],
['%d', '%s', '%s', '%d', '%s']
);
return $this->db->insert_id;
}
/**
* Find unbooked slots, optionally filtered by instructor and date range.
*
* @return list<AvailabilitySlot>
*/
public function findAvailable(int $instructorId = 0, string $from = '', string $to = ''): array
{
$where = ['is_booked = 0'];
$params = [];
if ($instructorId > 0) {
$where[] = 'instructor_id = %d';
$params[] = $instructorId;
}
if ($from !== '') {
$where[] = 'start_dt >= %s';
$params[] = $from;
}
if ($to !== '') {
$where[] = 'end_dt <= %s';
$params[] = $to;
}
$whereClause = implode(' AND ', $where);
$sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC";
$rows = $params
? $this->db->get_results($this->db->prepare($sql, $params))
: $this->db->get_results($sql);
return array_map(AvailabilitySlot::fromRow(...), $rows ?? []);
}
/**
* Find all slots for an instructor (booked and unbooked).
*
* @return list<AvailabilitySlot>
*/
public function findByInstructor(int $instructorId): array
{
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY start_dt ASC",
$instructorId
)
);
return array_map(AvailabilitySlot::fromRow(...), $rows ?? []);
}
public function findById(int $id): ?AvailabilitySlot
{
$row = $this->db->get_row(
$this->db->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id)
);
return $row ? AvailabilitySlot::fromRow($row) : null;
}
public function markBooked(int $id): bool
{
return (bool) $this->db->update(
$this->table,
['is_booked' => 1],
['id' => $id],
['%d'],
['%d']
);
}
/**
* Delete an unbooked slot. Returns false if the slot is already booked.
*/
public function delete(int $id): bool
{
return (bool) $this->db->delete(
$this->table,
['id' => $id, 'is_booked' => 0],
['%d', '%d']
);
}
}

View File

@@ -1,125 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Data;
use Unsupervised\Schedular\Model\Lesson;
class BookingRepository
{
private string $table;
public function __construct(private \wpdb $db)
{
$this->table = $db->prefix . 'us_lessons';
}
public function insert(Lesson $lesson): int
{
$this->db->insert(
$this->table,
[
'slot_id' => $lesson->slotId,
'student_id' => $lesson->studentId,
'instructor_id' => $lesson->instructorId,
'status' => $lesson->status,
'notes' => $lesson->notes,
'created_at' => current_time('mysql'),
],
['%d', '%d', '%d', '%s', '%s', '%s']
);
return $this->db->insert_id;
}
public function findById(int $id): ?Lesson
{
$row = $this->db->get_row(
$this->db->prepare("SELECT * FROM {$this->table} WHERE id = %d", $id)
);
return $row ? Lesson::fromRow($row) : null;
}
/**
* Upcoming lessons for an instructor (status != cancelled, slot in the future).
*
* @return list<Lesson>
*/
public function findUpcomingForInstructor(int $instructorId): array
{
$avTable = str_replace('us_lessons', 'us_availability', $this->table);
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT l.* FROM {$this->table} l
JOIN {$avTable} a ON a.id = l.slot_id
WHERE l.instructor_id = %d
AND l.status != %s
AND a.start_dt >= %s
ORDER BY a.start_dt ASC",
$instructorId,
Lesson::STATUS_CANCELLED,
current_time('mysql')
)
);
return array_map(Lesson::fromRow(...), $rows ?? []);
}
/**
* All lessons for a student.
*
* @return list<Lesson>
*/
public function findByStudent(int $studentId): array
{
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY created_at DESC",
$studentId
)
);
return array_map(Lesson::fromRow(...), $rows ?? []);
}
/**
* All upcoming lessons across all instructors (admin view).
*
* @return list<Lesson>
*/
public function findAllUpcoming(): array
{
$avTable = str_replace('us_lessons', 'us_availability', $this->table);
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT l.* FROM {$this->table} l
JOIN {$avTable} a ON a.id = l.slot_id
WHERE l.status != %s
AND a.start_dt >= %s
ORDER BY a.start_dt ASC",
Lesson::STATUS_CANCELLED,
current_time('mysql')
)
);
return array_map(Lesson::fromRow(...), $rows ?? []);
}
public function updateStatus(int $id, string $status): bool
{
if (! in_array($status, Lesson::VALID_STATUSES, true)) {
return false;
}
return (bool) $this->db->update(
$this->table,
['status' => $status],
['id' => $id],
['%s'],
['%d']
);
}
}

View File

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

View File

@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Frontend;
class LoginPage
{
public function render(array $atts): string
{
if (is_user_logged_in()) {
$redirect = esc_url(get_permalink());
return sprintf(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__('You are already logged in.', 'unsupervised-schedular'),
$redirect,
esc_html__('View available lessons', 'unsupervised-schedular')
);
}
$error = '';
$redirect = sanitize_url(get_permalink() ?? '');
if (isset($_POST['us_login']) && check_admin_referer('us_student_login')) {
$credentials = [
'user_login' => sanitize_user($_POST['log'] ?? ''),
'user_password' => $_POST['pwd'] ?? '',
'remember' => isset($_POST['rememberme']),
];
$user = wp_signon($credentials, false);
if (is_wp_error($user)) {
$error = esc_html__('Invalid username or password.', 'unsupervised-schedular');
} else {
wp_safe_redirect($redirect);
exit;
}
}
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/login-page.php';
return (string) ob_get_clean();
}
}

View File

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

View File

@@ -3,21 +3,18 @@ declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Data\Schema;
use Unsupervised\Schedular\Roles\RoleManager;
use Unsupervised\Schedular\Auth\RoleManager;
class Installer
{
public function run(): void
{
class Installer {
public function run(): void {
$this->createTables();
( new RoleManager() )->createRoles();
flush_rewrite_rules();
update_option( 'us_schedular_version', USC_VERSION );
}
private function createTables(): void
{
private function createTables(): void {
global $wpdb;
$charset = $wpdb->get_charset_collate();

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Model;
class AvailabilitySlot
{
public function __construct(
public readonly int $instructorId,
public readonly string $startDt,
public readonly string $endDt,
public readonly bool $isBooked = false,
public readonly ?int $id = null,
) {}
public static function fromRow(object $row): self
{
return new self(
instructorId: (int) $row->instructor_id,
startDt: $row->start_dt,
endDt: $row->end_dt,
isBooked: (bool) $row->is_booked,
id: (int) $row->id,
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'instructor_id' => $this->instructorId,
'start_dt' => $this->startDt,
'end_dt' => $this->endDt,
'is_booked' => $this->isBooked,
];
}
}

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Model;
class Lesson
{
public const STATUS_PENDING = 'pending';
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_CANCELLED = 'cancelled';
/** @var list<string> */
public const VALID_STATUSES = [self::STATUS_PENDING, self::STATUS_CONFIRMED, self::STATUS_CANCELLED];
public function __construct(
public readonly int $slotId,
public readonly int $studentId,
public readonly int $instructorId,
public readonly string $status = self::STATUS_PENDING,
public readonly ?string $notes = null,
public readonly ?int $id = null,
) {}
public static function fromRow(object $row): self
{
return new self(
slotId: (int) $row->slot_id,
studentId: (int) $row->student_id,
instructorId: (int) $row->instructor_id,
status: $row->status,
notes: $row->notes,
id: (int) $row->id,
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'slot_id' => $this->slotId,
'student_id' => $this->studentId,
'instructor_id' => $this->instructorId,
'status' => $this->status,
'notes' => $this->notes,
];
}
}

View File

@@ -3,17 +3,13 @@ declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Admin\AdminMenu;
use Unsupervised\Schedular\Api\RestRegistrar;
use Unsupervised\Schedular\Data\AvailabilityRepository;
use Unsupervised\Schedular\Data\BookingRepository;
use Unsupervised\Schedular\Frontend\ShortcodeRegistrar;
use Unsupervised\Schedular\Roles\RoleManager;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
class Plugin
{
public static function boot(): void
{
class Plugin {
public static function boot(): void {
load_plugin_textdomain( 'unsupervised-schedular', false, dirname( plugin_basename( USC_PLUGIN_FILE ) ) . '/languages' );
global $wpdb;
@@ -23,6 +19,6 @@ class Plugin
( new RoleManager() )->register();
( new AdminMenu( $availability, $bookings ) )->register();
( new RestRegistrar( $availability, $bookings ) )->register();
(new ShortcodeRegistrar($availability, $bookings))->register();
( new ShortcodeRegistrar() )->register();
}
}

31
src/RestRegistrar.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
class RestRegistrar {
public const NAMESPACE = 'us-scheduler/v1';
private AvailabilityEndpoint $availabilityEndpoint;
private BookingEndpoint $bookingEndpoint;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings );
}
public function register(): void {
add_action( 'rest_api_init', [ $this, 'registerRoutes' ] );
}
public function registerRoutes(): void {
$this->availabilityEndpoint->registerRoutes( self::NAMESPACE );
$this->bookingEndpoint->registerRoutes( self::NAMESPACE );
}
}

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Booking\BookingPage;
class ShortcodeRegistrar {
private BookingPage $bookingPage;
private LoginPage $loginPage;
public function __construct() {
$this->bookingPage = new BookingPage();
$this->loginPage = new LoginPage();
}
public function register(): void {
add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] );
add_shortcode( 'us_student_login', [ $this->loginPage, 'render' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
}
public function enqueueAssets(): void {
wp_register_style( 'us-scheduler', USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [], USC_VERSION, true );
wp_localize_script(
'us-scheduler',
'usScheduler',
[
'restUrl' => rest_url( 'us-scheduler/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
]
);
}
}

View File

@@ -1,10 +1,10 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Roles;
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Roles\RoleManager;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class RoleManagerTest extends TestCase

View File

@@ -1,12 +1,12 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Data;
namespace Unsupervised\Schedular\Tests\Unit\Availability;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Data\AvailabilityRepository;
use Unsupervised\Schedular\Model\AvailabilitySlot;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class AvailabilityRepositoryTest extends TestCase

View File

@@ -1,9 +1,9 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Model;
namespace Unsupervised\Schedular\Tests\Unit\Availability;
use Unsupervised\Schedular\Model\AvailabilitySlot;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class AvailabilitySlotTest extends TestCase

View File

@@ -1,12 +1,12 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Data;
namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Data\BookingRepository;
use Unsupervised\Schedular\Model\Lesson;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class BookingRepositoryTest extends TestCase

View File

@@ -1,9 +1,9 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Model;
namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Unsupervised\Schedular\Model\Lesson;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class LessonTest extends TestCase