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

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:
2026-03-30 16:37:30 -03:00
co-authored by Claude Sonnet 4.6
parent ed49924f95
commit 2fb2ca392d
26 changed files with 108 additions and 83 deletions
@@ -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
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Auth\RoleManager;
class AvailabilityEndpoint {
public function __construct( private AvailabilityRepository $repository ) {}
public function registerRoutes( string $route_namespace ): void {
register_rest_route(
$route_namespace,
'/availability',
[
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'index' ],
'permission_callback' => [ $this, 'canBook' ],
'args' => [
'instructor_id' => [
'type' => 'integer',
'default' => 0,
],
'from' => [
'type' => 'string',
'default' => '',
],
'to' => [
'type' => 'string',
'default' => '',
],
],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create' ],
'permission_callback' => [ $this, 'canManage' ],
'args' => [
'start_dt' => [
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
],
'end_dt' => [
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
],
],
],
]
);
register_rest_route(
$route_namespace,
'/availability/(?P<id>\d+)',
[
[
'methods' => \WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete' ],
'permission_callback' => [ $this, 'canManage' ],
],
]
);
}
public function index( \WP_REST_Request $request ): \WP_REST_Response {
$slots = $this->repository->findAvailable(
(int) $request->get_param( 'instructor_id' ),
(string) $request->get_param( 'from' ),
(string) $request->get_param( 'to' ),
);
return new \WP_REST_Response( array_map( fn( AvailabilitySlot $s ) => $s->toArray(), $slots ), 200 );
}
public function create( \WP_REST_Request $request ): \WP_REST_Response {
$slot = new AvailabilitySlot(
instructorId: get_current_user_id(),
startDt: (string) $request->get_param( 'start_dt' ),
endDt: (string) $request->get_param( 'end_dt' ),
);
$id = $this->repository->insert( $slot );
return new \WP_REST_Response( [ 'id' => $id ], 201 );
}
public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( $request->get_param( 'id' ) );
$slot = $this->repository->findById( $id );
if ( null === $slot ) {
return new \WP_Error( 'not_found', __( 'Slot not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( get_current_user_id() !== $slot->instructorId ) {
return new \WP_Error( 'forbidden', __( 'You cannot delete this slot.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( $slot->isBooked ) {
return new \WP_Error( 'slot_booked', __( 'Cannot delete a booked slot.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$this->repository->delete( $id );
return new \WP_REST_Response( null, 204 );
}
public function canBook(): bool {
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
}
public function canManage(): bool {
return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY );
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
class AvailabilityRepository {
private string $table;
public function __construct( private \wpdb $db ) {
$this->table = $db->prefix . 'us_availability';
}
public function insert( AvailabilitySlot $slot ): int {
$this->db->insert(
$this->table,
[
'instructor_id' => $slot->instructorId,
'start_dt' => $slot->startDt,
'end_dt' => $slot->endDt,
'is_booked' => 0,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%s', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
}
/**
* Find unbooked slots, optionally filtered by instructor and date range.
*
* @return list<AvailabilitySlot>
*/
public function findAvailable( int $instructorId = 0, string $from = '', string $to = '' ): array {
$where = [ 'is_booked = 0' ];
$params = [];
if ( $instructorId > 0 ) {
$where[] = 'instructor_id = %d';
$params[] = $instructorId;
}
if ( '' !== $from ) {
$where[] = 'start_dt >= %s';
$params[] = $from;
}
if ( '' !== $to ) {
$where[] = 'end_dt <= %s';
$params[] = $to;
}
$whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC";
$rows = $params
? $this->db->get_results( $this->db->prepare( $sql, $params ) )
: $this->db->get_results( $sql );
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
/**
* Find all slots for an instructor (booked and unbooked).
*
* @return list<AvailabilitySlot>
*/
public function findByInstructor( int $instructorId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY start_dt ASC",
$instructorId
)
);
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
public function findById( int $id ): ?AvailabilitySlot {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
);
return $row ? AvailabilitySlot::fromRow( $row ) : null;
}
public function markBooked( int $id ): bool {
return (bool) $this->db->update(
$this->table,
[ 'is_booked' => 1 ],
[ 'id' => $id ],
[ '%d' ],
[ '%d' ]
);
}
/**
* Delete an unbooked slot. Returns false if the slot is already booked.
*/
public function delete( int $id ): bool {
return (bool) $this->db->delete(
$this->table,
[
'id' => $id,
'is_booked' => 0,
],
[ '%d', '%d' ]
);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
class AvailabilitySlot {
public function __construct(
public readonly int $instructorId,
public readonly string $startDt,
public readonly string $endDt,
public readonly bool $isBooked = false,
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
return new self(
instructorId: (int) $row->instructor_id,
startDt: $row->start_dt,
endDt: $row->end_dt,
isBooked: (bool) $row->is_booked,
id: (int) $row->id,
);
}
/**
* Returns a plain array representation of the slot.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'instructor_id' => $this->instructorId,
'start_dt' => $this->startDt,
'end_dt' => $this->endDt,
'is_booked' => $this->isBooked,
];
}
}