Compare commits
3 Commits
0fbafc9d18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
2fb2ca392d
|
|||
|
ed49924f95
|
|||
|
e24c3ce850
|
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(composer test:*)",
|
||||||
|
"Bash(tea actions:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
61
CLAUDE.md
61
CLAUDE.md
@@ -13,7 +13,7 @@ composer cs # PHPCS coding standards check
|
|||||||
composer cs:fix # Auto-fix coding standards
|
composer cs:fix # Auto-fix coding standards
|
||||||
|
|
||||||
# Run a single test file
|
# 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
|
# Run a single test by name
|
||||||
./vendor/bin/phpunit --filter testInsertCallsWpdbInsertAndReturnsId
|
./vendor/bin/phpunit --filter testInsertCallsWpdbInsertAndReturnsId
|
||||||
@@ -28,19 +28,33 @@ composer cs:fix # Auto-fix coding standards
|
|||||||
|
|
||||||
### Directory Structure
|
### Directory Structure
|
||||||
```
|
```
|
||||||
src/ — All plugin PHP (PSR-4 namespace: Unsupervised\Schedular\)
|
src/ — All plugin PHP (PSR-4 namespace: Unsupervised\Schedular\)
|
||||||
templates/ — PHP view files included by controllers/shortcodes
|
Availability/ — Availability slots: value object, repository, controller, REST endpoint
|
||||||
assets/ — CSS and JS (vanilla JS, no build step)
|
Booking/ — Lessons/bookings: value object, repository, controller, REST endpoint, shortcode page
|
||||||
tests/Unit/ — PHPUnit unit tests (PSR-4: Unsupervised\Schedular\Tests\)
|
Auth/ — Roles, capabilities, login page
|
||||||
docs/features/— One markdown file per feature describing data model, API, and test locations
|
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
|
### Data Storage
|
||||||
Two custom database tables (created via `dbDelta` on activation):
|
Two custom database tables (created via `dbDelta` on activation):
|
||||||
- `{prefix}us_availability` — instructor availability windows
|
- `{prefix}us_availability` — instructor availability windows
|
||||||
- `{prefix}us_lessons` — booked lessons
|
- `{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
|
### 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` |
|
| `Plugin` | Wires all components together on `plugins_loaded` |
|
||||||
| `Installer` | Creates DB tables and roles on activation |
|
| `Installer` | Creates DB tables and roles on activation |
|
||||||
| `Roles\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps |
|
| `Schema` | CREATE TABLE SQL strings for dbDelta |
|
||||||
| `Data\AvailabilityRepository` | CRUD for availability slots |
|
| `AdminMenu` | Registers wp-admin menu pages |
|
||||||
| `Data\BookingRepository` | CRUD for lesson bookings |
|
| `RestRegistrar` | Registers all REST routes under `us-scheduler/v1` |
|
||||||
| `Model\AvailabilitySlot` | Immutable value object for a slot row |
|
| `ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes |
|
||||||
| `Model\Lesson` | Immutable value object for a lesson row |
|
| `Auth\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps |
|
||||||
| `Admin\AdminMenu` | Registers wp-admin menu pages |
|
| `Auth\LoginPage` | Renders front-end student login form |
|
||||||
| `Admin\AvailabilityController` | Instructor availability management page |
|
| `Availability\AvailabilitySlot` | Immutable value object for a slot row |
|
||||||
| `Admin\LessonController` | Admin and instructor lesson list pages |
|
| `Availability\AvailabilityRepository` | CRUD for availability slots |
|
||||||
| `Api\RestRegistrar` | Registers all REST routes under `us-scheduler/v1` |
|
| `Availability\AvailabilityController` | Instructor availability management page |
|
||||||
| `Api\AvailabilityEndpoint` | REST handlers for availability CRUD |
|
| `Availability\AvailabilityEndpoint` | REST handlers for availability CRUD |
|
||||||
| `Api\BookingEndpoint` | REST handlers for booking and status updates |
|
| `Booking\Lesson` | Immutable value object for a lesson row |
|
||||||
| `Frontend\ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes |
|
| `Booking\BookingRepository` | CRUD for lesson bookings |
|
||||||
| `Frontend\BookingPage` | Renders student booking UI shell (JS takes over) |
|
| `Booking\BookingEndpoint` | REST handlers for booking and status updates |
|
||||||
| `Frontend\LoginPage` | Renders front-end student login form |
|
| `Booking\BookingPage` | Renders student booking UI shell (JS takes over) |
|
||||||
|
| `Booking\LessonController` | Admin and instructor lesson list pages |
|
||||||
|
|
||||||
### REST API Namespace
|
### 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.
|
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
|
### Adding a Feature
|
||||||
1. Write the feature doc in `docs/features/<feature-name>.md` (data model, API, classes, test paths).
|
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.
|
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.
|
5. Run `composer test` — all tests must pass before the feature is complete.
|
||||||
|
|
||||||
### CI
|
### CI
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
5
memory/MEMORY.md
Normal file
5
memory/MEMORY.md
Normal 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
|
||||||
16
memory/feedback_architecture.md
Normal file
16
memory/feedback_architecture.md
Normal 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
|
||||||
35
memory/feedback_brainmonkey.md
Normal file
35
memory/feedback_brainmonkey.md
Normal 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.
|
||||||
20
memory/project_schedular.md
Normal file
20
memory/project_schedular.md
Normal 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
50
phpcs.xml.dist
Normal 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>
|
||||||
@@ -7,5 +7,3 @@ parameters:
|
|||||||
- src
|
- src
|
||||||
bootstrapFiles:
|
bootstrapFiles:
|
||||||
- tests/bootstrap.php
|
- tests/bootstrap.php
|
||||||
ignoreErrors:
|
|
||||||
- '#Unsafe usage of new static\(\)#'
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
60
src/AdminMenu.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
49
src/Auth/LoginPage.php
Normal 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
44
src/Auth/RoleManager.php
Normal 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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/Availability/AvailabilityController.php
Normal file
53
src/Availability/AvailabilityController.php
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/Availability/AvailabilityEndpoint.php
Normal file
119
src/Availability/AvailabilityEndpoint.php
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/Availability/AvailabilityRepository.php
Normal file
111
src/Availability/AvailabilityRepository.php
Normal 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' ]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/Availability/AvailabilitySlot.php
Normal file
40
src/Availability/AvailabilitySlot.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/Booking/BookingEndpoint.php
Normal file
143
src/Booking/BookingEndpoint.php
Normal 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' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/Booking/BookingPage.php
Normal file
36
src/Booking/BookingPage.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/Booking/BookingRepository.php
Normal file
116
src/Booking/BookingRepository.php
Normal 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
54
src/Booking/Lesson.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Booking/LessonController.php
Normal file
31
src/Booking/LessonController.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,28 +3,25 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Unsupervised\Schedular;
|
namespace Unsupervised\Schedular;
|
||||||
|
|
||||||
use Unsupervised\Schedular\Data\Schema;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\Roles\RoleManager;
|
|
||||||
|
|
||||||
class Installer
|
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
|
public function run(): void {
|
||||||
{
|
$this->createTables();
|
||||||
global $wpdb;
|
( new RoleManager() )->createRoles();
|
||||||
$charset = $wpdb->get_charset_collate();
|
flush_rewrite_rules();
|
||||||
|
update_option( 'us_schedular_version', USC_VERSION );
|
||||||
|
}
|
||||||
|
|
||||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
private function createTables(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$charset = $wpdb->get_charset_collate();
|
||||||
|
|
||||||
foreach (Schema::tables($wpdb->prefix, $charset) as $sql) {
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
dbDelta($sql);
|
|
||||||
}
|
foreach ( Schema::tables( $wpdb->prefix, $charset ) as $sql ) {
|
||||||
}
|
dbDelta( $sql );
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,26 +3,22 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Unsupervised\Schedular;
|
namespace Unsupervised\Schedular;
|
||||||
|
|
||||||
use Unsupervised\Schedular\Admin\AdminMenu;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\Api\RestRegistrar;
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
use Unsupervised\Schedular\Data\AvailabilityRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
use Unsupervised\Schedular\Data\BookingRepository;
|
|
||||||
use Unsupervised\Schedular\Frontend\ShortcodeRegistrar;
|
|
||||||
use Unsupervised\Schedular\Roles\RoleManager;
|
|
||||||
|
|
||||||
class Plugin
|
class Plugin {
|
||||||
{
|
|
||||||
public static function boot(): void
|
|
||||||
{
|
|
||||||
load_plugin_textdomain('unsupervised-schedular', false, dirname(plugin_basename(USC_PLUGIN_FILE)) . '/languages');
|
|
||||||
|
|
||||||
global $wpdb;
|
public static function boot(): void {
|
||||||
$availability = new AvailabilityRepository($wpdb);
|
load_plugin_textdomain( 'unsupervised-schedular', false, dirname( plugin_basename( USC_PLUGIN_FILE ) ) . '/languages' );
|
||||||
$bookings = new BookingRepository($wpdb);
|
|
||||||
|
|
||||||
(new RoleManager())->register();
|
global $wpdb;
|
||||||
(new AdminMenu($availability, $bookings))->register();
|
$availability = new AvailabilityRepository( $wpdb );
|
||||||
(new RestRegistrar($availability, $bookings))->register();
|
$bookings = new BookingRepository( $wpdb );
|
||||||
(new ShortcodeRegistrar($availability, $bookings))->register();
|
|
||||||
}
|
( new RoleManager() )->register();
|
||||||
|
( new AdminMenu( $availability, $bookings ) )->register();
|
||||||
|
( new RestRegistrar( $availability, $bookings ) )->register();
|
||||||
|
( new ShortcodeRegistrar() )->register();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/RestRegistrar.php
Normal file
31
src/RestRegistrar.php
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Unsupervised\Schedular\Data;
|
namespace Unsupervised\Schedular;
|
||||||
|
|
||||||
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,
|
||||||
instructor_id BIGINT UNSIGNED NOT NULL,
|
instructor_id BIGINT UNSIGNED NOT NULL,
|
||||||
start_dt DATETIME NOT NULL,
|
start_dt DATETIME NOT NULL,
|
||||||
@@ -25,7 +24,7 @@ class Schema
|
|||||||
KEY start_dt (start_dt)
|
KEY start_dt (start_dt)
|
||||||
) {$charset};",
|
) {$charset};",
|
||||||
|
|
||||||
"CREATE TABLE {$prefix}us_lessons (
|
"CREATE TABLE {$prefix}us_lessons (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
slot_id BIGINT UNSIGNED NOT NULL,
|
slot_id BIGINT UNSIGNED NOT NULL,
|
||||||
student_id BIGINT UNSIGNED NOT NULL,
|
student_id BIGINT UNSIGNED NOT NULL,
|
||||||
@@ -38,6 +37,6 @@ class Schema
|
|||||||
KEY student_id (student_id),
|
KEY student_id (student_id),
|
||||||
KEY instructor_id (instructor_id)
|
KEY instructor_id (instructor_id)
|
||||||
) {$charset};",
|
) {$charset};",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
38
src/ShortcodeRegistrar.php
Normal file
38
src/ShortcodeRegistrar.php
Normal 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' ),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Unsupervised\Schedular\Tests\Unit\Roles;
|
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use Unsupervised\Schedular\Roles\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
class RoleManagerTest extends TestCase
|
class RoleManagerTest extends TestCase
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Unsupervised\Schedular\Tests\Unit\Data;
|
namespace Unsupervised\Schedular\Tests\Unit\Availability;
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
use Unsupervised\Schedular\Data\AvailabilityRepository;
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
use Unsupervised\Schedular\Model\AvailabilitySlot;
|
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
class AvailabilityRepositoryTest extends TestCase
|
class AvailabilityRepositoryTest extends TestCase
|
||||||
@@ -18,9 +18,9 @@ class AvailabilityRepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
$this->db = Mockery::mock(\wpdb::class);
|
$this->db = Mockery::mock(\wpdb::class);
|
||||||
$this->db->prefix = 'wp_';
|
$this->db->prefix = 'wp_';
|
||||||
$this->repo = new AvailabilityRepository($this->db);
|
$this->repo = new AvailabilityRepository($this->db);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testInsertCallsWpdbInsertAndReturnsId(): void
|
public function testInsertCallsWpdbInsertAndReturnsId(): void
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
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;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
class AvailabilitySlotTest extends TestCase
|
class AvailabilitySlotTest extends TestCase
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Unsupervised\Schedular\Tests\Unit\Data;
|
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
use Unsupervised\Schedular\Data\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
use Unsupervised\Schedular\Model\Lesson;
|
use Unsupervised\Schedular\Booking\Lesson;
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
class BookingRepositoryTest extends TestCase
|
class BookingRepositoryTest extends TestCase
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
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;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
class LessonTest extends TestCase
|
class LessonTest extends TestCase
|
||||||
Reference in New Issue
Block a user