Skip to content

Commit e591f5d

Browse files
mjoslynclaude
andcommitted
Add exclude_roles to visibility roles
A visibility role can now list roles to EXEMPT: a user holding any of the exclude_roles is not matched even when they satisfy the include roles. This lets a role-based hide carve out users who hold a broader entitlement — e.g. hide a tier's pricing on restricted categories EXCEPT for users who are also dealer/operator — which the prior ANY/ALL positive-only matcher could not express (no negation). - Context::user_matches_visibility_role: veto when a user matches any exclude_role (the synthetic MSRP_CUSTOMER is honored); explicit per-user targeting is unaffected. - Config::visibility_roles: normalize/persist exclude_roles. - Admin\Settings: sanitize and render an "Except roles" multiselect. - Test: parts_dealer hidden; parts_dealer who is also dealer/operator exempt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3421bb3 commit e591f5d

4 files changed

Lines changed: 60 additions & 1 deletion

File tree

src/Admin/Settings.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,12 +261,14 @@ private function sanitize_visibility_roles( $rows ) {
261261
}
262262

263263
$roles = isset( $row['roles'] ) && is_array( $row['roles'] ) ? array_values( array_unique( array_filter( array_map( 'sanitize_key', $row['roles'] ) ) ) ) : array();
264+
$exclude = isset( $row['exclude_roles'] ) && is_array( $row['exclude_roles'] ) ? array_values( array_unique( array_filter( array_map( 'sanitize_key', $row['exclude_roles'] ) ) ) ) : array();
264265
$users = isset( $row['users'] ) && is_array( $row['users'] ) ? array_values( array_unique( array_filter( array_map( 'absint', $row['users'] ) ) ) ) : array();
265266
$match = isset( $row['match'] ) && 'all' === $row['match'] ? 'all' : 'any';
266267
$out[ $key ] = array(
267268
'key' => $key,
268269
'label' => sanitize_text_field( $row['label'] ?? ucfirst( $key ) ),
269270
'roles' => $roles,
271+
'exclude_roles' => $exclude,
270272
'users' => $users,
271273
'match' => $match,
272274
'categories' => $this->sanitize_category_set( $row['categories'] ?? array() ),
@@ -831,6 +833,18 @@ private function render_visibility_role_row( $name, $index, array $role, array $
831833
</select>
832834
<p class="description"><?php esc_html_e( 'Users are matched by these roles. "MSRP Customer" matches anyone without a pricing tier (retail customers, subscribers, guests).', 'wc-pricebook' ); ?></p>
833835
</div>
836+
<div class="wc-pricebook-field wc-pricebook-field--full">
837+
<label><?php esc_html_e( 'Except roles', 'wc-pricebook' ); ?></label>
838+
<?php
839+
$excluded_roles = isset( $role['exclude_roles'] ) && is_array( $role['exclude_roles'] ) ? array_map( 'strval', $role['exclude_roles'] ) : array();
840+
?>
841+
<select multiple class="wc-enhanced-select" name="<?php echo esc_attr( $base . '[exclude_roles][]' ); ?>" style="width:100%;" data-placeholder="<?php esc_attr_e( 'Select roles to exempt&hellip;', 'wc-pricebook' ); ?>">
842+
<?php foreach ( $this->role_options() as $slug => $role_label ) : ?>
843+
<option value="<?php echo esc_attr( (string) $slug ); ?>" <?php echo in_array( (string) $slug, $excluded_roles, true ) ? 'selected="selected"' : ''; ?>><?php echo esc_html( $role_label ); ?></option>
844+
<?php endforeach; ?>
845+
</select>
846+
<p class="description"><?php esc_html_e( 'A user holding any of these roles is exempt from this rule, even if they match the roles above (e.g. hide a tier’s pricing except for users who are also dealer/operator).', 'wc-pricebook' ); ?></p>
847+
</div>
834848
<div class="wc-pricebook-field wc-pricebook-field--full">
835849
<label><?php esc_html_e( 'Specific users', 'wc-pricebook' ); ?></label>
836850
<?php $selected_users = isset( $role['users'] ) && is_array( $role['users'] ) ? array_map( 'intval', $role['users'] ) : array(); ?>

src/Config.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ public function visibility_roles() {
255255
$role = is_array( $role ) ? $role : array();
256256
$key = isset( $role['key'] ) ? $role['key'] : $key;
257257
$want = isset( $role['roles'] ) && is_array( $role['roles'] ) ? array_values( array_unique( array_filter( array_map( 'sanitize_key', $role['roles'] ) ) ) ) : array();
258+
$except = isset( $role['exclude_roles'] ) && is_array( $role['exclude_roles'] ) ? array_values( array_unique( array_filter( array_map( 'sanitize_key', $role['exclude_roles'] ) ) ) ) : array();
258259
$users = isset( $role['users'] ) && is_array( $role['users'] ) ? array_values( array_unique( array_filter( array_map( 'intval', $role['users'] ) ) ) ) : array();
259260
$match = isset( $role['match'] ) && 'all' === $role['match'] ? 'all' : 'any';
260261
// Category SET ({ mode, categories }) — the same scoping model as pricing tiers.
@@ -265,6 +266,8 @@ public function visibility_roles() {
265266
'key' => $key,
266267
'label' => isset( $role['label'] ) ? $role['label'] : ucfirst( $key ),
267268
'roles' => $want,
269+
// Users holding any of these roles are exempt even if they match `roles`.
270+
'exclude_roles' => $except,
268271
'users' => $users,
269272
'match' => $match,
270273
'categories' => $categories,

src/Context.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,8 +572,24 @@ private function user_matches_visibility_role( $user_id, array $role, array $use
572572
foreach ( $want as $slug ) {
573573
$hits[] = ( self::MSRP_CUSTOMER === $slug ) ? (bool) $is_msrp : in_array( $slug, $user_roles, true );
574574
}
575+
$matched = 'all' === $match ? ! in_array( false, $hits, true ) : in_array( true, $hits, true );
576+
if ( ! $matched ) {
577+
return false;
578+
}
579+
580+
// Exclude roles: a user holding ANY of these is exempt from this visibility role,
581+
// even though they matched the include roles above. Lets a role-based rule carve
582+
// out users who hold a broader entitlement — e.g. hide a tier's pricing EXCEPT for
583+
// users who are also dealer/operator. The synthetic MSRP_CUSTOMER is honored here
584+
// too. Explicit per-user targeting (above) is not affected.
585+
$except = isset( $role['exclude_roles'] ) && is_array( $role['exclude_roles'] ) ? $role['exclude_roles'] : array();
586+
foreach ( $except as $slug ) {
587+
if ( ( self::MSRP_CUSTOMER === $slug ) ? (bool) $is_msrp : in_array( $slug, $user_roles, true ) ) {
588+
return false;
589+
}
590+
}
575591

576-
return 'all' === $match ? ! in_array( false, $hits, true ) : in_array( true, $hits, true );
592+
return true;
577593
}
578594

579595
/**

tests/ContextTest.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,32 @@ public function test_visibility_role_match_all() {
252252
$this->assertSame( array(), $this->context->visibility_role_categories( 6 )['exclude'] );
253253
}
254254

255+
public function test_visibility_role_exclude_roles_exempt_matched_users() {
256+
// Include parts_dealer, but exempt anyone who is also dealer/operator — the Fenton
257+
// case: hide restricted-category pricing from a parts-only dealer, while a
258+
// parts_dealer who is also a dealer/operator keeps seeing prices.
259+
$this->with_visibility_roles(
260+
array(
261+
'r' => array(
262+
'label' => 'R',
263+
'roles' => array( 'parts_dealer' ),
264+
'exclude_roles' => array( 'dealer', 'operator' ),
265+
'match' => 'any',
266+
'categories' => array( 'mode' => 'include', 'categories' => array( 555 ) ),
267+
'hide' => 'product',
268+
),
269+
)
270+
);
271+
Store::add_user( 5, array( 'customer', 'parts_dealer' ) ); // parts only → matched.
272+
Store::add_user( 6, array( 'parts_dealer', 'dealer' ) ); // also dealer → exempt.
273+
Store::add_user( 7, array( 'parts_dealer', 'operator' ) ); // also operator → exempt.
274+
Store::add_user( 8, array( 'customer' ) ); // not parts → no include match.
275+
$this->assertSame( array( 555 ), $this->context->visibility_role_categories( 5 )['exclude'] );
276+
$this->assertSame( array(), $this->context->visibility_role_categories( 6 )['exclude'] );
277+
$this->assertSame( array(), $this->context->visibility_role_categories( 7 )['exclude'] );
278+
$this->assertSame( array(), $this->context->visibility_role_categories( 8 )['exclude'] );
279+
}
280+
255281
public function test_role_targeting_is_capability_based() {
256282
// Role targeting (visibility roles, force overrides, bulk pricing) uses the same
257283
// capability check as tier membership: user_can($user, $slug). A capability

0 commit comments

Comments
 (0)