From c754e18a82030657a2a619103ba53694f2be6d8c Mon Sep 17 00:00:00 2001 From: Martin Slachta Date: Wed, 17 Jun 2026 11:15:09 +0200 Subject: [PATCH] #18 - membership --- .../js/datasource/RsvMembershipKeyResource.js | 4 + .../RsvMembershipProgramResource.js | 4 + .../RsvMembershipProgramController.php | 183 ++++++++++++++++++ .../RsvTimetableReservationCreatedEvent.php | 3 +- .../RsvTimetableReservationPendingEvent.php | 2 +- .../Listeners/RsvGoogleCalendarListener.php | 22 ++- includes/Models/RsvMembershipKey.php | 42 ++++ includes/Models/RsvMembershipProgram.php | 42 ++++ includes/Models/RsvTimetableReservation.php | 2 + .../RsvMembershipProgramRepository.php | 114 +++++++++++ includes/RsvAdminMenuDefinition.php | 10 + includes/RsvInstaller.php | 20 ++ includes/RsvRestApiDefinition.php | 1 + .../Forms/Pricing/RsvFormPriceCalculator.php | 5 +- .../Forms/RsvFormCalculatedValues.php | 21 +- includes/Services/Forms/RsvFormDefinition.php | 14 +- .../Forms/RsvFormDefinitionValidator.php | 40 ++++ includes/Services/Membership/MEMBERSHIP.md | 9 + .../Membership/RsvMembershipService.php | 50 +++++ includes/Services/RsvReservationService.php | 17 +- .../RsvTimetableReservationService.php | 15 +- includes/Views/RsvFormsPage.php | 163 +++++++++++++++- includes/Views/RsvMembershipProgramsPage.php | 130 +++++++++++++ psalm-baseline.xml | 3 - src/admin.js | 4 + 25 files changed, 885 insertions(+), 35 deletions(-) create mode 100644 assets/js/datasource/RsvMembershipKeyResource.js create mode 100644 assets/js/datasource/RsvMembershipProgramResource.js create mode 100644 includes/Controllers/RsvMembershipProgramController.php create mode 100644 includes/Models/RsvMembershipKey.php create mode 100644 includes/Models/RsvMembershipProgram.php create mode 100644 includes/Repository/RsvMembershipProgramRepository.php create mode 100644 includes/Services/Membership/MEMBERSHIP.md create mode 100644 includes/Services/Membership/RsvMembershipService.php create mode 100644 includes/Views/RsvMembershipProgramsPage.php diff --git a/assets/js/datasource/RsvMembershipKeyResource.js b/assets/js/datasource/RsvMembershipKeyResource.js new file mode 100644 index 0000000..3f3dbfd --- /dev/null +++ b/assets/js/datasource/RsvMembershipKeyResource.js @@ -0,0 +1,4 @@ +import { RsvDataSource } from './RsvDataSource.js'; + +export const RsvMembershipKeyResource = (program_id) => + RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/membership-program/' + program_id + '/keys'); diff --git a/assets/js/datasource/RsvMembershipProgramResource.js b/assets/js/datasource/RsvMembershipProgramResource.js new file mode 100644 index 0000000..683b3f9 --- /dev/null +++ b/assets/js/datasource/RsvMembershipProgramResource.js @@ -0,0 +1,4 @@ +import { RsvDataSource } from './RsvDataSource.js'; + +export const RsvMembershipProgramResource = () => + RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/membership-program'); diff --git a/includes/Controllers/RsvMembershipProgramController.php b/includes/Controllers/RsvMembershipProgramController.php new file mode 100644 index 0000000..54ef4ba --- /dev/null +++ b/includes/Controllers/RsvMembershipProgramController.php @@ -0,0 +1,183 @@ + 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'readonly' => true], + 'name' => ['type' => 'string', 'required' => true, 'minLength' => 1], + 'active' => ['type' => 'boolean', 'default' => true], + ], + ]; + } + + public function register_routes(): void { + register_rest_route($this->namespace, '/' . $this->resource_name, [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'index'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + [ + 'methods' => 'POST', + 'callback' => [$this, 'create'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + 'args' => self::input_args(self::schema()), + ], + ]); + + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)', [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'show'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + [ + 'methods' => 'PUT', + 'callback' => [$this, 'update'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + 'args' => self::input_args(self::schema()), + ], + [ + 'methods' => 'DELETE', + 'callback' => [$this, 'delete'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + ]); + + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)/keys', [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'index_keys'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + [ + 'methods' => 'POST', + 'callback' => [$this, 'add_key'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + ]); + + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)/keys/(?P\d+)', [ + [ + 'methods' => 'DELETE', + 'callback' => [$this, 'delete_key'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ], + ]); + } + + public function index(WP_REST_Request $request): WP_REST_Response { + [$skip, $limit] = self::paging($request); + $repo = new RsvMembershipProgramRepository(); + $programs = array_map(fn($p) => $p->to_array(), $repo->all($limit, $skip)); + return $this->paged_response($programs, $repo->count_all()); + } + + public function create(WP_REST_Request $request): WP_REST_Response { + $params = $request->get_json_params(); + $name = $params['name'] ?? ''; + $active = $params['active'] ?? true; + + if (trim($name) === '') { + throw new InvalidArgumentException('Name is required.'); + } + + $repo = new RsvMembershipProgramRepository(); + $id = $repo->add($name, $active); + return new WP_REST_Response(RsvMembershipProgram::from_array(['id' => $id, 'name' => $name, 'active' => $active])->to_array(), 201); + } + + public function show(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $repo = new RsvMembershipProgramRepository(); + $program = $repo->get($id); + + if ($program === null) { + return new WP_REST_Response(['error' => 'Not found'], 404); + } + + return new WP_REST_Response($program, 200); + } + + public function update(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $params = $request->get_json_params(); + $name = $params['name'] ?? ''; + $active = $params['active'] ?? true; + + if (trim($name) === '') { + throw new InvalidArgumentException('Name is required.'); + } + + $repo = new RsvMembershipProgramRepository(); + if ($repo->get($id) === null) { + return new WP_REST_Response(['error' => 'Not found'], 404); + } + + $repo->update($id, $name, $active); + return new WP_REST_Response(['id' => $id, 'name' => $name, 'active' => $active], 200); + } + + public function delete(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $repo = new RsvMembershipProgramRepository(); + + if ($repo->get($id) === null) { + return new WP_REST_Response(['error' => 'Not found'], 404); + } + + $repo->delete($id); + return new WP_REST_Response(['ok' => true], 200); + } + + public function index_keys(WP_REST_Request $request): WP_REST_Response { + [$skip, $limit] = self::paging($request); + $program_id = (int) $request->get_param('id'); + $repo = new RsvMembershipProgramRepository(); + + if ($repo->get($program_id) === null) { + return new WP_REST_Response(['error' => 'Program not found'], 404); + } + + $keys = array_map(fn($k) => $k->to_array(), $repo->keys($program_id, $limit, $skip)); + return $this->paged_response($keys, $repo->count_keys($program_id)); + } + + public function add_key(WP_REST_Request $request): WP_REST_Response { + $program_id = (int) $request->get_param('id'); + $params = $request->get_json_params(); + $key_value = $params['key_value'] ?? ''; + + if (trim($key_value) === '') { + throw new InvalidArgumentException('Key value is required.'); + } + + $repo = new RsvMembershipProgramRepository(); + if ($repo->get($program_id) === null) { + return new WP_REST_Response(['error' => 'Program not found'], 404); + } + + $key_id = $repo->add_key($program_id, $key_value); + return new WP_REST_Response(['id' => $key_id, 'program_id' => $program_id, 'key_value' => $key_value], 201); + } + + public function delete_key(WP_REST_Request $request): WP_REST_Response { + $program_id = (int) $request->get_param('id'); + $key_id = (int) $request->get_param('key_id'); + + $repo = new RsvMembershipProgramRepository(); + if ($repo->get($program_id) === null) { + return new WP_REST_Response(['error' => 'Program not found'], 404); + } + + $repo->delete_key($key_id); + return new WP_REST_Response(['ok' => true], 200); + } + +} diff --git a/includes/Events/RsvTimetableReservationCreatedEvent.php b/includes/Events/RsvTimetableReservationCreatedEvent.php index 95b54dc..c9bb8c9 100644 --- a/includes/Events/RsvTimetableReservationCreatedEvent.php +++ b/includes/Events/RsvTimetableReservationCreatedEvent.php @@ -2,6 +2,7 @@ class RsvTimetableReservationCreatedEvent { public function __construct( - public RsvTimetableReservation $reservation + public RsvTimetableReservation $reservation, + public RsvReservation $parent ) {} } diff --git a/includes/Events/RsvTimetableReservationPendingEvent.php b/includes/Events/RsvTimetableReservationPendingEvent.php index 9a9be71..5b93c6b 100644 --- a/includes/Events/RsvTimetableReservationPendingEvent.php +++ b/includes/Events/RsvTimetableReservationPendingEvent.php @@ -9,7 +9,7 @@ class RsvTimetableReservationPendingEvent { public function __construct( public int $reservation_id, public RsvTimetableReservation $reservation, - public string $code, + public ?string $code, public string $maintainer_email ) {} } diff --git a/includes/Listeners/RsvGoogleCalendarListener.php b/includes/Listeners/RsvGoogleCalendarListener.php index 5067c17..cba33bc 100644 --- a/includes/Listeners/RsvGoogleCalendarListener.php +++ b/includes/Listeners/RsvGoogleCalendarListener.php @@ -10,6 +10,19 @@ class RsvGoogleCalendarListener { public static function on_created(RsvTimetableReservationCreatedEvent $event): void { try { + $form_submit = (new RsvFormSubmitRepository())->get((int) $event->parent->form_submit_id); + if ($form_submit === null) { + return; + } + + $definition = (new RsvFormDefinitionRepository())->get((int) $form_submit['form_id']); + if ($definition === null) { + return; + } + + $email_key = $definition['definition']['email_key'] ?? null; + $user_email = is_string($email_key) && $email_key !== '' ? ($form_values[$email_key] ?? null) : null; + $gcal = new RsvGoogleCalendarService(); if (!$gcal->is_google_connected()) { return; @@ -20,13 +33,14 @@ class RsvGoogleCalendarListener { return; } - $status = $event->reservation->requires_confirmation ? 'tentative' : 'confirmed'; + $status = $event->reservation->is_confirmed == null ? 'tentative' : 'confirmed'; $gcal->add_event( $calendar_id, "Reservation #{$event->reservation->id}", - $event->reservation->start_utc, - $event->reservation->end_utc, - $event->reservation->user_email, + // format time as utc + $event->reservation->start_utc->format('Y-m-d H:i:s'), + $event->reservation->end_utc->format('Y-m-d H:i:s'), + $user_email, $event->reservation->id, $status, ); diff --git a/includes/Models/RsvMembershipKey.php b/includes/Models/RsvMembershipKey.php new file mode 100644 index 0000000..768315d --- /dev/null +++ b/includes/Models/RsvMembershipKey.php @@ -0,0 +1,42 @@ + 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'readonly' => true], + 'program_id' => ['type' => 'integer', 'required' => true], + 'key_value' => ['type' => 'string', 'required' => true, 'minLength' => 1], + ], + ]; + } + + public static function from_array(array $data): self { + return new self( + intval($data['id'] ?? null), + intval($data['program_id'] ?? 0), + $data['key_value'] ?? '' + ); + } + + public function __construct(?int $id, int $program_id, string $key_value) { + $this->id = $id; + $this->program_id = $program_id; + $this->key_value = $key_value; + } + + public function to_array() { + return [ + 'id' => $this->id, + 'program_id' => $this->program_id, + 'key_value' => $this->key_value, + ]; + } +} diff --git a/includes/Models/RsvMembershipProgram.php b/includes/Models/RsvMembershipProgram.php new file mode 100644 index 0000000..d57424b --- /dev/null +++ b/includes/Models/RsvMembershipProgram.php @@ -0,0 +1,42 @@ + 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'readonly' => true], + 'name' => ['type' => 'string', 'required' => true, 'minLength' => 1], + 'active' => ['type' => 'boolean', 'default' => true], + ], + ]; + } + + public static function from_array(array $data): self { + return new self( + intval($data['id'] ?? null), + $data['name'] ?? '', + boolval($data['active'] ?? true) + ); + } + + public function __construct(?int $id, string $name, bool $active = true) { + $this->id = $id; + $this->name = $name; + $this->active = $active; + } + + public function to_array() { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'active' => $this->active, + ]; + } +} diff --git a/includes/Models/RsvTimetableReservation.php b/includes/Models/RsvTimetableReservation.php index aa29458..ff4d2cb 100644 --- a/includes/Models/RsvTimetableReservation.php +++ b/includes/Models/RsvTimetableReservation.php @@ -6,6 +6,8 @@ class RsvTimetableReservation { public DateTime $start_utc; // UTC, 'Y-m-d H:i:s' public DateTime $end_utc; // UTC, 'Y-m-d H:i:s' + public ?int $is_confirmed = null; + public static function schema(): array { return [ 'type' => 'object', diff --git a/includes/Repository/RsvMembershipProgramRepository.php b/includes/Repository/RsvMembershipProgramRepository.php new file mode 100644 index 0000000..cab9738 --- /dev/null +++ b/includes/Repository/RsvMembershipProgramRepository.php @@ -0,0 +1,114 @@ +table_program = Db::prefix() . 'rsv_membership_program'; + $this->table_key = Db::prefix() . 'rsv_membership_key'; + } + + public function all(?int $limit = null, int $skip = 0): array { + if ($limit === null) { + $rows = Db::get_results( + "SELECT * FROM {$this->table_program} ORDER BY id DESC", + [], + ARRAY_A + ); + } else { + $rows = Db::get_results( + "SELECT * FROM {$this->table_program} ORDER BY id DESC LIMIT %d OFFSET %d", + [$limit, $skip], + ARRAY_A + ); + } + return array_map(fn($row) => RsvMembershipProgram::from_array($row), $rows); + } + + public function count_all(): int { + return (int) Db::get_var("SELECT COUNT(*) FROM {$this->table_program}"); + } + + public function get(int $id): ?array { + return Db::get_row( + "SELECT * FROM {$this->table_program} WHERE id = %d", + [$id], + ARRAY_A + ); + } + + public function add(string $name, bool $active): int { + return Db::insert($this->table_program, [ + 'name' => $name, + 'active' => $active ? 1 : 0, + ]); + } + + public function update(int $id, string $name, bool $active): int { + return Db::update( + $this->table_program, + [ + 'name' => $name, + 'active' => $active ? 1 : 0, + ], + ['id' => $id] + ); + } + + public function delete(int $id): void { + Db::delete($this->table_program, ['id' => $id]); + } + + public function keys(int $program_id, ?int $limit = null, int $skip = 0): array { + if ($limit === null) { + $rows = Db::get_results( + "SELECT * FROM {$this->table_key} WHERE program_id = %d ORDER BY id", + [$program_id], + ARRAY_A + ); + } else { + $rows = Db::get_results( + "SELECT * FROM {$this->table_key} WHERE program_id = %d ORDER BY id LIMIT %d OFFSET %d", + [$program_id, $limit, $skip], + ARRAY_A + ); + } + return array_map(fn($row) => RsvMembershipKey::from_array($row), $rows); + } + + public function count_keys(int $program_id): int { + return (int) Db::get_var( + "SELECT COUNT(*) FROM {$this->table_key} WHERE program_id = %d", + [$program_id] + ); + } + + private function normalize_key(string $key_value): string { + return preg_replace('/\s+/', ' ', strtolower(trim($key_value))); + } + + public function add_key(int $program_id, string $key_value): int { + return Db::insert($this->table_key, [ + 'program_id' => $program_id, + 'key_value' => $key_value, + 'key_normalized_value' => $this->normalize_key($key_value) + ]); + } + + public function delete_key(int $key_id): void { + Db::delete($this->table_key, ['id' => $key_id]); + } + + public function key_exists(int $program_id, string $key_value): bool { + return Db::get_var( + "SELECT 1 FROM {$this->table_key} k + INNER JOIN {$this->table_program} p ON p.id = k.program_id + WHERE p.active = 1 AND k.program_id = %d AND k.key_normalized_value = %s + LIMIT 1", + [$program_id, $this->normalize_key($key_value)] + ) !== null; + } +} diff --git a/includes/RsvAdminMenuDefinition.php b/includes/RsvAdminMenuDefinition.php index e545ec4..5217a2f 100644 --- a/includes/RsvAdminMenuDefinition.php +++ b/includes/RsvAdminMenuDefinition.php @@ -8,6 +8,7 @@ function rsv_admin_menu_definition() { $forms = new RsvFormsPage(); $timetable = new RsvTimetablePage(); $google_cal = new RsvGoogleCalendarSettingsPage(); + $membership = new RsvMembershipProgramsPage(); add_menu_page( 'Reservations Settings', // Page title @@ -28,6 +29,15 @@ function rsv_admin_menu_definition() { [$forms, 'render'] ); + add_submenu_page( + 'reservations-settings', + 'Membership Programs', + 'Membership Programs', + RsvCapabilities::MANAGE, + 'membership-programs', + [$membership, 'render'] + ); + add_submenu_page( 'reservations-settings', 'Timetables', diff --git a/includes/RsvInstaller.php b/includes/RsvInstaller.php index df1be6d..688d8a9 100644 --- a/includes/RsvInstaller.php +++ b/includes/RsvInstaller.php @@ -93,6 +93,26 @@ class RsvInstaller { ON DELETE CASCADE ) $charset_collate;"); + self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_membership_program ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + name TINYTEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY (id) + ) $charset_collate;"); + + self::run("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}rsv_membership_key ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + program_id bigint unsigned NOT NULL, + key_value VARCHAR(191) NOT NULL, + key_normalized_value VARCHAR(191) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_program_key (program_id, key_normalized_value), + KEY idx_key_value (key_normalized_value), + CONSTRAINT fk_member_key_program + FOREIGN KEY (program_id) REFERENCES {$wpdb->prefix}rsv_membership_program (id) + ON DELETE CASCADE + ) $charset_collate;"); + // Grant the custom capability that gates the admin REST endpoints. RsvCapabilities::ensure(); } diff --git a/includes/RsvRestApiDefinition.php b/includes/RsvRestApiDefinition.php index bb94ff9..4c68809 100644 --- a/includes/RsvRestApiDefinition.php +++ b/includes/RsvRestApiDefinition.php @@ -84,4 +84,5 @@ function rsv_define_rest_api(): void { (new RsvTimetableReservationController())->register_routes(); (new RsvFormController())->register_routes(); (new RsvFormDefinitionController())->register_routes(); + (new RsvMembershipProgramController())->register_routes(); } diff --git a/includes/Services/Forms/Pricing/RsvFormPriceCalculator.php b/includes/Services/Forms/Pricing/RsvFormPriceCalculator.php index f406a6b..051251f 100644 --- a/includes/Services/Forms/Pricing/RsvFormPriceCalculator.php +++ b/includes/Services/Forms/Pricing/RsvFormPriceCalculator.php @@ -1,6 +1,6 @@ getValue($element->getName())); } - return $total; + $pct = (new RsvMembershipService())->discount_for($definition, $data); + return $total * (1.0 - max(0.0, min(100.0, $pct)) / 100.0); } } diff --git a/includes/Services/Forms/RsvFormCalculatedValues.php b/includes/Services/Forms/RsvFormCalculatedValues.php index 6c2d6ab..4d7ab4a 100644 --- a/includes/Services/Forms/RsvFormCalculatedValues.php +++ b/includes/Services/Forms/RsvFormCalculatedValues.php @@ -4,8 +4,25 @@ final class RsvFormCalculatedValues { /** @return array */ public function for(RsvFormDefinition $definition, RsvFormData $data): array { + $calculator = new RsvFormPriceCalculator(); + global $rsv_form_price_registry; + + $price_before_discount = 0.0; + foreach ($definition->getElements() as $element) { + $element_calculator = $rsv_form_price_registry->get($element->getType()); + if ($element_calculator === null) { + continue; + } + $price_before_discount += (float) $element_calculator($element, $data->getValue($element->getName())); + } + + $discount_pct = (new RsvMembershipService())->discount_for($definition, $data); + $final_price = $calculator->calculate($definition, $data); + return [ - 'price' => (new RsvFormPriceCalculator())->calculate($definition, $data), + 'price' => $final_price, + 'price_before_discount' => $price_before_discount, + 'discount_percent' => $discount_pct, ]; } @@ -14,6 +31,6 @@ final class RsvFormCalculatedValues { * @return list */ public static function names(): array { - return ['price']; + return ['price', 'price_before_discount', 'discount_percent']; } } diff --git a/includes/Services/Forms/RsvFormDefinition.php b/includes/Services/Forms/RsvFormDefinition.php index 4ed0be9..2e33223 100644 --- a/includes/Services/Forms/RsvFormDefinition.php +++ b/includes/Services/Forms/RsvFormDefinition.php @@ -7,10 +7,12 @@ class RsvFormDefinition { public string $email_key = ""; + public array $membership = []; + public string $success_message = ""; /** - * @param array $definition Full definition array including 'elements', 'email_key' and 'success_message'. + * @param array $definition Full definition array including 'elements', 'email_key' and 'success_message'. */ public function __construct(string $id, array $definition) { $this->_elements = []; @@ -23,6 +25,7 @@ class RsvFormDefinition { $this->_id = $id; $this->email_key = $definition['email_key'] ?? ''; + $this->membership = $definition['membership'] ?? []; $this->success_message = $definition['success_message'] ?? ''; } @@ -34,6 +37,15 @@ class RsvFormDefinition { return $this->email_key; } + /** @return array */ + public function getMembershipBindings(): array { + return $this->membership['bindings'] ?? []; + } + + public function getMembershipCombine(): string { + return $this->membership['combine'] ?? 'max'; + } + /** Template shown to the visitor after a successful submission. */ public function getSuccessMessage(): string { return $this->success_message; diff --git a/includes/Services/Forms/RsvFormDefinitionValidator.php b/includes/Services/Forms/RsvFormDefinitionValidator.php index 1e4262a..ea35cc3 100644 --- a/includes/Services/Forms/RsvFormDefinitionValidator.php +++ b/includes/Services/Forms/RsvFormDefinitionValidator.php @@ -34,6 +34,9 @@ final class RsvFormDefinitionValidator { $errors[] = 'Form must contain a submit button.'; } + // Validate membership bindings if present. + $errors = array_merge($errors, $this->validate_membership_bindings($definition)); + return $errors; } @@ -109,4 +112,41 @@ final class RsvFormDefinitionValidator { global $rsv_template_registry; return new RsvTemplateEngine(registry: $rsv_template_registry); } + + /** + * @param array $definition + * @return list + */ + private function validate_membership_bindings(array $definition): array { + $errors = []; + $membership = $definition['membership'] ?? []; + + if (!is_array($membership)) { + return $errors; + } + + $bindings = $membership['bindings'] ?? []; + if (!is_array($bindings)) { + return $errors; + } + + foreach ($bindings as $idx => $binding) { + if (!is_array($binding)) { + continue; + } + + $program_id = intval($binding['program_id'] ?? 0); + $discount = floatval($binding['discount'] ?? 0.0); + + if ($program_id <= 0) { + $errors[] = "Membership binding {$idx}: program_id must be a positive integer."; + } + + if ($discount < 0.0 || $discount > 100.0) { + $errors[] = "Membership binding {$idx}: discount must be between 0 and 100."; + } + } + + return $errors; + } } diff --git a/includes/Services/Membership/MEMBERSHIP.md b/includes/Services/Membership/MEMBERSHIP.md new file mode 100644 index 0000000..e6c6e30 --- /dev/null +++ b/includes/Services/Membership/MEMBERSHIP.md @@ -0,0 +1,9 @@ +# Membership + +Forms can have a price calculated from it's definition and submitted data. For a discount, the administrator can create a membership program. In the system, that means a collection of strings. In the form definition, the administrator defines, which membership program applies what discount and how to extract the key into the membership program from the from submission. + +## Matching the keys + +The membership is a collection of keys. But some types of them are hard to match exactly. For example it might be important to ignore case or be culture invariant. For this reason, second column in the membership table is used, which is for the normalized key, that must be matched exactly against another normalized key. + +For example, for full names, the process of normalization might remove diacritics or convert to lowercase. The same process must be applied when looking for the key. diff --git a/includes/Services/Membership/RsvMembershipService.php b/includes/Services/Membership/RsvMembershipService.php new file mode 100644 index 0000000..ec585d3 --- /dev/null +++ b/includes/Services/Membership/RsvMembershipService.php @@ -0,0 +1,50 @@ +getMembershipBindings() as $binding) { + $program_id = intval($binding['program_id'] ?? 0); + $discount = floatval($binding['discount'] ?? 0.0); + $field = strval($binding['field'] ?? ''); + + if ($program_id <= 0 || $field === '') { + continue; + } + + $raw = $data->getValue($field, ''); + $value = is_scalar($raw) ? trim((string) $raw) : ''; + + if ($value === '') { + continue; + } + + if ($repo->key_exists($program_id, $value)) { + $matched_discounts[] = $discount; + } + } + + if (empty($matched_discounts)) { + return 0.0; + } + + if ($def->getMembershipCombine() === 'sum') { + return min(100.0, array_sum($matched_discounts)); + } + + return max($matched_discounts); + } +} diff --git a/includes/Services/RsvReservationService.php b/includes/Services/RsvReservationService.php index 7f274ab..006bca8 100644 --- a/includes/Services/RsvReservationService.php +++ b/includes/Services/RsvReservationService.php @@ -53,9 +53,10 @@ class RsvReservationService { try { $reservation_id = $this->repo->insert($reservation->to_array()); + $reservation->id = $reservation_id; foreach ($reservation->timetable_reservations as $timetable_reservation) { - $timetable_reservation_service->create($reservation_id, $timetable_reservation); + $timetable_reservation->id = $timetable_reservation_service->create($reservation_id, $timetable_reservation); } Db::commit(); @@ -67,7 +68,19 @@ class RsvReservationService { // Only now that the rows are durably committed do we let listeners // (maintainer emails, calendar sync) observe the new reservation. - $timetable_reservation_service->flush_deferred_events(); + foreach($reservation->timetable_reservations as $timetable_reservation) { + if($timetable_reservation->is_confirmed === null) { + $maintainer_email = (new RsvTimetableRepository())->get_maintainer_email($timetable_reservation->timetable_id); + RsvEventDispatcher::dispatch(new RsvTimetableReservationPendingEvent( + $reservation_id, + $timetable_reservation, + $timetable_reservation_service->get_confirmation_code($timetable_reservation->id), + $maintainer_email + )); + } + + RsvEventDispatcher::dispatch(new RsvTimetableReservationCreatedEvent($timetable_reservation, $reservation)); + } $this->confirmation_state_changed($reservation_id); diff --git a/includes/Services/RsvTimetableReservationService.php b/includes/Services/RsvTimetableReservationService.php index 98b9d73..abac6e1 100644 --- a/includes/Services/RsvTimetableReservationService.php +++ b/includes/Services/RsvTimetableReservationService.php @@ -115,13 +115,9 @@ class RsvTimetableReservationService { return $this->repo->has_pending_confirmation($reservation_id); } - private function get_confirmation_code(int $reservation_id): string { + public function get_confirmation_code(int $reservation_id): ?string { $code = $this->repo->get_confirmation_code($reservation_id); - if ($code === null) { - throw new InvalidArgumentException('No pending confirmation for this reservation.'); - } - return $code; } @@ -157,20 +153,13 @@ class RsvTimetableReservationService { if ($maintainer_email) { $code = $this->create_confirmation($reservation_id, $id); - $this->deferred_events[] = new RsvTimetableReservationPendingEvent( - $reservation_id, - $reservation, - $code, - $maintainer_email - ); } } else { + $reservation->is_confirmed = 1; $reservation_service = new RsvReservationService(); $reservation_service->confirmation_state_changed($reservation_id); } - $this->deferred_events[] = new RsvTimetableReservationCreatedEvent($reservation); - return $id; } diff --git a/includes/Views/RsvFormsPage.php b/includes/Views/RsvFormsPage.php index 593044b..6c779ed 100644 --- a/includes/Views/RsvFormsPage.php +++ b/includes/Views/RsvFormsPage.php @@ -106,6 +106,7 @@ class RsvFormsPage extends RsvAdminPage { $next_id = count($elements_with_ids) + 1; $timetables = (new RsvTimetableService())->get_all(); + $programs = array_map(fn($p) => $p->to_array(), (new RsvMembershipProgramRepository())->all()); $email_key_options = ['' => '— select field —']; foreach ($raw_elements as $el) { @@ -130,6 +131,25 @@ class RsvFormsPage extends RsvAdminPage { ->render(); ?> +
+

Membership Discounts

+

Map a membership program to a discount percentage. The chosen field's value must match a key in that program for the discount to apply.

+ +
+

+ +

+ +
output(); ?> - elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?> + elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables, $programs, $bindings); ?> + output(); + ?> + + get($id); + + if ($program === null) { + echo '

Program not found.

'; + return; + } + + ?> +

Edit Program:

+ ← Back to Programs +
+ + text('name', 'Name', '', true, $program['name']) + ->checkbox('active', 'Active', '', $program['active'] ?? true) + ->render(); + ?> + + +
+

Roster

+

Each member is identified by a single key. The key format depends on the active membership strategy.

+ + column(function () use ($id) { + echo RsvFormBuilder::create('add_membership_key', get_rest_url(null, 'reservations/v1/membership-program/' . $id . '/keys'), 'POST', 'Member added.') + ->heading('Add Member') + ->text('key_value', 'Key') + ->render(); + ?> +
+

+ +

+ column(function () use ($id) { ?> +
+ + output(); + ?> + + - - - diff --git a/src/admin.js b/src/admin.js index 4dd9e24..589fd0f 100644 --- a/src/admin.js +++ b/src/admin.js @@ -12,6 +12,8 @@ import { RsvFormDefinitionResource } from '../assets/js/datasource/RsvFormDefini import { RsvTimetableResource } from '../assets/js/datasource/RsvTimetableResource.js'; import { RsvTimetableCapacityResource } from '../assets/js/datasource/RsvTimetableCapacityResource.js'; import { RsvTimetableReservationResource } from '../assets/js/datasource/RsvTimetableReservationResource.js'; +import { RsvMembershipProgramResource } from '../assets/js/datasource/RsvMembershipProgramResource.js'; +import { RsvMembershipKeyResource } from '../assets/js/datasource/RsvMembershipKeyResource.js'; import { RsvReservationClient } from '../assets/js/datasource/RsvReservationClient.js'; window.RsvDataGrid = RsvDataGrid; @@ -22,4 +24,6 @@ window.RsvFormDefinitionResource = RsvFormDefinitionResource; window.RsvTimetableResource = RsvTimetableResource; window.RsvTimetableCapacityResource = RsvTimetableCapacityResource; window.RsvTimetableReservationResource = RsvTimetableReservationResource; +window.RsvMembershipProgramResource = RsvMembershipProgramResource; +window.RsvMembershipKeyResource = RsvMembershipKeyResource; window.RsvReservationClient = RsvReservationClient; -- 2.52.0