Restructure src/ and tests/ from package-by-type to package-by-domain
CI / Coding Standards (push) Successful in 43s
CI / PHPStan (push) Successful in 52s
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.2) (push) Successful in 49s
CI / Tests (PHP 8.3) (push) Successful in 37s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 43s
CI / PHPStan (push) Successful in 52s
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.2) (push) Successful in 49s
CI / Tests (PHP 8.3) (push) Successful in 37s
CI / No Debug Code (push) Successful in 2s
All classes are now organised by domain (Availability, Booking, Auth). Each domain package contains its value object, repository, admin controller, REST endpoint, and any shortcode pages under a matching sub-namespace. Cross-cutting wiring (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) lives at src/ root. Tests mirror the domain structure. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -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' )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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' ]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user