>, total: int} */ public function list(): array { $items = []; foreach ( $this->rules->all() as $rule ) { $items[] = $this->to_admin_array( $rule ); } return [ 'items' => $items, 'total' => count( $items ), ]; } /** * Create a rule. * * @param array $payload Fields. * @return array */ public function create( array $payload ): array { $now = $this->clock->now(); $rule = new ReminderRule( null, Uuid::generate(), $this->name( $payload ), $this->template_key( $payload ), $this->channel( $payload ), $this->offset( $payload ), $this->applies_to( $payload ), $this->target_ids( $payload ), $this->statuses( $payload ), array_key_exists( 'is_enabled', $payload ) ? (bool) $payload['is_enabled'] : true, $now, $now, ); $this->rules->save( $rule ); return $this->to_admin_array( $rule ); } /** * Patch a rule. * * @param int $id Id. * @param array $payload Fields. * @return array * * @throws DomainException When missing. */ public function update( int $id, array $payload ): array { $existing = $this->rules->find_by_id( $id ); if ( null === $existing ) { throw new DomainException( 'reminder.not_found', 'Reminder rule not found.' ); } $now = $this->clock->now(); $rule = new ReminderRule( $existing->id(), $existing->uuid(), array_key_exists( 'name', $payload ) ? $this->name( $payload ) : $existing->name(), array_key_exists( 'template_key', $payload ) ? $this->template_key( $payload ) : $existing->template_key(), array_key_exists( 'channel', $payload ) ? $this->channel( $payload ) : $existing->channel(), array_key_exists( 'offset_minutes', $payload ) ? $this->offset( $payload ) : $existing->offset_minutes(), array_key_exists( 'applies_to', $payload ) ? $this->applies_to( $payload ) : $existing->applies_to(), array_key_exists( 'target_ids', $payload ) ? $this->target_ids( $payload ) : $existing->target_ids(), array_key_exists( 'booking_statuses', $payload ) ? $this->statuses( $payload ) : $existing->booking_statuses(), array_key_exists( 'is_enabled', $payload ) ? (bool) $payload['is_enabled'] : $existing->is_enabled(), $existing->created_at(), $now, ); $this->rules->save( $rule ); return $this->to_admin_array( $rule ); } /** * Delete a rule. * * @param int $id Id. * @return array{deleted: bool} * * @throws DomainException When missing. */ public function delete( int $id ): array { if ( null === $this->rules->find_by_id( $id ) ) { throw new DomainException( 'reminder.not_found', 'Reminder rule not found.' ); } $this->rules->delete( $id ); return [ 'deleted' => true ]; } /** * Map a rule to the admin payload. * * @param ReminderRule $rule Rule. * @return array */ private function to_admin_array( ReminderRule $rule ): array { return [ 'id' => $rule->id(), 'name' => $rule->name(), 'template_key' => $rule->template_key(), 'channel' => $rule->channel(), 'offset_minutes' => $rule->offset_minutes(), 'applies_to' => $rule->applies_to(), 'target_ids' => $rule->target_ids(), 'booking_statuses' => $rule->booking_statuses(), 'is_enabled' => $rule->is_enabled(), ]; } /** * Rule name. * * @param array $payload Payload. */ private function name( array $payload ): string { $name = trim( (string) ( $payload['name'] ?? '' ) ); return '' !== $name ? $name : 'Reminder'; } /** * Template key. * * @param array $payload Payload. */ private function template_key( array $payload ): string { $key = strtolower( trim( (string) ( $payload['template_key'] ?? 'booking.reminder.customer' ) ) ); if ( 1 !== preg_match( '/^[a-z0-9._-]+$/', $key ) ) { return 'booking.reminder.customer'; } return $key; } /** * Channel id. * * @param array $payload Payload. */ private function channel( array $payload ): string { $channel = strtolower( (string) ( $payload['channel'] ?? 'email' ) ); $channel = in_array( $channel, self::CHANNELS, true ) ? $channel : 'email'; if ( in_array( $channel, [ 'sms', 'whatsapp' ], true ) ) { $this->named_features()->assert_allows( NamedFeature::SmsWhatsapp ); } return $channel; } /** * Named-feature gate (directory zip is open). */ private function named_features(): NamedFeatureGate { return $this->named ?? new OpenNamedFeatureGate(); } /** * Offset in minutes. * * @param array $payload Payload. */ private function offset( array $payload ): int { return (int) ( $payload['offset_minutes'] ?? -1440 ); } /** * Scope. * * @param array $payload Payload. */ private function applies_to( array $payload ): string { $applies = strtolower( (string) ( $payload['applies_to'] ?? 'all' ) ); return in_array( $applies, self::APPLIES, true ) ? $applies : 'all'; } /** * Target entity ids. * * @param array $payload Payload. * @return list|null */ private function target_ids( array $payload ): ?array { if ( ! isset( $payload['target_ids'] ) || ! is_array( $payload['target_ids'] ) ) { return null; } $ids = []; foreach ( $payload['target_ids'] as $id ) { $n = (int) $id; if ( $n > 0 ) { $ids[] = $n; } } return [] === $ids ? null : array_values( array_unique( $ids ) ); } /** * Booking status allow-list. * * @param array $payload Payload. * @return list|null */ private function statuses( array $payload ): ?array { if ( ! isset( $payload['booking_statuses'] ) || ! is_array( $payload['booking_statuses'] ) ) { return null; } $out = []; foreach ( $payload['booking_statuses'] as $status ) { if ( is_string( $status ) && preg_match( '/^[a-z_]+$/', $status ) ) { $out[] = $status; } } return [] === $out ? null : array_values( array_unique( $out ) ); } }