Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Volunteer Group structure #57

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,4 @@ storage/backups

cron-fail.log
.DS_Store
.phpunit.cache/
2 changes: 1 addition & 1 deletion app/Actions/Slack/AddToUserGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
2 changes: 1 addition & 1 deletion app/Actions/Slack/RemoveFromUserGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
5 changes: 3 additions & 2 deletions app/External/Slack/Api/UsergroupsApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 66 additions & 12 deletions app/External/Slack/Modals/ManageVolunteerGroups.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this to be an external select?
External selects load options on keypress. There's noticeable latency and unbeknownst to slack or the user, our response will (almost) never change since we're not actually doing any filtering in getOptions. It's silly to be doing these extra network roundtrips for most of our select fields.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're probably right. The max number of options for a static select is 100, so we would need to handle switching to an external select dynamically. Not the end of the world, I just didn't want to deal with that. We do also filter all options automatically in the SlackOptionsController so we're not returning the same thing each time, we're just handling the filtering in one place. If you return the same JSON blob to Slack, they don't do any filtering for you. So they would notice if we were returning the same thing over and over.

I can try to implement that behavior with graceful fallback to an external select in another PR, but I don't want to handle it here.

->minQueryLength(0);
}

public function groupForm()
{
$this->modalView->newSection()
->mrkdwnText("Sorry, nothing to see here");
}

public static function callbackId(): string
Expand All @@ -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;
Expand All @@ -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);
}
}
8 changes: 8 additions & 0 deletions app/FeatureFlags.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 16 additions & 0 deletions app/Http/Requests/SlackRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
5 changes: 4 additions & 1 deletion app/Models/Customer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions app/Models/UserMembership.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* @property int customer_id
* @property int plan_id
* @property string status
*
* @property Customer $customer
*/
class UserMembership extends Model
{
Expand Down
79 changes: 76 additions & 3 deletions app/Models/VolunteerGroupChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <system>_<object type>_<field type> 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',
Expand All @@ -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();
}
}
8 changes: 6 additions & 2 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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')]);
});
Expand Down
2 changes: 2 additions & 0 deletions app/Reactors/GithubMembershipReactor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading