Merge pull request 'Add group-class enrolment (year commitment, capacity, registration gate)' (#21) from feature/group-classes into main
CI / Tests (PHP 8.1) (push) Successful in 42s
CI / Coding Standards (push) Successful in 51s
CI / PHPStan (push) Successful in 56s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 41s
CI / Tests (PHP 8.3) (push) Successful in 45s
CI / Build Plugin Zip (push) Successful in 40s
CI / Tests (PHP 8.1) (push) Successful in 42s
CI / Coding Standards (push) Successful in 51s
CI / PHPStan (push) Successful in 56s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 41s
CI / Tests (PHP 8.3) (push) Successful in 45s
CI / Build Plugin Zip (push) Successful in 40s
Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
/* global usScheduler */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const app = document.getElementById('us-group-app');
|
||||
if (!app) return;
|
||||
|
||||
const list = document.getElementById('us-group-list');
|
||||
const confirm = document.getElementById('us-group-confirmation');
|
||||
const errorBox = document.getElementById('us-group-error');
|
||||
const { restUrl, nonce } = usScheduler;
|
||||
|
||||
function apiFetch(path, options = {}) {
|
||||
return fetch(restUrl + path, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
}).then(async (res) => {
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || 'Request failed');
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
errorBox.textContent = message;
|
||||
errorBox.style.display = 'block';
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
errorBox.style.display = 'none';
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function questionField(q) {
|
||||
const name = `q_${q.id}`;
|
||||
const required = q.is_required ? 'required' : '';
|
||||
let input;
|
||||
if (q.field_type === 'textarea') {
|
||||
input = `<textarea name="${name}" ${required}></textarea>`;
|
||||
} else if (q.field_type === 'select') {
|
||||
const opts = (q.options || []).map((o) => `<option value="${escHtml(o)}">${escHtml(o)}</option>`).join('');
|
||||
input = `<select name="${name}" ${required}><option value="">—</option>${opts}</select>`;
|
||||
} else if (q.field_type === 'checkbox') {
|
||||
input = `<input type="checkbox" name="${name}" value="1">`;
|
||||
} else {
|
||||
input = `<input type="text" name="${name}" ${required}>`;
|
||||
}
|
||||
return `<p class="us-question"><label>${escHtml(q.label)}<br>${input}</label></p>`;
|
||||
}
|
||||
|
||||
function policyField(p) {
|
||||
return `
|
||||
<div class="us-policy">
|
||||
<h4>${escHtml(p.title)}</h4>
|
||||
<div class="us-policy-body">${p.body || ''}</div>
|
||||
<label><input type="checkbox" class="us-policy-accept" value="${p.policy_version_id}" required> I have read and agree to the ${escHtml(p.title)}.</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderClasses(offerings) {
|
||||
const groups = offerings.filter((o) => o.kind === 'group_class');
|
||||
if (!groups.length) {
|
||||
list.innerHTML = '<p>No group classes are open for enrolment right now.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = groups.map((o) => `
|
||||
<div class="us-class">
|
||||
<h3>${escHtml(o.title)}</h3>
|
||||
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
||||
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
||||
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
|
||||
<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
|
||||
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
|
||||
btn.addEventListener('click', () => openEnrolment(offering));
|
||||
});
|
||||
}
|
||||
|
||||
function openEnrolment(offering) {
|
||||
clearError();
|
||||
Promise.all([
|
||||
apiFetch(`offerings/${offering.id}/questions`),
|
||||
apiFetch('policies?scope=booking'),
|
||||
])
|
||||
.then(([questions, policies]) => renderEnrolment(offering, questions, policies))
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
function renderEnrolment(offering, questions, policies) {
|
||||
list.innerHTML = `
|
||||
<div class="us-register">
|
||||
<h3>${escHtml(offering.title)}</h3>
|
||||
<form id="us-enrol-form">
|
||||
${questions.map(questionField).join('')}
|
||||
${policies.map(policyField).join('')}
|
||||
<p>
|
||||
<button type="submit" class="us-enrol-btn">Confirm Enrolment</button>
|
||||
<button type="button" id="us-group-cancel" class="us-cancel-btn">Back</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('us-group-cancel').addEventListener('click', loadClasses);
|
||||
document.getElementById('us-enrol-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
submitEnrolment(e.target, offering, questions);
|
||||
});
|
||||
}
|
||||
|
||||
function submitEnrolment(form, offering, questions) {
|
||||
clearError();
|
||||
|
||||
const answers = {};
|
||||
questions.forEach((q) => {
|
||||
const field = form.elements[`q_${q.id}`];
|
||||
if (!field) return;
|
||||
answers[q.id] = field.type === 'checkbox' ? (field.checked ? '1' : '0') : field.value;
|
||||
});
|
||||
|
||||
const accepted = [...form.querySelectorAll('.us-policy-accept:checked')].map((c) => Number(c.value));
|
||||
|
||||
apiFetch('enrollments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
offering_id: offering.id,
|
||||
answers,
|
||||
accepted_policy_version_ids: accepted,
|
||||
}),
|
||||
})
|
||||
.then(() => {
|
||||
list.style.display = 'none';
|
||||
confirm.style.display = 'block';
|
||||
})
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
function loadClasses() {
|
||||
clearError();
|
||||
list.style.display = 'block';
|
||||
confirm.style.display = 'none';
|
||||
apiFetch('offerings?kind=group_class')
|
||||
.then(renderClasses)
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
loadClasses();
|
||||
}());
|
||||
@@ -43,11 +43,19 @@ instructor's group classes if the caller has `view_own_lessons` on those offerin
|
||||
- Instructors see enrolments for their own group classes under **My Lessons**
|
||||
|
||||
## Implementation
|
||||
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository`
|
||||
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
|
||||
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
|
||||
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController`
|
||||
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_lessons`)
|
||||
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
|
||||
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode)
|
||||
- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
|
||||
|
||||
> **Payment seam:** payment is deferred to #7. An enrolment is created with
|
||||
> `status = active` and `payment_id = null`; the pay→confirm + receipt step plugs
|
||||
> in later. Instructor-specific enrolment views (the spec's "under My Lessons")
|
||||
> are a follow-up — this iteration ships the studio-admin **Group Classes** page
|
||||
> (`view_all_lessons`) plus per-student/per-instructor REST queries.
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
|
||||
- `tests/Unit/GroupClass/EnrollmentTest.php`
|
||||
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
|
||||
|
||||
+16
-1
@@ -10,6 +10,8 @@ use Unsupervised\Schedular\Auth\RegistrationController;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassController;
|
||||
use Unsupervised\Schedular\Offering\OfferingController;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyController;
|
||||
@@ -27,14 +29,16 @@ class AdminMenu {
|
||||
private QuestionController $questionController;
|
||||
private PolicyController $policyController;
|
||||
private RegistrationController $registrationController;
|
||||
private GroupClassController $groupClassController;
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites ) {
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments ) {
|
||||
$this->availabilityController = new AvailabilityController( $availability, $offerings );
|
||||
$this->lessonController = new LessonController( $bookings );
|
||||
$this->offeringController = new OfferingController( $offerings );
|
||||
$this->questionController = new QuestionController( $questions, $offerings );
|
||||
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
|
||||
$this->registrationController = new RegistrationController( $invites );
|
||||
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
|
||||
}
|
||||
|
||||
public function register(): void {
|
||||
@@ -107,6 +111,17 @@ class AdminMenu {
|
||||
35
|
||||
);
|
||||
|
||||
// Studio admin: all group-class enrolments.
|
||||
add_menu_page(
|
||||
__( 'Group Classes', 'unsupervised-schedular' ),
|
||||
__( 'Group Classes', 'unsupervised-schedular' ),
|
||||
RoleManager::CAP_VIEW_ALL_LESSONS,
|
||||
'us-group-classes',
|
||||
[ $this->groupClassController, 'renderPage' ],
|
||||
'dashicons-groups',
|
||||
36
|
||||
);
|
||||
|
||||
// Instructor: view their upcoming lessons.
|
||||
add_menu_page(
|
||||
__( 'My Lessons', 'unsupervised-schedular' ),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
class Enrollment {
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
/**
|
||||
* All valid enrolment statuses.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_STATUSES = [ self::STATUS_ACTIVE, self::STATUS_CANCELLED, self::STATUS_COMPLETED ];
|
||||
|
||||
public function __construct(
|
||||
public readonly int $offeringId,
|
||||
public readonly int $studentId,
|
||||
public readonly int $instructorId,
|
||||
public readonly string $status = self::STATUS_ACTIVE,
|
||||
public readonly ?int $paymentId = null,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
public static function fromRow( object $row ): self {
|
||||
return new self(
|
||||
offeringId: (int) $row->offering_id,
|
||||
studentId: (int) $row->student_id,
|
||||
instructorId: (int) $row->instructor_id,
|
||||
status: $row->status,
|
||||
paymentId: null !== $row->payment_id ? (int) $row->payment_id : null,
|
||||
id: (int) $row->id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the enrolment.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'offering_id' => $this->offeringId,
|
||||
'student_id' => $this->studentId,
|
||||
'instructor_id' => $this->instructorId,
|
||||
'status' => $this->status,
|
||||
'payment_id' => $this->paymentId,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||
|
||||
class EnrollmentEndpoint {
|
||||
|
||||
public function __construct(
|
||||
private EnrollmentRepository $enrollments,
|
||||
private OfferingRepository $offerings,
|
||||
private RegistrationGate $gate,
|
||||
) {}
|
||||
|
||||
public function registerRoutes( string $route_namespace ): void {
|
||||
register_rest_route(
|
||||
$route_namespace,
|
||||
'/enrollments',
|
||||
[
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [ $this, 'index' ],
|
||||
'permission_callback' => [ $this, 'isLoggedIn' ],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [ $this, 'enroll' ],
|
||||
'permission_callback' => [ $this, 'canBook' ],
|
||||
'args' => [
|
||||
'offering_id' => [
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'absint',
|
||||
],
|
||||
'answers' => [
|
||||
'type' => 'object',
|
||||
'default' => [],
|
||||
],
|
||||
'accepted_policy_version_ids' => [
|
||||
'type' => 'array',
|
||||
'default' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function index( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||
$userId = get_current_user_id();
|
||||
|
||||
if ( current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
|
||||
$enrollments = $this->enrollments->findAllActive();
|
||||
} elseif ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
|
||||
$enrollments = $this->enrollments->findByInstructor( $userId );
|
||||
} else {
|
||||
$enrollments = $this->enrollments->findByStudent( $userId );
|
||||
}
|
||||
|
||||
return new \WP_REST_Response( array_map( fn( Enrollment $e ) => $e->toArray(), $enrollments ), 200 );
|
||||
}
|
||||
|
||||
public function enroll( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||
$offeringId = absint( $request->get_param( 'offering_id' ) );
|
||||
$offering = $this->offerings->findById( $offeringId );
|
||||
|
||||
if ( null === $offering || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
|
||||
return new \WP_Error( 'invalid_offering', __( 'Group class not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
||||
}
|
||||
|
||||
$studentId = get_current_user_id();
|
||||
|
||||
if ( $this->enrollments->hasActiveEnrollment( $offeringId, $studentId ) ) {
|
||||
return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
}
|
||||
|
||||
if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
|
||||
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||
}
|
||||
|
||||
$answers = $this->answers( $request );
|
||||
$acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) );
|
||||
|
||||
$gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds );
|
||||
if ( $gateError instanceof \WP_Error ) {
|
||||
return $gateError;
|
||||
}
|
||||
|
||||
$id = $this->enrollments->insert(
|
||||
new Enrollment(
|
||||
offeringId: $offeringId,
|
||||
studentId: $studentId,
|
||||
instructorId: $offering->instructorId,
|
||||
)
|
||||
);
|
||||
|
||||
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
|
||||
|
||||
return new \WP_REST_Response(
|
||||
[
|
||||
'id' => $id,
|
||||
'status' => Enrollment::STATUS_ACTIVE,
|
||||
],
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
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 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a question_id => value map from the request.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function answers( \WP_REST_Request $request ): array {
|
||||
$out = [];
|
||||
foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) {
|
||||
$out[ (int) $questionId ] = sanitize_text_field( (string) $value );
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function clientIp(): ?string {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
|
||||
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
|
||||
|
||||
return '' !== $ip ? $ip : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
class EnrollmentRepository {
|
||||
|
||||
private string $table;
|
||||
|
||||
public function __construct( private \wpdb $db ) {
|
||||
$this->table = $db->prefix . 'us_group_enrollments';
|
||||
}
|
||||
|
||||
public function insert( Enrollment $enrollment ): int {
|
||||
$this->db->insert(
|
||||
$this->table,
|
||||
[
|
||||
'offering_id' => $enrollment->offeringId,
|
||||
'student_id' => $enrollment->studentId,
|
||||
'instructor_id' => $enrollment->instructorId,
|
||||
'status' => $enrollment->status,
|
||||
'payment_id' => $enrollment->paymentId,
|
||||
'enrolled_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%d', '%s', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
}
|
||||
|
||||
public function findById( int $id ): ?Enrollment {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
|
||||
);
|
||||
|
||||
return $row ? Enrollment::fromRow( $row ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count active enrolments for an offering (capacity check).
|
||||
*/
|
||||
public function countActiveForOffering( int $offeringId ): int {
|
||||
return (int) $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
"SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND status = %s",
|
||||
$offeringId,
|
||||
Enrollment::STATUS_ACTIVE
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a student already holds an active enrolment in an offering.
|
||||
*/
|
||||
public function hasActiveEnrollment( int $offeringId, int $studentId ): bool {
|
||||
$count = (int) $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
"SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND student_id = %d AND status = %s",
|
||||
$offeringId,
|
||||
$studentId,
|
||||
Enrollment::STATUS_ACTIVE
|
||||
)
|
||||
);
|
||||
|
||||
return $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* A student's enrolments, newest first.
|
||||
*
|
||||
* @return list<Enrollment>
|
||||
*/
|
||||
public function findByStudent( int $studentId ): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY enrolled_at DESC",
|
||||
$studentId
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrolments in an instructor's group classes, newest first.
|
||||
*
|
||||
* @return list<Enrollment>
|
||||
*/
|
||||
public function findByInstructor( int $instructorId ): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY enrolled_at DESC",
|
||||
$instructorId
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* All active enrolments across instructors (studio-admin view).
|
||||
*
|
||||
* @return list<Enrollment>
|
||||
*/
|
||||
public function findAllActive(): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
"SELECT * FROM {$this->table} WHERE status = %s ORDER BY enrolled_at DESC",
|
||||
Enrollment::STATUS_ACTIVE
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
public function updateStatus( int $id, string $status ): bool {
|
||||
if ( ! in_array( $status, Enrollment::VALID_STATUSES, true ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $this->db->update(
|
||||
$this->table,
|
||||
[ 'status' => $status ],
|
||||
[ 'id' => $id ],
|
||||
[ '%s' ],
|
||||
[ '%d' ]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
|
||||
class GroupClassController {
|
||||
|
||||
public function __construct(
|
||||
private EnrollmentRepository $enrollments,
|
||||
private OfferingRepository $offerings,
|
||||
) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$rows = array_map(
|
||||
function ( Enrollment $enrollment ): array {
|
||||
$offering = $this->offerings->findById( $enrollment->offeringId );
|
||||
$student = get_userdata( $enrollment->studentId );
|
||||
|
||||
return [
|
||||
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
|
||||
'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
|
||||
'status' => $enrollment->status,
|
||||
];
|
||||
},
|
||||
$this->enrollments->findAllActive()
|
||||
);
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/group-classes.php';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
|
||||
class GroupClassPage {
|
||||
|
||||
/**
|
||||
* Renders the group-class enrolment 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 enrol in a class', '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-group' );
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php';
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
@@ -32,11 +33,12 @@ class Plugin {
|
||||
$policyService = new PolicyService( $policies, $policyVersions );
|
||||
$acceptances = new AcceptanceRepository( $wpdb );
|
||||
$invites = new InviteRepository( $wpdb );
|
||||
$enrollments = new EnrollmentRepository( $wpdb );
|
||||
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
|
||||
|
||||
( new RoleManager() )->register();
|
||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites ) )->register();
|
||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate ) )->register();
|
||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments ) )->register();
|
||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments ) )->register();
|
||||
( new ShortcodeRegistrar( $invites, $policies, $policyVersions, $acceptances ) )->register();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\OfferingEndpoint;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyEndpoint;
|
||||
@@ -26,13 +28,15 @@ class RestRegistrar {
|
||||
private OfferingEndpoint $offeringEndpoint;
|
||||
private QuestionEndpoint $questionEndpoint;
|
||||
private PolicyEndpoint $policyEndpoint;
|
||||
private EnrollmentEndpoint $enrollmentEndpoint;
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate ) {
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments ) {
|
||||
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability );
|
||||
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate );
|
||||
$this->offeringEndpoint = new OfferingEndpoint( $offerings );
|
||||
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
|
||||
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
|
||||
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate );
|
||||
}
|
||||
|
||||
public function register(): void {
|
||||
@@ -45,5 +49,6 @@ class RestRegistrar {
|
||||
$this->offeringEndpoint->registerRoutes( self::NAMESPACE );
|
||||
$this->questionEndpoint->registerRoutes( self::NAMESPACE );
|
||||
$this->policyEndpoint->registerRoutes( self::NAMESPACE );
|
||||
$this->enrollmentEndpoint->registerRoutes( self::NAMESPACE );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,21 @@ class Schema {
|
||||
KEY registration (registration_type, registration_id)
|
||||
) {$charset};",
|
||||
|
||||
"CREATE TABLE {$prefix}us_group_enrollments (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
offering_id BIGINT UNSIGNED NOT NULL,
|
||||
student_id BIGINT UNSIGNED NOT NULL,
|
||||
instructor_id BIGINT UNSIGNED NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
payment_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
enrolled_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY offering_id (offering_id),
|
||||
KEY student_id (student_id),
|
||||
KEY instructor_id (instructor_id),
|
||||
KEY status (status)
|
||||
) {$charset};",
|
||||
|
||||
"CREATE TABLE {$prefix}us_invites (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
email VARCHAR(191) NOT NULL,
|
||||
|
||||
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\LoginPage;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\Booking\BookingPage;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
@@ -16,6 +17,7 @@ class ShortcodeRegistrar {
|
||||
private BookingPage $bookingPage;
|
||||
private LoginPage $loginPage;
|
||||
private RegistrationPage $registrationPage;
|
||||
private GroupClassPage $groupClassPage;
|
||||
|
||||
public function __construct(
|
||||
InviteRepository $invites,
|
||||
@@ -26,27 +28,30 @@ class ShortcodeRegistrar {
|
||||
$this->bookingPage = new BookingPage();
|
||||
$this->loginPage = new LoginPage();
|
||||
$this->registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances );
|
||||
$this->groupClassPage = new GroupClassPage();
|
||||
}
|
||||
|
||||
public function register(): void {
|
||||
add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] );
|
||||
add_shortcode( 'us_student_login', [ $this->loginPage, 'render' ] );
|
||||
add_shortcode( 'us_student_register', [ $this->registrationPage, 'render' ] );
|
||||
add_shortcode( 'us_group_classes', [ $this->groupClassPage, 'render' ] );
|
||||
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
|
||||
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' ),
|
||||
]
|
||||
);
|
||||
$data = [
|
||||
'restUrl' => rest_url( 'us-scheduler/v1/' ),
|
||||
'nonce' => wp_create_nonce( 'wp_rest' ),
|
||||
];
|
||||
|
||||
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [], USC_VERSION, true );
|
||||
wp_localize_script( 'us-scheduler', 'usScheduler', $data );
|
||||
|
||||
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [], USC_VERSION, true );
|
||||
wp_localize_script( 'us-scheduler-group', 'usScheduler', $data );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/** @var list<array{student: string, offering: string, status: string}> $rows */
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Group Classes', 'unsupervised-schedular'); ?></h1>
|
||||
<p class="description"><?php esc_html_e('Active enrolments across all group classes.', 'unsupervised-schedular'); ?></p>
|
||||
|
||||
<?php if (empty($rows)) : ?>
|
||||
<p><?php esc_html_e('No active enrolments.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $row) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
<td><?php echo esc_html($row['offering']); ?></td>
|
||||
<td><?php echo esc_html($row['status']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<div id="us-group-app">
|
||||
<div id="us-group-list">
|
||||
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
|
||||
</div>
|
||||
<div id="us-group-confirmation" style="display:none;">
|
||||
<p><?php esc_html_e('You are enrolled. The studio will be in touch.', 'unsupervised-schedular'); ?></p>
|
||||
</div>
|
||||
<div id="us-group-error" style="display:none;" role="alert"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class EnrollmentRepositoryTest extends TestCase
|
||||
{
|
||||
private \wpdb $db;
|
||||
private EnrollmentRepository $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->db = Mockery::mock(\wpdb::class);
|
||||
$this->db->prefix = 'wp_';
|
||||
$this->repo = new EnrollmentRepository($this->db);
|
||||
}
|
||||
|
||||
public function testInsertReturnsId(): void
|
||||
{
|
||||
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00');
|
||||
|
||||
$this->db->shouldReceive('insert')
|
||||
->once()
|
||||
->with(
|
||||
'wp_us_group_enrollments',
|
||||
Mockery::on(static function (array $d): bool {
|
||||
return $d['offering_id'] === 7
|
||||
&& $d['student_id'] === 5
|
||||
&& $d['instructor_id'] === 3
|
||||
&& $d['status'] === Enrollment::STATUS_ACTIVE;
|
||||
}),
|
||||
['%d', '%d', '%d', '%s', '%d', '%s']
|
||||
);
|
||||
$this->db->insert_id = 12;
|
||||
|
||||
self::assertSame(12, $this->repo->insert(new Enrollment(7, 5, 3)));
|
||||
}
|
||||
|
||||
public function testCountActiveForOffering(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(Mockery::pattern('/COUNT\(\*\).*offering_id = %d AND status = %s/s'), 7, Enrollment::STATUS_ACTIVE)
|
||||
->andReturn('SELECT ...');
|
||||
|
||||
$this->db->shouldReceive('get_var')->andReturn('4');
|
||||
|
||||
self::assertSame(4, $this->repo->countActiveForOffering(7));
|
||||
}
|
||||
|
||||
public function testHasActiveEnrollmentTrueWhenCountPositive(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(Mockery::pattern('/offering_id = %d AND student_id = %d AND status = %s/'), 7, 5, Enrollment::STATUS_ACTIVE)
|
||||
->andReturn('SELECT ...');
|
||||
|
||||
$this->db->shouldReceive('get_var')->andReturn('1');
|
||||
|
||||
self::assertTrue($this->repo->hasActiveEnrollment(7, 5));
|
||||
}
|
||||
|
||||
public function testHasActiveEnrollmentFalseWhenZero(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
|
||||
$this->db->shouldReceive('get_var')->andReturn('0');
|
||||
|
||||
self::assertFalse($this->repo->hasActiveEnrollment(7, 5));
|
||||
}
|
||||
|
||||
public function testFindAllActiveMapsRows(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
|
||||
$this->db->shouldReceive('get_results')->andReturn([
|
||||
(object) [
|
||||
'id' => '12',
|
||||
'offering_id' => '7',
|
||||
'student_id' => '5',
|
||||
'instructor_id' => '3',
|
||||
'status' => Enrollment::STATUS_ACTIVE,
|
||||
'payment_id' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$all = $this->repo->findAllActive();
|
||||
|
||||
self::assertCount(1, $all);
|
||||
self::assertInstanceOf(Enrollment::class, $all[0]);
|
||||
}
|
||||
|
||||
public function testUpdateStatusRejectsInvalid(): void
|
||||
{
|
||||
self::assertFalse($this->repo->updateStatus(1, 'bogus'));
|
||||
}
|
||||
|
||||
public function testUpdateStatusCallsWpdb(): void
|
||||
{
|
||||
$this->db->shouldReceive('update')
|
||||
->once()
|
||||
->with('wp_us_group_enrollments', ['status' => Enrollment::STATUS_CANCELLED], ['id' => 1], ['%s'], ['%d'])
|
||||
->andReturn(1);
|
||||
|
||||
self::assertTrue($this->repo->updateStatus(1, Enrollment::STATUS_CANCELLED));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class EnrollmentTest extends TestCase
|
||||
{
|
||||
public function testDefaults(): void
|
||||
{
|
||||
$enrollment = new Enrollment(offeringId: 7, studentId: 5, instructorId: 3);
|
||||
|
||||
self::assertSame(Enrollment::STATUS_ACTIVE, $enrollment->status);
|
||||
self::assertNull($enrollment->paymentId);
|
||||
self::assertNull($enrollment->id);
|
||||
}
|
||||
|
||||
public function testStatusConstants(): void
|
||||
{
|
||||
self::assertContains(Enrollment::STATUS_ACTIVE, Enrollment::VALID_STATUSES);
|
||||
self::assertContains(Enrollment::STATUS_CANCELLED, Enrollment::VALID_STATUSES);
|
||||
self::assertContains(Enrollment::STATUS_COMPLETED, Enrollment::VALID_STATUSES);
|
||||
}
|
||||
|
||||
public function testFromRowMapsCorrectly(): void
|
||||
{
|
||||
$enrollment = Enrollment::fromRow((object) [
|
||||
'id' => '12',
|
||||
'offering_id' => '7',
|
||||
'student_id' => '5',
|
||||
'instructor_id' => '3',
|
||||
'status' => Enrollment::STATUS_ACTIVE,
|
||||
'payment_id' => null,
|
||||
]);
|
||||
|
||||
self::assertSame(12, $enrollment->id);
|
||||
self::assertSame(7, $enrollment->offeringId);
|
||||
self::assertSame(5, $enrollment->studentId);
|
||||
self::assertNull($enrollment->paymentId);
|
||||
}
|
||||
|
||||
public function testToArrayContainsExpectedKeys(): void
|
||||
{
|
||||
$arr = (new Enrollment(7, 5, 3, id: 12))->toArray();
|
||||
|
||||
foreach (['id', 'offering_id', 'student_id', 'instructor_id', 'status', 'payment_id'] as $key) {
|
||||
self::assertArrayHasKey($key, $arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user