diff --git a/.gitignore b/.gitignore index 0bb60881..e3e177e0 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,4 @@ storage/backups cron-fail.log .DS_Store +.phpunit.cache/ diff --git a/app/Actions/Slack/AddToUserGroup.php b/app/Actions/Slack/AddToUserGroup.php index ebf43f86..9a5e8d28 100644 --- a/app/Actions/Slack/AddToUserGroup.php +++ b/app/Actions/Slack/AddToUserGroup.php @@ -28,7 +28,7 @@ public function execute($customerId, $userGroupHandle) throw_if(is_null($slackId), "Customer $customerId cannot be added to usergroup $userGroupHandle!"); - $userGroup = $this->slackApi->usergroups->byName($userGroupHandle); + $userGroup = $this->slackApi->usergroups->byNameOrId($userGroupHandle); throw_if(is_null($userGroup), "Couldn't find usergroup for $userGroupHandle"); diff --git a/app/Actions/Slack/RemoveFromUserGroup.php b/app/Actions/Slack/RemoveFromUserGroup.php index 0edccf03..b9f5df39 100644 --- a/app/Actions/Slack/RemoveFromUserGroup.php +++ b/app/Actions/Slack/RemoveFromUserGroup.php @@ -28,7 +28,7 @@ public function execute($customerId, $userGroupHandle) throw_if(is_null($slackId), "Customer $customerId cannot be removed from usergroup $userGroupHandle!"); - $usergroup = $this->slackApi->usergroups->byName($userGroupHandle); + $usergroup = $this->slackApi->usergroups->byNameOrId($userGroupHandle); throw_if(is_null($usergroup), "Couldn't find usergroup for $userGroupHandle"); diff --git a/app/External/Slack/Api/UsergroupsApi.php b/app/External/Slack/Api/UsergroupsApi.php index 04312203..e8b6ab29 100644 --- a/app/External/Slack/Api/UsergroupsApi.php +++ b/app/External/Slack/Api/UsergroupsApi.php @@ -33,10 +33,11 @@ public function __get(string $name) /** * Note: Helper method, not official slack API */ - public function byName($handle): array + public function byNameOrId($identifier): array|null { return $this->list() - ->firstWhere('handle', $handle); + ->where(fn($userGroup) => $userGroup['handle'] == $identifier || $userGroup['id'] == $identifier) + ->first(); } public function list(): Collection diff --git a/app/External/Slack/Modals/ManageVolunteerGroups.php b/app/External/Slack/Modals/ManageVolunteerGroups.php index 3f73e437..bbfb4ecf 100644 --- a/app/External/Slack/Modals/ManageVolunteerGroups.php +++ b/app/External/Slack/Modals/ManageVolunteerGroups.php @@ -5,18 +5,22 @@ use App\External\Slack\SlackOptions; use App\Http\Requests\SlackRequest; use App\Models\VolunteerGroup; +use App\External\Slack\BlockActions\RespondsToBlockActions; use SlackPhp\BlockKit\Kit; use SlackPhp\BlockKit\Surfaces\Modal; class ManageVolunteerGroups implements ModalInterface { use ModalTrait; + use RespondsToBlockActions; private Modal $modalView; - private const GROUP = 'group'; + private const GROUP_DROPDOWN = 'group-dropdown'; + private const CREATE_NEW_GROUP = 'create-new-group'; - private const CREATE_NEW = 'create-new'; + private const GROUP_NAME = 'group-name'; + private const PLAN_ID = 'plan-id'; public function __construct() { @@ -31,19 +35,42 @@ public function __construct() public function initialView() { $this->modalView->newInput() - ->blockId(self::GROUP) + ->blockId(self::GROUP_DROPDOWN) ->label('Group') - ->newSelectMenu(self::GROUP) + ->newSelectMenu(self::GROUP_DROPDOWN) ->forExternalOptions() - ->placeholder('Select Volunteer Group'); + ->minQueryLength(0) + ->placeholder("Select Volunteer Group"); $this->modalView->divider(); $this->modalView->newSection() - ->blockId(self::CREATE_NEW) - ->mrkdwnText('Or, create a new volunteer group:') - ->newButtonAccessory(self::CREATE_NEW) - ->text('Create New'); + ->blockId(self::CREATE_NEW_GROUP) + ->mrkdwnText("Or, create a new volunteer group:") + ->newButtonAccessory(self::CREATE_NEW_GROUP) + ->text("Create New"); + } + + public function createForm() + { + $this->modalView->newInput() + ->blockId(self::GROUP_NAME) + ->label('Group name') + ->newTextInput(self::GROUP_NAME) + ->placeholder("Name"); + + $this->modalView->newInput() + ->blockId(self::PLAN_ID) + ->label('Plan ID that grants access') + ->newSelectMenu(self::PLAN_ID) + ->forExternalOptions() + ->minQueryLength(0); + } + + public function groupForm() + { + $this->modalView->newSection() + ->mrkdwnText("Sorry, nothing to see here"); } public static function callbackId(): string @@ -60,9 +87,13 @@ public static function getOptions(SlackRequest $request) { $options = SlackOptions::new(); - /** @var VolunteerGroup $volunteerGroup */ - foreach (VolunteerGroup::all() as $volunteerGroup) { - $options[$volunteerGroup->id] = $volunteerGroup->name; + $blockId = $request->payload()['block_id']; + + if($blockId == self::GROUP_DROPDOWN) { + /** @var VolunteerGroup $volunteerGroup */ + foreach (VolunteerGroup::all() as $volunteerGroup) { + $options[$volunteerGroup->id] = $volunteerGroup->name; + } } return $options; @@ -72,4 +103,27 @@ public function jsonSerialize() { return $this->modalView->jsonSerialize(); } + + public static function getBlockActions(): array + { + return [ + self::blockActionUpdate(self::GROUP_DROPDOWN), + self::blockActionUpdate(self::CREATE_NEW_GROUP), + ]; + } + + static function onBlockAction(SlackRequest $request) + { + $modal = new ManageVolunteerGroups(); + + $action = $request->action(); + + if($action == self::CREATE_NEW_GROUP) { + $modal->createForm(); + } elseif ($action == self::GROUP_DROPDOWN) { + $modal->groupForm(); + } + + return $modal->updateViaApi($request); + } } diff --git a/app/FeatureFlags.php b/app/FeatureFlags.php index 771d6324..27f1506d 100644 --- a/app/FeatureFlags.php +++ b/app/FeatureFlags.php @@ -8,6 +8,14 @@ */ class FeatureFlags { + /** + * With this feature flag off, things that would invite people to a specific slack channel will still be handled in + * the specific locations they've been hardcoded. For example, the SlackReactor might use the TrainableEquipment + * table to manage who gets invited to a channel for a user or for a trainer. With it on, that code should be + * prevented from running and instead the VolunteerGroupsReactor should handle it. + */ + public const USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS = 'use-volunteer-groups-for-slack-channels'; + // The following flags are no longer used but are kept here as a reference to make sure they're not re-used. // When removing a feature, mark it as private so it cannot be used anywhere else and tag it as deprecated. diff --git a/app/Http/Requests/SlackRequest.php b/app/Http/Requests/SlackRequest.php index c54139b4..38154ee1 100644 --- a/app/Http/Requests/SlackRequest.php +++ b/app/Http/Requests/SlackRequest.php @@ -80,4 +80,20 @@ public function getSlackId() return $data['user']['id']; } + + public function action() + { + $payload = $this->payload(); + if(! array_key_exists('actions', $payload)) { + return null; + } + + $actions = $payload['actions']; + + if(empty($actions)) { + return null; + } + + return current($actions); + } } diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 454cdc38..60692c02 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -72,7 +72,10 @@ public function memberships() public function hasMembership($planId): bool { - return $this->memberships->where('plan_id', $planId)->count() > 0; // TODO Where status is active + return $this->memberships + ->where('plan_id', $planId) + ->where('status', 'active') + ->isNotEmpty(); } public function subscriptions() diff --git a/app/Models/UserMembership.php b/app/Models/UserMembership.php index b782f57e..c80c759a 100644 --- a/app/Models/UserMembership.php +++ b/app/Models/UserMembership.php @@ -12,6 +12,8 @@ * @property int customer_id * @property int plan_id * @property string status + * + * @property Customer $customer */ class UserMembership extends Model { diff --git a/app/Models/VolunteerGroupChannel.php b/app/Models/VolunteerGroupChannel.php index 461cf597..e85067f0 100644 --- a/app/Models/VolunteerGroupChannel.php +++ b/app/Models/VolunteerGroupChannel.php @@ -2,8 +2,16 @@ namespace App\Models; +use App\FeatureFlags; +use App\VolunteerGroupChannels\ChannelInterface; +use App\VolunteerGroupChannels\GitHubTeam; +use App\VolunteerGroupChannels\GoogleGroup; +use App\VolunteerGroupChannels\SlackChannel; +use App\VolunteerGroupChannels\SlackUserGroup; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use ReflectionClass; +use YlsIdeas\FeatureFlags\Facades\Features; /** * @property int volunteer_group_id @@ -14,11 +22,24 @@ class VolunteerGroupChannel extends Model { use HasFactory; - public const SLACK_CHANNEL_ID = 'slack_channel_id'; - - public const SLACK_USERGROUP_ID = 'slack_usergroup_id'; + private ChannelInterface|null $channelInstance = null; + /* + * For new channels, add the constant here in the form of __ alphabetically. + * That's not a hard and fast rule, but it groups parts in the same system, let's us know what in that system this + * channel is for, and what the identifier we're using to refer to it is. For example, SLACK_USER_GROUP_ID is the + * system "Slack", the object is "user group", and we're referring to the user group by its built in id, vs + * referring to it by name which can change in Slack's system. Once the field is used, the value cannot change since + * that's what's used in the database. The const key can change, however. + * + * Don't forget to make a class that implements ChannelInterface. Channels should be idempotent meaning calling add + * when someone is already in a channel or remove when they're not should not do anything. Channels are also + * required to queue add and remove + */ + public const GITHUB_TEAM_NAME = 'github_team_name'; public const GOOGLE_GROUP_EMAIL = 'google_group_email'; + public const SLACK_CHANNEL_ID = 'slack_channel_id'; + public const SLACK_USER_GROUP_ID = 'slack_user_group_id'; protected $fillable = [ 'volunteer_group_id', @@ -30,4 +51,56 @@ public function group() { return $this->belongsTo(VolunteerGroup::class); } + + protected function getChannel(): ChannelInterface + { + if (is_null($this->channelInstance)) { + $className = collect(get_declared_classes()) + ->filter(fn($name) => str_starts_with($name, 'App\\VolunteerGroupChannels')) + ->map(fn($name) => new ReflectionClass($name)) + ->filter(fn($reflect) => $reflect->implementsInterface(ChannelInterface::class)) + ->filter(fn($reflect) => $reflect->getMethod('getTypeKey')->invoke(null) == $this->type) + ->map(fn($reflect) => $reflect->getName()) + ->first(); + + if (is_null($className)) { + throw new \Exception("Unknown channel type: {$this->type}"); + } + + $this->channelInstance = app($className); + } + + return $this->channelInstance; + } + + public function add(Customer $customer): void + { + if (! Features::accessible(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS) && $this->type == self::SLACK_CHANNEL_ID) { + return; // If we're a slack channel, but the feature flag isn't enabled, don't add the customer this way. + } + + try { + $this->getChannel()->add($customer, $this->value); + } catch (\Exception $exception) { + report($exception); + } + } + + public function remove(Customer $customer): void + { + if (! Features::accessible(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS) && $this->type == self::SLACK_CHANNEL_ID) { + return; // If we're a slack channel, but the feature flag isn't enabled, don't remove the customer this way. + } + + try { + $this->getChannel()->remove($customer, $this->value); + } catch (\Exception $exception) { + report($exception); + } + } + + public function removeOnMembershipLost(): bool + { + return $this->getChannel()::removeOnMembershipLost(); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f293a281..e152229c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,8 +4,7 @@ use App\External\QuickBooks\QuickBooksAuthSettings; use App\Http\Requests\SlackRequest; -use Illuminate\Cache\RateLimiting\Limit; -use Illuminate\Support\Facades\RateLimiter; +use App\VolunteerGroupChannels\ChannelInterface; use Illuminate\Support\ServiceProvider; use QuickBooksOnline\API\Core\OAuth\OAuth2\OAuth2LoginHelper; use QuickBooksOnline\API\DataService\DataService; @@ -29,6 +28,11 @@ public function boot(): void return SlackRequest::createFrom($app['request'], $request); }); + // Only create one instance of each implementation per request cycle + $this->app->afterResolving(ChannelInterface::class, function ($resolved, $app) { + $app->instance(get_class($resolved), $resolved); + }); + $this->app->bind(StripeClient::class, function () { return new StripeClient(['api_key' => config('denhac.stripe.stripe_api_key')]); }); diff --git a/app/Reactors/GithubMembershipReactor.php b/app/Reactors/GithubMembershipReactor.php index 6a8ca978..48c65c5c 100644 --- a/app/Reactors/GithubMembershipReactor.php +++ b/app/Reactors/GithubMembershipReactor.php @@ -17,6 +17,8 @@ final class GithubMembershipReactor extends Reactor public function onGithubUsernameUpdated(GitHubUsernameUpdated $event) { + // TODO Make this handle adding/removing all teams based on the volunteer groups for this customer + if (! is_null($event->oldUsername)) { /** @var GitHubApi $gitHubApi */ $gitHubApi = app(GitHubApi::class); diff --git a/app/Reactors/SlackReactor.php b/app/Reactors/SlackReactor.php index c2e1792d..b5ff9364 100644 --- a/app/Reactors/SlackReactor.php +++ b/app/Reactors/SlackReactor.php @@ -5,6 +5,7 @@ use App\Actions\Slack\AddToChannel; use App\Actions\Slack\SendMessage; use App\External\Slack\SlackProfileFields; +use App\FeatureFlags; use App\Jobs\DemoteMemberToPublicOnlyMemberInSlack; use App\Jobs\InviteCustomerNeedIdCheckOnlyMemberInSlack; use App\Jobs\MakeCustomerRegularMemberInSlack; @@ -18,6 +19,7 @@ use App\StorableEvents\WooCommerce\CustomerCreated; use App\StorableEvents\WooCommerce\UserMembershipCreated; use Illuminate\Support\Collection; +use YlsIdeas\FeatureFlags\Facades\Features; use function ltrim; use SlackPhp\BlockKit\Surfaces\Message; use Spatie\EventSourcing\EventHandlers\Reactors\Reactor; @@ -56,26 +58,28 @@ public function onUserMembershipCreated(UserMembershipCreated $event) $customerId = $event->membership['customer_id']; $plan_id = $event->membership['plan_id']; - /** @var Collection $userSlackIds */ - $userSlackIds = TrainableEquipment::select('user_slack_id') - ->where('user_plan_id', $plan_id) - ->get() - ->map(fn ($row) => $row['user_slack_id']); + if(! Features::accessible(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS)) { + /** @var Collection $userSlackIds */ + $userSlackIds = TrainableEquipment::select('user_slack_id') + ->where('user_plan_id', $plan_id) + ->get() + ->map(fn($row) => $row['user_slack_id']); - /** @var Collection $trainerSlackIds */ - $trainerSlackIds = TrainableEquipment::select('trainer_slack_id') - ->where('trainer_plan_id', $plan_id) - ->get() - ->map(fn ($row) => $row['trainer_slack_id']); + /** @var Collection $trainerSlackIds */ + $trainerSlackIds = TrainableEquipment::select('trainer_slack_id') + ->where('trainer_plan_id', $plan_id) + ->get() + ->map(fn($row) => $row['trainer_slack_id']); - $slackIds = collect($userSlackIds->union($trainerSlackIds))->unique(); + $slackIds = collect($userSlackIds->union($trainerSlackIds))->unique(); - foreach ($slackIds as $slackId) { - if (is_null($slackId)) { - continue; - } + foreach ($slackIds as $slackId) { + if (is_null($slackId)) { + continue; + } - AddToChannel::queue()->execute($customerId, $slackId); + AddToChannel::queue()->execute($customerId, $slackId); + } } } diff --git a/app/Reactors/VolunteerGroupsReactor.php b/app/Reactors/VolunteerGroupsReactor.php new file mode 100644 index 00000000..bf6cb8a6 --- /dev/null +++ b/app/Reactors/VolunteerGroupsReactor.php @@ -0,0 +1,91 @@ +membership['plan_id']; + $customerId = $event->membership['customer_id']; + $customer = Customer::find($customerId); + + $volunteerGroup = VolunteerGroup::wherePlanId($planId)->with('channels')->first(); + + if(is_null($volunteerGroup)) { + return; + } + + /** @var VolunteerGroup $volunteerGroup */ + $volunteerGroup->channels()->each(fn($ch) => $ch->add($customer)); + } + + public function onUserMembershipUpdated(UserMembershipUpdated $event): void + { + $status = $event->membership['status']; + $planId = $event->membership['plan_id']; + $customerId = $event->membership['customer_id']; + $customer = Customer::find($customerId); + + $volunteerGroup = VolunteerGroup::wherePlanId($planId)->with('channels')->first(); + if(is_null($volunteerGroup)) { + return; + } + + /** @var VolunteerGroup $volunteerGroup */ + + if (in_array($status, ['paused', 'expired', 'cancelled'])) { + $volunteerGroup->channels()->each(fn($ch) => $ch->remove($customer)); + } elseif (in_array($status, ['active', 'free_trial', 'pending'])) { + $volunteerGroup->channels()->each(fn($ch) => $ch->add($customer)); + } + } + + public function onUserMembershipDeleted(UserMembershipDeleted $event) + { + $userMembershipId = $event->membership['id']; + /** @var UserMembership $userMembership */ + // The UserMembership projector soft deleted this, so to access the plan id we need to use withTrashed() + $userMembership = UserMembership::withTrashed()->find($userMembershipId); + $customer = $userMembership->customer; + + $volunteerGroup = VolunteerGroup::wherePlanId($userMembership->plan_id)->with('channels')->first(); + if(is_null($volunteerGroup)) { + return; + } + + /** @var VolunteerGroup $volunteerGroup */ + $volunteerGroup->channels()->each(fn($ch) => $ch->remove($customer)); + } + + public function onMembershipDeactivated(MembershipDeactivated $event): void + { + $customerId = $event->customerId; + /** @var Customer $customer */ + $customer = Customer::find($customerId); + + $userMembershipIds = $customer->memberships->map(fn($um) => $um->plan_id)->toArray(); + $volunteerGroups = VolunteerGroup::whereIn('plan_id', $userMembershipIds)->with('channels')->get(); + + foreach ($volunteerGroups as $volunteerGroup) { + /** @var VolunteerGroup $volunteerGroup */ + + foreach ($volunteerGroup->channels as $channel) { + /** @var VolunteerGroupChannel $channel */ + if($channel->removeOnMembershipLost()) { + $channel->remove($customer); + } + } + } + } +} diff --git a/app/VolunteerGroupChannels/ChannelInterface.php b/app/VolunteerGroupChannels/ChannelInterface.php new file mode 100644 index 00000000..202d830d --- /dev/null +++ b/app/VolunteerGroupChannels/ChannelInterface.php @@ -0,0 +1,17 @@ +github_username)) { + return; + } + + AddToGitHubTeam::queue()->execute($customer->github_username, $channelValue); + } + + function remove(Customer $customer, string $channelValue): void + { + if (is_null($customer->github_username)) { + return; + } + + RemoveFromGitHubTeam::queue()->execute($customer->github_username, $channelValue); + } + + static function getTypeKey(): string + { + return VolunteerGroupChannel::GITHUB_TEAM_NAME; + } + + static function removeOnMembershipLost(): bool + { + // Removing from the organization may be sufficient to remove from all teams + // TODO Actually remove from the organization instead of from the team + return false; + } +} diff --git a/app/VolunteerGroupChannels/GoogleGroup.php b/app/VolunteerGroupChannels/GoogleGroup.php new file mode 100644 index 00000000..8011af65 --- /dev/null +++ b/app/VolunteerGroupChannels/GoogleGroup.php @@ -0,0 +1,35 @@ +execute($customer->email, $channelValue); + } + + function remove(Customer $customer, string $channelValue): void + { + RemoveFromGroup::queue()->execute($customer->email, $channelValue); + } + + static function getTypeKey(): string + { + return VolunteerGroupChannel::GOOGLE_GROUP_EMAIL; + } + + static function removeOnMembershipLost(): bool + { + // There's no overall structure to Google Groups that someone can be removed from. We have to handle each group + // individually. + return true; + } +} diff --git a/app/VolunteerGroupChannels/SlackChannel.php b/app/VolunteerGroupChannels/SlackChannel.php new file mode 100644 index 00000000..19c78013 --- /dev/null +++ b/app/VolunteerGroupChannels/SlackChannel.php @@ -0,0 +1,33 @@ +execute($customer->slack_id, $channelValue); + } + + function remove(Customer $customer, string $channelValue): void + { + RemoveFromChannel::queue()->execute($customer->slack_id, $channelValue); + } + + static function getTypeKey(): string + { + return VolunteerGroupChannel::SLACK_CHANNEL_ID; + } + + static function removeOnMembershipLost(): bool + { + // User is demoted to single channel guest, no need to remove them here from individual channels + return false; + } +} diff --git a/app/VolunteerGroupChannels/SlackUserGroup.php b/app/VolunteerGroupChannels/SlackUserGroup.php new file mode 100644 index 00000000..6bea3f61 --- /dev/null +++ b/app/VolunteerGroupChannels/SlackUserGroup.php @@ -0,0 +1,33 @@ +execute($customer->slack_id, $channelValue); + } + + function remove(Customer $customer, string $channelValue): void + { + RemoveFromUserGroup::queue()->execute($customer->slack_id, $channelValue); + } + + static function getTypeKey(): string + { + return VolunteerGroupChannel::SLACK_USER_GROUP_ID; + } + + static function removeOnMembershipLost(): bool + { + // Demoting someone to a single channel guest can still keep them in a user group, so we remove them manually + return true; + } +} diff --git a/config/features.php b/config/features.php index 9b31d125..78f11e95 100644 --- a/config/features.php +++ b/config/features.php @@ -3,56 +3,49 @@ return [ /* |-------------------------------------------------------------------------- - | default + | Pipeline |-------------------------------------------------------------------------- | - | The repository to use for establishing a feature's on/off state. + | The pipeline for the feature to travel through. | */ - 'default' => 'database', + 'pipeline' => ['database'], /* |-------------------------------------------------------------------------- - | Config Feature Switches + | Gateways |-------------------------------------------------------------------------- | - | This is a set of features to load into the config features repository. + | Configures the different gateway options | */ - 'feature' => [ - // 'login' => true, - ], - - /* - |-------------------------------------------------------------------------- - | Repositories - |-------------------------------------------------------------------------- - | - | Configures the different repository options - | - */ - - 'repositories' => [ + 'gateways' => [ + 'in_memory' => [ + 'file' => env('FEATURE_FLAG_IN_MEMORY_FILE', '.features.php'), + 'driver' => 'in_memory', + ], 'database' => [ - 'table' => 'features', + 'driver' => 'database', + 'cache' => [ + 'ttl' => 600, + ], + 'connection' => env('FEATURE_FLAG_DATABASE_CONNECTION'), + 'table' => env('FEATURE_FLAG_DATABASE_TABLE', 'features'), ], - 'config' => [ - 'key' => 'features.feature', + 'gate' => [ + 'driver' => 'gate', + 'gate' => env('FEATURE_FLAG_GATE_GATE', 'feature'), + 'guard' => env('FEATURE_FLAG_GATE_GUARD'), + 'cache' => [ + 'ttl' => 600, + ], ], 'redis' => [ - 'prefix' => 'features', - 'connection' => 'default', - ], - 'chain' => [ - 'drivers' => [ - 'config', - 'redis', - 'database', - ], - 'store' => 'database', - 'update_on_resolve' => true, + 'driver' => 'redis', + 'prefix' => env('FEATURE_FLAG_REDIS_PREFIX', 'features'), + 'connection' => env('FEATURE_FLAG_REDIS_CONNECTION', 'default'), ], ], ]; diff --git a/database/migrations/2022_08_05_032823_create_volunteer_groups_table.php b/database/migrations/2022_08_05_032823_create_volunteer_groups_table.php index 10d868e1..7ac3006c 100644 --- a/database/migrations/2022_08_05_032823_create_volunteer_groups_table.php +++ b/database/migrations/2022_08_05_032823_create_volunteer_groups_table.php @@ -15,8 +15,8 @@ public function up(): void $table->id(); $table->timestamps(); $table->string('name'); - $table->unsignedInteger('plan_id'); - $table->unsignedInteger('max_people'); + $table->unsignedInteger('plan_id')->unique(); + $table->unsignedInteger('max_people')->nullable(); }); } diff --git a/phpunit.xml b/phpunit.xml index 669ae4fa..5f2c2ba2 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,10 +1,6 @@ - - - - ./app - - + + ./tests/Unit @@ -24,4 +20,9 @@ + + + ./app + + diff --git a/tests/TestCase.php b/tests/TestCase.php index ae954df8..83a4e1f2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -13,6 +13,7 @@ use Tests\Helpers\Wordpress\CustomerBuilder; use Tests\Helpers\Wordpress\SubscriptionBuilder; use Tests\Helpers\Wordpress\UserMembershipBuilder; +use YlsIdeas\FeatureFlags\Facades\Features; /** * Class TestCase @@ -74,7 +75,7 @@ public function waiver(): WaiverBuilder return new WaiverBuilder(); } - public function subscriptionStatuses(): array + public static function subscriptionStatuses(): array { return [ 'Pending' => ['pending'], @@ -89,14 +90,29 @@ public function subscriptionStatuses(): array ]; } - public function userMembershipStatuses(): array + public static function userMembershipStatuses(): array + { + return array_merge([ + 'Delayed' => ['delayed'], + 'Complimentary' => ['complimentary'], + ], + self::activeUserMembershipStatuses(), + self::inactiveUserMembershipStatuses(), + ); + } + + public static function activeUserMembershipStatuses(): array { return [ 'Active' => ['active'], 'Free Trial' => ['free_trial'], - 'Delayed' => ['delayed'], - 'Complimentary' => ['complimentary'], 'Pending Cancellation' => ['pending'], + ]; + } + + public static function inactiveUserMembershipStatuses(): array + { + return [ 'Paused' => ['paused'], 'Expired' => ['expired'], 'Cancelled' => ['cancelled'], @@ -154,4 +170,14 @@ private function filteredEventHandlers(Collection $eventHandlers, $instances): a ->reject(null) ->all(); } + + protected function turnOn($featureFlag): void + { + Features::turnOn('database', $featureFlag); + } + + protected function turnOff($featureFlag): void + { + Features::turnOff('database', $featureFlag); + } } diff --git a/tests/Unit/Reactors/SlackReactorTest.php b/tests/Unit/Reactors/SlackReactorTest.php index c7ba1645..5a84a215 100644 --- a/tests/Unit/Reactors/SlackReactorTest.php +++ b/tests/Unit/Reactors/SlackReactorTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Reactors; use App\Actions\Slack\AddToChannel; +use App\FeatureFlags; use App\Jobs\DemoteMemberToPublicOnlyMemberInSlack; use App\Jobs\InviteCustomerNeedIdCheckOnlyMemberInSlack; use App\Jobs\MakeCustomerRegularMemberInSlack; @@ -62,6 +63,8 @@ function (MakeCustomerRegularMemberInSlack $job) use ($customerId) { /** @test */ public function being_added_to_trainable_equipment_as_user_adds_to_slack_channel(): void { + $this->turnOff(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + $planId = 1234; $slackId = 'C1345348'; $customerId = 27; @@ -87,6 +90,8 @@ public function being_added_to_trainable_equipment_as_user_adds_to_slack_channel /** @test */ public function being_added_to_trainable_equipment_as_trainer_adds_to_slack_channel(): void { + $this->turnOff(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + $planId = 1234; $slackId = 'C1345348'; $customerId = 27; @@ -112,6 +117,8 @@ public function being_added_to_trainable_equipment_as_trainer_adds_to_slack_chan /** @test */ public function being_added_to_trainable_equipment_as_user_does_not_add_to_slack_channel_with_null_channel(): void { + $this->turnOff(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + $planId = 1234; $customerId = 27; @@ -134,13 +141,69 @@ public function being_added_to_trainable_equipment_as_user_does_not_add_to_slack /** @test */ public function being_added_to_trainable_equipment_as_trainer_does_not_add_to_slack_channel_with_null_channel(): void { + $this->turnOff(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + + $planId = 1234; + $customerId = 27; + + TrainableEquipment::create([ + 'name' => 'Test', + 'user_plan_id' => 5678, + 'trainer_plan_id' => $planId, + ]); + + $userMembership = $this->userMembership() + ->customer($customerId) + ->status('active') + ->plan($planId); + + event(new UserMembershipCreated($userMembership)); + + $this->assertAction(AddToChannel::class)->never(); + } + + + + /** @test */ + public function ff_on_being_added_to_trainable_equipment_as_user_does_not_add_to_slack_channel(): void + { + $this->turnOn(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + $planId = 1234; + $slackId = 'C1345348'; + $customerId = 27; + + TrainableEquipment::create([ + 'name' => 'Test', + 'user_plan_id' => $planId, + 'user_slack_id' => $slackId, + 'trainer_plan_id' => 5678, + ]); + + $userMembership = $this->userMembership() + ->customer($customerId) + ->status('active') + ->plan($planId); + + event(new UserMembershipCreated($userMembership)); + + $this->assertAction(AddToChannel::class)->never(); + } + + /** @test */ + public function ff_on_being_added_to_trainable_equipment_as_trainer_does_not_add_to_slack_channel(): void + { + $this->turnOn(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + + $planId = 1234; + $slackId = 'C1345348'; $customerId = 27; TrainableEquipment::create([ 'name' => 'Test', 'user_plan_id' => 5678, 'trainer_plan_id' => $planId, + 'trainer_slack_id' => $slackId, ]); $userMembership = $this->userMembership() diff --git a/tests/Unit/Reactors/VolunteerGroupsReactorTest.php b/tests/Unit/Reactors/VolunteerGroupsReactorTest.php new file mode 100644 index 00000000..2ec6bd31 --- /dev/null +++ b/tests/Unit/Reactors/VolunteerGroupsReactorTest.php @@ -0,0 +1,252 @@ +withOnlyEventHandlerType(VolunteerGroupsReactor::class); + + Queue::fake(); + + $this->customerBuilder = $this->customer(); + $this->customer = Customer::create([ + 'id' => 1, + 'first_name' => $this->faker->firstName(), + 'last_name' => $this->faker->lastName(), + 'username' => $this->faker->userName(), + 'email' => $this->faker->email(), + 'member' => true, + ]); + + $this->slackChannelSpy = spy(SlackChannel::class); + $this->slackChannelSpy->allows('removeOnMembershipLost')->andReturn(false); + app()->instance(SlackChannel::class, $this->slackChannelSpy); + + $this->slackUserGroupSpy = spy(SlackUserGroup::class); + $this->slackUserGroupSpy->allows('removeOnMembershipLost')->andReturn(true); + app()->instance(SlackUserGroup::class, $this->slackUserGroupSpy); + + /** @var VolunteerGroup $volunteerGroupA */ + $volunteerGroupA = VolunteerGroup::create([ + 'name' => 'Group for Plan A', + 'plan_id' => self::TEST_PLAN_A_ID, + ]); + + VolunteerGroupChannel::create([ + 'volunteer_group_id' => $volunteerGroupA->id, + 'type' => VolunteerGroupChannel::SLACK_CHANNEL_ID, + 'value' => $this->faker->uuid(), + ]); + + $this->userMembershipA = $this->userMembership()->plan(self::TEST_PLAN_A_ID)->customer($this->customer); + + /** @var VolunteerGroup $volunteerGroupB */ + $volunteerGroupB = VolunteerGroup::create([ + 'name' => 'Group for Plan B', + 'plan_id' => self::TEST_PLAN_B_ID, + ]); + + VolunteerGroupChannel::create([ + 'volunteer_group_id' => $volunteerGroupB->id, + 'type' => VolunteerGroupChannel::SLACK_CHANNEL_ID, + 'value' => $this->faker->uuid(), + ]); + + /** @var VolunteerGroup $volunteerGroupC */ + $volunteerGroupC = VolunteerGroup::create([ + 'name' => 'Group for Plan C', + 'plan_id' => self::TEST_PLAN_C_ID, + ]); + + VolunteerGroupChannel::create([ + 'volunteer_group_id' => $volunteerGroupC->id, + 'type' => VolunteerGroupChannel::SLACK_USER_GROUP_ID, + 'value' => $this->faker->uuid(), + ]); + + // These actually get inserted in the database because they're pulled from the customer model relation + UserMembership::create([ + 'id' => 1, + 'plan_id' => self::TEST_PLAN_B_ID, + 'status' => 'active', + 'customer_id' => $this->customer->id, + ]); + UserMembership::create([ + 'id' => 2, + 'plan_id' => self::TEST_PLAN_C_ID, + 'status' => 'active', + 'customer_id' => $this->customer->id, + ]); + } + + protected function verifyNoInteraction(MockInterface $spy): void + { + $spy->shouldNotHaveReceived('add'); + $spy->shouldNotHaveReceived('remove'); + } + + protected function verifyAddWasCalled(MockInterface $spy): void + { + $spy->shouldHaveReceived('add')->withArgs(function ($customer) { + return $this->customer->id == $customer->id; + }); + } + + protected function verifyRemoveWasCalled(MockInterface $spy): void + { + $spy->shouldHaveReceived('remove')->withArgs(function ($customer) { + return $this->customer->id == $customer->id; + }); + } + + /** @test */ + public function ff_off_slack_channel_is_not_used_on_membership_created(): void + { + $this->turnOff(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + + event(new UserMembershipCreated($this->userMembershipA)); + + $this->verifyNoInteraction($this->slackChannelSpy); + } + + /** @test */ + public function ff_on_slack_channel_is_used_on_membership_created(): void + { + $this->turnOn(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + + event(new UserMembershipCreated($this->userMembershipA)); + + $this->verifyAddWasCalled($this->slackChannelSpy); + } + + /** + * @test + * @dataProvider inactiveUserMembershipStatuses + */ + public function ff_off_slack_channel_is_not_used_on_membership_update_to_inactive($status): void + { + $this->turnOff(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + + $this->userMembershipA->status($status); + + event(new UserMembershipUpdated($this->userMembershipA)); + + $this->verifyNoInteraction($this->slackChannelSpy); + } + + /** + * @test + * @dataProvider inactiveUserMembershipStatuses + */ + public function ff_on_slack_channel_is_used_on_membership_update_to_inactive($status): void + { + $this->turnOn(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + + $this->userMembershipA->status($status); + + event(new UserMembershipUpdated($this->userMembershipA)); + + $this->verifyRemoveWasCalled($this->slackChannelSpy); + } + + /** + * @test + * @dataProvider activeUserMembershipStatuses + */ + public function ff_off_slack_channel_is_not_used_on_membership_update_to_active($status): void + { + $this->turnOff(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + + $this->userMembershipA->status($status); + + event(new UserMembershipUpdated($this->userMembershipA)); + + $this->verifyNoInteraction($this->slackChannelSpy); + } + + /** + * @test + * @dataProvider activeUserMembershipStatuses + */ + public function ff_on_slack_channel_is_used_on_membership_update_to_active($status): void + { + $this->turnOn(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + + $this->userMembershipA->status($status); + + event(new UserMembershipUpdated($this->userMembershipA)); + + $this->verifyAddWasCalled($this->slackChannelSpy); + } + + /** @test */ + public function ff_off_slack_channel_is_not_used_on_membership_deleted(): void + { + $this->turnOff(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + + event(new UserMembershipDeleted($this->userMembershipA)); + + $this->verifyNoInteraction($this->slackChannelSpy); + } + + /** @test */ + public function ff_on_slack_channel_is_used_on_membership_deleted(): void + { + $this->turnOn(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + + event(new UserMembershipDeleted($this->userMembershipA)); + + $this->verifyRemoveWasCalled($this->slackChannelSpy); + } + + /** @test */ + public function on_membership_deactivated_all_channels_that_should_be_removed_get_removed(): void + { + // This isn't a feature flag test, our slack channel just happens to be gated. Remove the flag when done, keep the test + $this->turnOn(FeatureFlags::USE_VOLUNTEER_GROUPS_FOR_SLACK_CHANNELS); + + event(new MembershipDeactivated($this->customer->id)); + + $this->verifyNoInteraction($this->slackChannelSpy); // removeOnMembershipLost = false + $this->verifyRemoveWasCalled($this->slackUserGroupSpy); // removeOnMembershipLost = true + } +}