Add Offerings domain and studio-admin capabilities
CI / Coding Standards (pull_request) Successful in 55s
CI / PHPStan (pull_request) Successful in 1m0s
CI / Tests (PHP 8.1) (pull_request) Successful in 50s
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / Tests (PHP 8.3) (pull_request) Successful in 50s
CI / No Debug Code (pull_request) Successful in 2s

Implements the offerings catalog (#1): private-lesson types and group
classes carrying pricing, billing mode (one_time/full_term), duration,
capacity, and term details. Adds the src/Offering/ domain (value object,
repository, REST endpoint, admin controller + template), the us_offerings
table, and an Offerings admin page.

Also lands the capability slice of #9: registers the us_studio_admin role
and the new capability strings (manage_instructors, manage_offerings,
manage_questions, manage_policies, manage_billing, view_all_payments,
view_own_payments, export_payments) so offering management gates correctly.

Tests: tests/Unit/Offering/ (value object + repository) and a studio-admin
case in RoleManagerTest. composer test, cs, and PHPStan level 6 all pass.

Refs #1 #9

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 10:33:02 -03:00
parent d3a2767976
commit 36331388d1
13 changed files with 969 additions and 6 deletions
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\RoleManager;
class OfferingController {
public function __construct( private OfferingRepository $repository ) {}
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) ) {
wp_die( esc_html__( 'You do not have permission to manage offerings.', 'unsupervised-schedular' ) );
}
$instructorId = get_current_user_id();
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_offering_action' ) ) {
$this->handleFormAction( $instructorId, $manageAll );
}
$offerings = $manageAll
? $this->repository->findAll()
: $this->repository->findAll( $instructorId );
include USC_PLUGIN_DIR . 'templates/admin/offerings.php';
}
private function handleFormAction( int $instructorId, bool $manageAll ): 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 ) {
$this->addOffering( $instructorId );
}
if ( 'delete' === $action ) {
$offeringId = absint( $_POST['offering_id'] ?? 0 );
if ( $offeringId > 0 ) {
$offering = $this->repository->findById( $offeringId );
if ( $offering && ( $manageAll || $offering->instructorId === $instructorId ) ) {
$this->repository->delete( $offeringId );
}
}
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
private function addOffering( int $instructorId ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) );
$kind = sanitize_key( wp_unslash( $_POST['kind'] ?? '' ) );
if ( '' === $title || ! in_array( $kind, Offering::VALID_KINDS, true ) ) {
return;
}
$billingMode = sanitize_key( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) );
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
$billingMode = Offering::BILLING_ONE_TIME;
}
$duration = absint( $_POST['duration_minutes'] ?? 0 );
$capacity = absint( $_POST['capacity'] ?? 0 );
$this->repository->insert(
new Offering(
instructorId: $instructorId,
kind: $kind,
title: $title,
priceCents: absint( $_POST['price_cents'] ?? 0 ),
billingMode: $billingMode,
durationMinutes: $duration > 0 ? $duration : null,
allowWeekly: isset( $_POST['allow_weekly'] ),
capacity: $capacity > 0 ? $capacity : null,
scheduleNote: $this->nullableText( sanitize_text_field( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ),
)
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
private function nullableText( string $value ): ?string {
return '' === $value ? null : $value;
}
}