capability needed to use it. */ private const KINDS = array( 'orders'=>'storesuit_orders', 'products'=>'manage_woocommerce', 'reports'=>'storesuit_finance' ); private const REPORT_CHANNELS = array( 'web', 'pos', 'instagram', 'whatsapp', 'facebook', 'phone', 'b2b', 'other', 'unassigned' ); private static function kind( array $data ): string { $kind = Store::text( $data, 'kind' ) ?: 'orders'; if ( ! isset( self::KINDS[ $kind ] ) ) { throw new \InvalidArgumentException( I18n::t( 'Choose valid filters.' ) ); } Store::require_cap( self::KINDS[ $kind ] ); return $kind; } private static function invalid( string $kind ): \InvalidArgumentException { return new \InvalidArgumentException( $kind === 'orders' ? I18n::t( 'Choose valid order filters.' ) : I18n::t( 'Choose valid filters.' ) ); } /** Option names; the Order desk keeps the names it had since 0.34. */ private static function option( string $kind, string $scope ): string { $base = array( 'orders'=>'storesuit_order_views', 'products'=>'storesuit_product_views', 'reports'=>'storesuit_report_views' )[ $kind ]; return $scope === 'team' ? $base . '_team' : $base; } /** The one validator per kind: a value that is not valid falls back to its default and its key is reported in $dropped. */ public static function filters( string $kind, array $data, ?array &$dropped = null ): array { if ( $kind === 'orders' ) { return OrderIndex::filters( $data, $dropped ); } $dropped = array(); if ( $kind === 'products' ) { $f = array( 'search'=>'', 'health'=>'all', 'visibility'=>'publish' ); $search = trim( Store::text( $data, 'search' ) ); if ( mb_strlen( $search ) > 80 ) { $search = mb_substr( $search, 0, 80 ); $dropped[] = 'search'; } $f['search'] = $search; $health = Store::text( $data, 'health' ); if ( $health !== '' && $health !== 'all' ) { if ( in_array( $health, Products::health_filters(), true ) ) { $f['health'] = $health; } else { $dropped[] = 'health'; } } $visibility = Store::text( $data, 'visibility' ); if ( $visibility !== '' && $visibility !== 'publish' ) { if ( in_array( $visibility, array( 'draft', 'pending', 'private', 'future', 'all' ), true ) ) { $f['visibility'] = $visibility; } else { $dropped[] = 'visibility'; } } return $f; } $f = array( 'from'=>'', 'to'=>'', 'channel'=>'' ); foreach ( array( 'from', 'to' ) as $key ) { $day = Store::text( $data, $key ); if ( $day === '' ) { continue; } if ( preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $day, $m ) && checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ) ) { $f[ $key ] = $day; } else { $dropped[] = $key; } } if ( $f['from'] !== '' && $f['to'] !== '' && $f['from'] > $f['to'] ) { $f['to'] = ''; $dropped[] = 'to'; } $channel = Store::text( $data, 'channel' ); if ( $channel !== '' ) { if ( in_array( $channel, self::REPORT_CHANNELS, true ) ) { $f['channel'] = $channel; } else { $dropped[] = 'channel'; } } return $f; } private static function defaults( string $kind ): array { $none = array(); return self::filters( $kind, array(), $none ); } private static function rows( string $kind, string $scope ): array { $saved = $scope === 'team' ? get_option( self::option( $kind, 'team' ), array() ) : get_user_option( self::option( $kind, 'mine' ), get_current_user_id() ); if ( ! is_array( $saved ) ) { return array(); } return array_values( array_filter( $saved, static fn( $row ) => is_array( $row ) && is_string( $row['id'] ?? null ) && is_string( $row['name'] ?? null ) && is_array( $row['filters'] ?? null ) ) ); } private static function write( string $kind, string $scope, array $rows ): void { if ( $scope === 'team' ) { update_option( self::option( $kind, 'team' ), $rows, false ); return; } update_user_option( get_current_user_id(), self::option( $kind, 'mine' ), $rows ); } private static function version( string $kind, string $scope ): string { return hash( 'sha256', wp_json_encode( self::rows( $kind, $scope ) ) ); } /** A stored view is checked again on every read; values that no longer exist are reported, never applied. */ private static function present( string $kind, array $row ): array { $dropped = array(); $filters = self::filters( $kind, $row['filters'], $dropped ); $by = get_userdata( (int) ( $row['updated_by'] ?? 0 ) ); return array( 'id'=>$row['id'], 'name'=>$row['name'], 'filters'=>$filters, 'issues'=>array_values( array_unique( $dropped ) ), 'updated_at'=>(string) ( $row['updated_at'] ?? '' ), 'updated_by'=>$by ? $by->display_name : I18n::t( 'Former team member' ) ); } public static function read( array $data = array() ): array { $kind = self::kind( $data ); return array( 'kind' => $kind, 'mine' => array_map( static fn( $row ) => self::present( $kind, $row ), self::rows( $kind, 'mine' ) ), 'team' => array_map( static fn( $row ) => self::present( $kind, $row ), self::rows( $kind, 'team' ) ), 'versions' => array( 'mine'=>self::version( $kind, 'mine' ), 'team'=>self::version( $kind, 'team' ) ), 'limit' => self::LIMIT, 'can_share' => current_user_can( Roles::MANAGE ), ); } private static function scope( array $data ): string { $scope = Store::text( $data, 'scope' ); if ( ! in_array( $scope, array( 'mine', 'team' ), true ) ) { throw new \InvalidArgumentException( I18n::t( 'Choose who sees this view.' ) ); } if ( $scope === 'team' ) { Store::require_cap( Roles::MANAGE ); } return $scope; } private static function key( array $data ): string { $key = Store::text( $data, 'request_key' ); if ( ! preg_match( '/^[a-zA-Z0-9_-]{16,60}$/', $key ) ) { throw new \InvalidArgumentException( I18n::t( 'The action key is missing. Refresh the page.' ) ); } return $key; } /** One lock per list: a member's own views, or the shared team list, per kind. */ private static function locked( string $kind, string $scope, array $data, callable $change ): array { return Store::lock( 'order-views:' . $kind . ':' . ( $scope === 'team' ? 'team' : 'user:' . get_current_user_id() ), static function () use ( $kind, $scope, $data, $change ) { if ( ! hash_equals( self::version( $kind, $scope ), Store::text( $data, 'version' ) ) ) { throw new \RuntimeException( I18n::t( 'Saved views changed in another action. Refresh and try again.' ) ); } return $change( self::rows( $kind, $scope ) ); } ); } /** The Order desk keeps its own activity names; product and report views share general ones. */ private static function event( string $kind, string $what ): string { return $kind === 'orders' ? 'order_view_' . $what : 'saved_view_' . $what; } public static function save( array $data ): array { $kind = self::kind( $data ); $scope = self::scope( $data ); $name = trim( Store::text( $data, 'name' ) ); if ( $name === '' || mb_strlen( $name ) > 64 ) { throw new \InvalidArgumentException( I18n::t( 'Enter a view name of at most 64 characters.' ) ); } $id = Store::text( $data, 'id' ); if ( $id !== '' && ! preg_match( '/^[a-f0-9-]{36}$/', $id ) ) { throw new \InvalidArgumentException( I18n::t( 'The saved view could not be found.' ) ); } $filters = null; if ( array_key_exists( 'filters', $data ) ) { $dropped = array(); if ( ! is_array( $data['filters'] ) || array_diff_key( $data['filters'], self::defaults( $kind ) ) ) { $dropped[] = 'filters'; } else { $filters = self::filters( $kind, $data['filters'], $dropped ); } if ( $dropped ) { throw self::invalid( $kind ); } } elseif ( $id === '' ) { throw self::invalid( $kind ); } $user = get_current_user_id(); $done = Store::idempotency( hash( 'sha256', 'order-view-save:' . $kind . ':' . $user . ':' . self::key( $data ) ), static function () use ( $kind, $scope, $data, $id, $name, $filters, $user ) { return self::locked( $kind, $scope, $data, static function ( array $rows ) use ( $kind, $scope, $id, $name, $filters, $user ) { $index = null; foreach ( $rows as $i => $row ) { if ( $row['id'] === $id ) { $index = $i; break; } } if ( $id !== '' && $index === null ) { throw new \InvalidArgumentException( I18n::t( 'The saved view could not be found.' ) ); } if ( $index === null && count( $rows ) >= self::LIMIT ) { throw new \InvalidArgumentException( $scope === 'team' ? I18n::t( 'The team can share up to 10 views.' ) : I18n::t( 'You can save up to 10 views of your own.' ) ); } foreach ( $rows as $row ) { if ( $row['id'] !== $id && mb_strtolower( $row['name'] ) === mb_strtolower( $name ) ) { throw new \InvalidArgumentException( I18n::t( 'A view with this name already exists.' ) ); } } $next = $index === null ? array( 'id'=>wp_generate_uuid4(), 'name'=>'', 'filters'=>array(), 'created_by'=>$user ) : $rows[ $index ]; $before = $next; $next['name'] = $name; if ( $filters !== null ) { $next['filters'] = $filters; } if ( $index !== null && $next['name'] === $before['name'] && $next['filters'] == $before['filters'] ) { return array( 'id'=>$next['id'], 'changed'=>false ); } $next['updated_at'] = gmdate( 'c' ); $next['updated_by'] = $user; if ( $index === null ) { $rows[] = $next; } else { $rows[ $index ] = $next; } self::write( $kind, $scope, $rows ); if ( $scope === 'team' ) { Store::audit( 'settings', 0, self::event( $kind, 'saved' ), array( 'count'=>count( $rows ), 'kind'=>$kind ) ); } return array( 'id'=>$next['id'], 'changed'=>true ); } ); } ); return array_merge( self::read( array( 'kind'=>$kind ) ), array( 'saved'=>(string) ( $done['id'] ?? '' ), 'changed'=>! empty( $done['changed'] ) ) ); } /** Views kept in one browser before 0.36 move to the account in one request; invalid, repeated or extra ones stay out. */ public static function import( array $data ): array { $kind = self::kind( $data ); if ( $kind === 'orders' ) { throw new \InvalidArgumentException( I18n::t( 'Choose valid filters.' ) ); } $views = is_array( $data['views'] ?? null ) ? array_slice( array_values( $data['views'] ), 0, self::LIMIT ) : array(); $user = get_current_user_id(); $done = Store::idempotency( hash( 'sha256', 'order-view-import:' . $kind . ':' . $user . ':' . self::key( $data ) ), static function () use ( $kind, $views, $user ) { return Store::lock( 'order-views:' . $kind . ':user:' . $user, static function () use ( $kind, $views, $user ) { $rows = self::rows( $kind, 'mine' ); $moved = 0; foreach ( $views as $view ) { if ( ! is_array( $view ) || count( $rows ) >= self::LIMIT ) { continue; } $name = trim( Store::text( $view, 'name' ) ); if ( $name === '' || mb_strlen( $name ) > 64 || ! is_array( $view['filters'] ?? null ) || array_diff_key( $view['filters'], self::defaults( $kind ) ) ) { continue; } $dropped = array(); $filters = self::filters( $kind, $view['filters'], $dropped ); if ( $dropped ) { continue; } foreach ( $rows as $row ) { if ( mb_strtolower( $row['name'] ) === mb_strtolower( $name ) ) { continue 2; } } $rows[] = array( 'id'=>wp_generate_uuid4(), 'name'=>$name, 'filters'=>$filters, 'created_by'=>$user, 'updated_at'=>gmdate( 'c' ), 'updated_by'=>$user ); ++$moved; } if ( $moved ) { self::write( $kind, 'mine', $rows ); } return array( 'moved'=>$moved ); } ); } ); return array_merge( self::read( array( 'kind'=>$kind ) ), array( 'moved'=>(int) ( $done['moved'] ?? 0 ) ) ); } public static function remove( array $data ): array { $kind = self::kind( $data ); $scope = self::scope( $data ); if ( empty( $data['confirmed'] ) ) { throw new \InvalidArgumentException( I18n::t( 'Confirm removal of this saved view.' ) ); } $id = Store::text( $data, 'id' ); Store::idempotency( hash( 'sha256', 'order-view-remove:' . $kind . ':' . get_current_user_id() . ':' . self::key( $data ) ), static function () use ( $kind, $scope, $data, $id ) { return self::locked( $kind, $scope, $data, static function ( array $rows ) use ( $kind, $scope, $id ) { $left = array_values( array_filter( $rows, static fn( $row ) => $row['id'] !== $id ) ); if ( $id === '' || count( $left ) === count( $rows ) ) { throw new \InvalidArgumentException( I18n::t( 'The saved view could not be found.' ) ); } self::write( $kind, $scope, $left ); if ( $scope === 'team' ) { Store::audit( 'settings', 0, self::event( $kind, 'removed' ), array( 'count'=>count( $left ), 'kind'=>$kind ) ); } return array( 'removed'=>$id ); } ); } ); return self::read( array( 'kind'=>$kind ) ); } }