Notify instructors on new bookings and enrolments
CI / Coding Standards (pull_request) Successful in 27s
CI / Tests (PHP 8.1) (pull_request) Successful in 37s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.3) (pull_request) Successful in 36s
CI / Tests (PHP 8.5) (pull_request) Successful in 40s
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Static Analysis (pull_request) Successful in 48s
CI / Build Plugin Zip (pull_request) Skipped

Add an opt-in, per-instructor email notice sent when someone books one of
their lessons or enrols in one of their group classes. Off by default and
set from My Availability → Notifications; covers both the student/guardian
REST flows and the studio's wp-admin "book/add for a student" forms.

The opt-in check lives in InstructorNotificationMailer so no booking path
can drift on who is mailed; lessons fire from LessonBooker::settle (the one
step both booking paths reach), enrolments from EnrollmentEndpoint::enroll
and GroupClassController::addDirect. The notice is a courtesy and never
fails a booking or enrolment that otherwise succeeded.

Co-authored-by: anthropic/claude-opus-4-8
This commit is contained in:
2026-09-18 16:18:43 -03:00
co-authored by anthropic/claude-opus-4-8
parent 56fc0bd57d
commit 12765d8f13
16 changed files with 583 additions and 3 deletions
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\InstructorNotificationMailer;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
@@ -26,6 +27,7 @@ class EnrollmentEndpointTest extends TestCase
private RegistrationGate $gate;
private PaymentService $payments;
private GroupAccessRepository $access;
private InstructorNotificationMailer&Mockery\MockInterface $instructorMailer;
private EnrollmentEndpoint $endpoint;
protected function setUp(): void
@@ -37,6 +39,9 @@ class EnrollmentEndpointTest extends TestCase
Functions\when('sanitize_text_field')->returnArg();
Functions\when('get_current_user_id')->justReturn(5);
Functions\when('current_time')->justReturn('2026-07-24');
// The enrolment notice resolves the student's name before handing off to the
// (mocked) mailer; a bare false is enough since the name falls back to the id.
Functions\when('get_userdata')->justReturn(false);
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
@@ -44,6 +49,11 @@ class EnrollmentEndpointTest extends TestCase
$this->payments = Mockery::mock(PaymentService::class);
$this->access = Mockery::mock(GroupAccessRepository::class);
// The opt-in instructor notice is exercised on its own; here it is a mock
// that ignores whatever it is handed, so enrolment tests stay about enrolment.
$this->instructorMailer = Mockery::mock(InstructorNotificationMailer::class);
$this->instructorMailer->shouldReceive('notifyEnrollment')->andReturn(true)->byDefault();
$this->guardians = Mockery::mock(GuardianService::class);
$this->guardians->shouldReceive('canActFor')
->andReturnUsing(static fn (int $actor, int $student): bool => $actor === $student)->byDefault();
@@ -57,6 +67,7 @@ class EnrollmentEndpointTest extends TestCase
$this->payments,
$this->access,
$this->guardians,
$this->instructorMailer,
);
}
@@ -93,6 +104,19 @@ class EnrollmentEndpointTest extends TestCase
self::assertNull($result->get_data()['payment']);
}
public function testASuccessfulEnrolmentNotifiesTheClassInstructor(): void
{
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(0.0));
$this->expectSuccessfulEnrollment();
$this->payments->shouldNotReceive('createForRegistration');
// The class is taught by instructor 3; the enrolling student falls back to
// their id for a name (get_userdata is stubbed false in setUp).
$this->instructorMailer->shouldReceive('notifyEnrollment')->once()->with(3, '5', 'Choir')->andReturn(true);
$this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
}
public function testEnrollInPricedClassReturnsPaymentSummary(): void
{
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0));
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\InstructorNotificationMailer;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RoleManager;
@@ -32,6 +33,7 @@ class GroupClassControllerTest extends TestCase
private RegistrationMailer&Mockery\MockInterface $mailer;
private IntakeAudit&Mockery\MockInterface $audit;
private IntakeRecording&Mockery\MockInterface $intake;
private InstructorNotificationMailer&Mockery\MockInterface $instructorMailer;
private GroupClassController $controller;
protected function setUp(): void
@@ -47,6 +49,10 @@ class GroupClassControllerTest extends TestCase
$this->mailer = Mockery::mock(RegistrationMailer::class);
$this->audit = Mockery::mock(IntakeAudit::class);
$this->intake = Mockery::mock(IntakeRecording::class);
// The opt-in instructor notice is tested on its own; a mock keeps these
// tests about enrolment, not about who gets emailed.
$this->instructorMailer = Mockery::mock(InstructorNotificationMailer::class);
$this->instructorMailer->shouldReceive('notifyEnrollment')->andReturn(true)->byDefault();
$this->controller = new GroupClassController(
$this->enrollments,
$this->offerings,
@@ -57,6 +63,7 @@ class GroupClassControllerTest extends TestCase
$this->mailer,
$this->audit,
$this->intake,
$this->instructorMailer,
);
Functions\when('current_user_can')->justReturn(true);