diff --git a/bin/phpstan b/bin/phpstan --- a/bin/phpstan +++ b/bin/phpstan @@ -4,7 +4,7 @@ pushd ${cwd}/../src/ -php -dmemory_limit=400M \ +php -dmemory_limit=500M \ vendor/bin/phpstan \ analyse diff --git a/docker-compose.yml b/docker-compose.yml --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,6 +59,7 @@ container_name: kolab-mariadb environment: MYSQL_ROOT_PASSWORD: Welcome2KolabSystems + TZ: "+02:00" healthcheck: interval: 10s test: test -e /var/run/mysqld/mysqld.sock diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -6,6 +6,7 @@ APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com APP_THEME=default +APP_TENANT_ID=1 APP_LOCALE=en APP_LOCALES=en,de diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -2,18 +2,37 @@ namespace App\Console; -class Command extends \Illuminate\Console\Command +use Illuminate\Support\Facades\DB; + +abstract class Command extends \Illuminate\Console\Command { + /** + * Annotate this command as being dangerous for any potential unintended consequences. + * + * Commands are considered dangerous if; + * + * * observers are deliberately not triggered, meaning that the deletion of an object model that requires the + * associated observer to clean some things up, or charge a wallet or something, are deliberately not triggered, + * + * * deletion of objects and their relations rely on database foreign keys with obscure cascading, + * + * * a command will result in the permanent, irrecoverable loss of data. + * + * @var boolean + */ + protected $dangerous = false; + /** * Find the domain. * - * @param string $domain Domain ID or namespace + * @param string $domain Domain ID or namespace + * @param bool $withDeleted Include deleted * * @return \App\Domain|null */ - public function getDomain($domain) + public function getDomain($domain, $withDeleted = false) { - return $this->getObject(\App\Domain::class, $domain, 'namespace'); + return $this->getObject(\App\Domain::class, $domain, 'namespace', $withDeleted); } /** @@ -22,38 +41,86 @@ * @param string $objectClass The name of the class * @param string $objectIdOrTitle The name of a database field to match. * @param string|null $objectTitle An additional database field to match. + * @param bool $withDeleted Act as if --with-deleted was used * * @return mixed */ - public function getObject($objectClass, $objectIdOrTitle, $objectTitle) + public function getObject($objectClass, $objectIdOrTitle, $objectTitle = null, $withDeleted = false) { - if ($this->hasOption('with-deleted') && $this->option('with-deleted')) { - $object = $objectClass::withTrashed()->find($objectIdOrTitle); - } else { - $object = $objectClass::find($objectIdOrTitle); + if (!$withDeleted) { + $withDeleted = $this->hasOption('with-deleted') && $this->option('with-deleted'); } + $object = $this->getObjectModel($objectClass, $withDeleted)->find($objectIdOrTitle); + if (!$object && !empty($objectTitle)) { - if ($this->hasOption('with-deleted') && $this->option('with-deleted')) { - $object = $objectClass::withTrashed()->where($objectTitle, $objectIdOrTitle)->first(); - } else { - $object = $objectClass::where($objectTitle, $objectIdOrTitle)->first(); - } + $object = $this->getObjectModel($objectClass, $withDeleted) + ->where($objectTitle, $objectIdOrTitle)->first(); } return $object; } + /** + * Returns a preconfigured Model object for a specified class. + * + * @param string $objectClass The name of the class + * @param bool $withDeleted Include withTrashed() query + * + * @return mixed + */ + protected function getObjectModel($objectClass, $withDeleted = false) + { + if ($withDeleted) { + $model = $objectClass::withTrashed(); + } else { + $model = new $objectClass(); + } + + $modelsWithTenant = [ + \App\Discount::class, + \App\Domain::class, + \App\Group::class, + \App\Package::class, + \App\Plan::class, + \App\Sku::class, + \App\User::class, + ]; + + $modelsWithOwner = [ + \App\Wallet::class, + ]; + + $tenant_id = \config('app.tenant_id'); + + // Add tenant filter + if (in_array($objectClass, $modelsWithTenant)) { + $model = $model->withEnvTenant(); + } elseif (in_array($objectClass, $modelsWithOwner)) { + $model = $model->whereExists(function ($query) use ($tenant_id) { + $query->select(DB::raw(1)) + ->from('users') + ->whereRaw('wallets.user_id = users.id') + ->whereRaw('users.tenant_id ' . ($tenant_id ? "= $tenant_id" : 'is null')); + }); + } + + // TODO: tenant check for Entitlement, Transaction, etc. + + return $model; + } + /** * Find the user. * - * @param string $user User ID or email + * @param string $user User ID or email + * @param bool $withDeleted Include deleted * * @return \App\User|null */ - public function getUser($user) + public function getUser($user, $withDeleted = false) { - return $this->getObject(\App\User::class, $user, 'email'); + return $this->getObject(\App\User::class, $user, 'email', $withDeleted); } /** @@ -68,6 +135,26 @@ return $this->getObject(\App\Wallet::class, $wallet, null); } + public function handle() + { + if ($this->dangerous) { + $this->warn( + "This command is a dangerous scalpel command with potentially significant unintended consequences" + ); + + $confirmation = $this->confirm("Are you sure you understand what's about to happen?"); + + if (!$confirmation) { + $this->info("Better safe than sorry."); + return false; + } + + $this->info("VĂ¡monos!"); + } + + return true; + } + /** * Return a string for output, with any additional attributes specified as well. * diff --git a/src/app/Console/Commands/DBPing.php b/src/app/Console/Commands/DB/PingCommand.php rename from src/app/Console/Commands/DBPing.php rename to src/app/Console/Commands/DB/PingCommand.php --- a/src/app/Console/Commands/DBPing.php +++ b/src/app/Console/Commands/DB/PingCommand.php @@ -1,11 +1,11 @@ error("The application timezone is not configured to be UTC"); + return 1; + } + + if ($result[0]->{'Value'} != '+00:00' && $result[0]->{'Value'} != 'UTC') { + $this->error("The database timezone is neither configured as '+00:00' nor 'UTC'"); + return 1; + } + + return 0; + } +} diff --git a/src/app/Console/Commands/Discount/MergeCommand.php b/src/app/Console/Commands/Discount/MergeCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Discount/MergeCommand.php @@ -0,0 +1,85 @@ + 158f660b-e992-4fb9-ac12-5173b5f33807 \ + * > 62af659f-17d8-4527-87c1-c69eaa26653c \ + * > --description="Employee discount" + * ``` + */ +class MergeCommand extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'discount:merge {source} {target} {--description*}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Merge one discount in to another discount, ' . + 'optionally set the description, and delete the source discount'; + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $source = $this->getObject(\App\Discount::class, $this->argument('source')); + + if (!$source) { + $this->error("No such source discount: {$source}"); + return 1; + } + + $target = $this->getObject(\App\Discount::class, $this->argument('target')); + + if (!$target) { + $this->error("No such target discount: {$target}"); + return 1; + } + + if ($source->discount !== $target->discount) { + $this->error("Can't merge two discounts that have different rates"); + return 1; + } + + foreach ($source->wallets as $wallet) { + $wallet->discount_id = $target->id; + $wallet->timestamps = false; + $wallet->save(); + } + + if ($this->option('description')) { + $target->description = $this->option('description'); + $target->save(); + } + + $source->delete(); + } +} diff --git a/src/app/Console/Commands/DiscountList.php b/src/app/Console/Commands/DiscountList.php deleted file mode 100644 --- a/src/app/Console/Commands/DiscountList.php +++ /dev/null @@ -1,61 +0,0 @@ -orderBy('discount')->get()->each( - function ($discount) { - $name = $discount->description; - - if ($discount->code) { - $name .= " [{$discount->code}]"; - } - - $this->info( - sprintf( - "%s %3d%% %s", - $discount->id, - $discount->discount, - $name - ) - ); - } - ); - } -} diff --git a/src/app/Console/Commands/DiscountsCommand.php b/src/app/Console/Commands/DiscountsCommand.php --- a/src/app/Console/Commands/DiscountsCommand.php +++ b/src/app/Console/Commands/DiscountsCommand.php @@ -4,6 +4,29 @@ use App\Console\ObjectListCommand; +/** + * List discounts. + * + * Example usage: + * + * ``` + * $ ./artisan discounts + * 003f18e5-cbd2-4de8-9485-b0c966e4757d + * 00603496-5c91-4347-b341-cd5022566210 + * 0076b174-f122-458a-8466-bd05c3cac35d + * (...) + * ``` + * + * To include specific attributes, use `--attr` (allowed multiple times): + * + * ``` + * $ ./artisan discounts --attr=discount --attr=description + * 003f18e5-cbd2-4de8-9485-b0c966e4757d 54 Custom volume discount + * 00603496-5c91-4347-b341-cd5022566210 30 Developer Discount + * 0076b174-f122-458a-8466-bd05c3cac35d 100 it's me + * (...) + * ``` + */ class DiscountsCommand extends ObjectListCommand { protected $objectClass = \App\Discount::class; diff --git a/src/app/Console/Commands/DomainAdd.php b/src/app/Console/Commands/Domain/CreateCommand.php rename from src/app/Console/Commands/DomainAdd.php rename to src/app/Console/Commands/Domain/CreateCommand.php --- a/src/app/Console/Commands/DomainAdd.php +++ b/src/app/Console/Commands/Domain/CreateCommand.php @@ -1,20 +1,18 @@ argument('domain')); // must use withTrashed(), because unique constraint - $domain = Domain::withTrashed()->where('namespace', $namespace)->first(); + $domain = \App\Domain::withTrashed()->where('namespace', $namespace)->first(); if ($domain && !$this->option('force')) { $this->error("Domain {$namespace} already exists."); return 1; } - Queue::fake(); // ignore LDAP for now - if ($domain) { if ($domain->deleted_at) { - // revive domain - $domain->deleted_at = null; - $domain->status = 0; + // set the status back to new + $domain->status = \App\Domain::STATUS_NEW; $domain->save(); // remove existing entitlement - $entitlement = Entitlement::withTrashed()->where( + $entitlement = \App\Entitlement::withTrashed()->where( [ 'entitleable_id' => $domain->id, 'entitleable_type' => \App\Domain::class @@ -60,17 +55,36 @@ if ($entitlement) { $entitlement->forceDelete(); } + + // restore the domain to allow for the observer to handle the create job + $domain->restore(); + + $this->info( + sprintf( + "Domain %s with ID %d revived. Remember to assign it to a wallet with 'domain:set-wallet'", + $domain->namespace, + $domain->id + ) + ); } else { $this->error("Domain {$namespace} not marked as deleted... examine more closely"); return 1; } } else { - $domain = Domain::create([ + $domain = \App\Domain::create( + [ 'namespace' => $namespace, - 'type' => Domain::TYPE_EXTERNAL, - ]); - } + 'type' => \App\Domain::TYPE_EXTERNAL, + ] + ); - $this->info($domain->id); + $this->info( + sprintf( + "Domain %s created with ID %d. Remember to assign it to a wallet with 'domain:set-wallet'", + $domain->namespace, + $domain->id + ) + ); + } } } diff --git a/src/app/Console/Commands/DomainDelete.php b/src/app/Console/Commands/DomainDelete.php --- a/src/app/Console/Commands/DomainDelete.php +++ b/src/app/Console/Commands/DomainDelete.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class DomainDelete extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'Delete a domain'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,7 +27,7 @@ */ public function handle() { - $domain = \App\Domain::where('id', $this->argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; diff --git a/src/app/Console/Commands/DomainList.php b/src/app/Console/Commands/DomainList.php --- a/src/app/Console/Commands/DomainList.php +++ b/src/app/Console/Commands/DomainList.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; +use App\Console\Command; use App\Domain; -use Illuminate\Console\Command; class DomainList extends Command { @@ -21,16 +21,6 @@ */ protected $description = 'List domains'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -44,7 +34,7 @@ $domains = Domain::orderBy('namespace'); } - $domains->each( + $domains->withEnvTenant()->each( function ($domain) { $msg = $domain->namespace; diff --git a/src/app/Console/Commands/DomainListUsers.php b/src/app/Console/Commands/DomainListUsers.php --- a/src/app/Console/Commands/DomainListUsers.php +++ b/src/app/Console/Commands/DomainListUsers.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class DomainListUsers extends Command { @@ -37,7 +37,7 @@ */ public function handle() { - $domain = \App\Domain::where('namespace', $this->argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; diff --git a/src/app/Console/Commands/DomainRestore.php b/src/app/Console/Commands/DomainRestore.php --- a/src/app/Console/Commands/DomainRestore.php +++ b/src/app/Console/Commands/DomainRestore.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; use Illuminate\Support\Facades\DB; class DomainRestore extends Command @@ -28,7 +28,7 @@ */ public function handle() { - $domain = \App\Domain::withTrashed()->where('namespace', $this->argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain'), true); if (!$domain) { $this->error("Domain not found."); diff --git a/src/app/Console/Commands/DomainSetStatus.php b/src/app/Console/Commands/DomainSetStatus.php --- a/src/app/Console/Commands/DomainSetStatus.php +++ b/src/app/Console/Commands/DomainSetStatus.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; +use App\Console\Command; use App\Domain; -use Illuminate\Console\Command; use Illuminate\Support\Facades\Queue; class DomainSetStatus extends Command @@ -29,7 +29,7 @@ */ public function handle() { - $domain = Domain::where('namespace', $this->argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; diff --git a/src/app/Console/Commands/DomainSetWallet.php b/src/app/Console/Commands/DomainSetWallet.php --- a/src/app/Console/Commands/DomainSetWallet.php +++ b/src/app/Console/Commands/DomainSetWallet.php @@ -2,11 +2,11 @@ namespace App\Console\Commands; +use App\Console\Command; use App\Entitlement; use App\Domain; use App\Sku; use App\Wallet; -use Illuminate\Console\Command; use Illuminate\Support\Facades\Queue; class DomainSetWallet extends Command @@ -32,14 +32,14 @@ */ public function handle() { - $domain = Domain::where('namespace', $this->argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { $this->error("Domain not found."); return 1; } - $wallet = Wallet::find($this->argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { $this->error("Wallet not found."); @@ -60,6 +60,7 @@ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => 0, + 'fee' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ] diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/DomainStatus.php --- a/src/app/Console/Commands/DomainStatus.php +++ b/src/app/Console/Commands/DomainStatus.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; +use App\Console\Command; use App\Domain; -use Illuminate\Console\Command; class DomainStatus extends Command { @@ -21,16 +21,6 @@ */ protected $description = 'Display the status of a domain'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -38,7 +28,7 @@ */ public function handle() { - $domain = Domain::where('namespace', $this->argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; diff --git a/src/app/Console/Commands/DomainSuspend.php b/src/app/Console/Commands/DomainSuspend.php --- a/src/app/Console/Commands/DomainSuspend.php +++ b/src/app/Console/Commands/DomainSuspend.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; +use App\Console\Command; use App\Domain; -use Illuminate\Console\Command; class DomainSuspend extends Command { @@ -21,16 +21,6 @@ */ protected $description = 'Suspend a domain'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -38,7 +28,7 @@ */ public function handle() { - $domain = Domain::where('namespace', $this->argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; diff --git a/src/app/Console/Commands/DomainUnsuspend.php b/src/app/Console/Commands/DomainUnsuspend.php --- a/src/app/Console/Commands/DomainUnsuspend.php +++ b/src/app/Console/Commands/DomainUnsuspend.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; +use App\Console\Command; use App\Domain; -use Illuminate\Console\Command; class DomainUnsuspend extends Command { @@ -21,16 +21,6 @@ */ protected $description = 'Remove a domain suspension'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -38,7 +28,7 @@ */ public function handle() { - $domain = Domain::where('namespace', $this->argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; diff --git a/src/app/Console/Commands/Group/CreateCommand.php b/src/app/Console/Commands/Group/CreateCommand.php --- a/src/app/Console/Commands/Group/CreateCommand.php +++ b/src/app/Console/Commands/Group/CreateCommand.php @@ -7,6 +7,11 @@ use App\Http\Controllers\API\V4\GroupsController; use Illuminate\Support\Facades\DB; +/** + * Create a (mail-enabled) distribution group. + * + * @see \App\Console\Commands\Scalpel\Group\CreateCommand + */ class CreateCommand extends Command { /** diff --git a/src/app/Console/Commands/Job/DomainCreate.php b/src/app/Console/Commands/Job/DomainCreate.php --- a/src/app/Console/Commands/Job/DomainCreate.php +++ b/src/app/Console/Commands/Job/DomainCreate.php @@ -2,8 +2,8 @@ namespace App\Console\Commands\Job; +use App\Console\Command; use App\Domain; -use Illuminate\Console\Command; class DomainCreate extends Command { @@ -28,7 +28,7 @@ */ public function handle() { - $domain = Domain::where('namespace', $this->argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/DomainUpdate.php --- a/src/app/Console/Commands/Job/DomainUpdate.php +++ b/src/app/Console/Commands/Job/DomainUpdate.php @@ -2,8 +2,8 @@ namespace App\Console\Commands\Job; +use App\Console\Command; use App\Domain; -use Illuminate\Console\Command; class DomainUpdate extends Command { @@ -28,7 +28,7 @@ */ public function handle() { - $domain = Domain::where('namespace', $this->argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; diff --git a/src/app/Console/Commands/Job/UserCreate.php b/src/app/Console/Commands/Job/UserCreate.php --- a/src/app/Console/Commands/Job/UserCreate.php +++ b/src/app/Console/Commands/Job/UserCreate.php @@ -2,8 +2,8 @@ namespace App\Console\Commands\Job; +use App\Console\Command; use App\User; -use Illuminate\Console\Command; class UserCreate extends Command { @@ -28,7 +28,7 @@ */ public function handle() { - $user = User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/Job/UserUpdate.php b/src/app/Console/Commands/Job/UserUpdate.php --- a/src/app/Console/Commands/Job/UserUpdate.php +++ b/src/app/Console/Commands/Job/UserUpdate.php @@ -2,8 +2,8 @@ namespace App\Console\Commands\Job; +use App\Console\Command; use App\User; -use Illuminate\Console\Command; class UserUpdate extends Command { @@ -28,7 +28,7 @@ */ public function handle() { - $user = User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/Job/WalletCheck.php b/src/app/Console/Commands/Job/WalletCheck.php --- a/src/app/Console/Commands/Job/WalletCheck.php +++ b/src/app/Console/Commands/Job/WalletCheck.php @@ -2,8 +2,8 @@ namespace App\Console\Commands\Job; +use App\Console\Command; use App\Wallet; -use Illuminate\Console\Command; class WalletCheck extends Command { @@ -28,7 +28,7 @@ */ public function handle() { - $wallet = Wallet::find($this->argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; diff --git a/src/app/Console/Commands/MollieInfo.php b/src/app/Console/Commands/MollieInfo.php --- a/src/app/Console/Commands/MollieInfo.php +++ b/src/app/Console/Commands/MollieInfo.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; +use App\Console\Command; use App\User; -use Illuminate\Console\Command; class MollieInfo extends Command { @@ -29,7 +29,7 @@ public function handle() { if ($this->argument('user')) { - $user = User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/OpenVidu/RoomCreate.php b/src/app/Console/Commands/OpenVidu/RoomCreate.php --- a/src/app/Console/Commands/OpenVidu/RoomCreate.php +++ b/src/app/Console/Commands/OpenVidu/RoomCreate.php @@ -2,7 +2,7 @@ namespace App\Console\Commands\OpenVidu; -use Illuminate\Console\Command; +use App\Console\Command; class RoomCreate extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'Create a room for a user'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,7 +27,7 @@ */ public function handle() { - $user = \App\User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/OpenVidu/Rooms.php b/src/app/Console/Commands/OpenVidu/Rooms.php --- a/src/app/Console/Commands/OpenVidu/Rooms.php +++ b/src/app/Console/Commands/OpenVidu/Rooms.php @@ -20,16 +20,6 @@ */ protected $description = 'List OpenVidu rooms'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * diff --git a/src/app/Console/Commands/OpenVidu/Sessions.php b/src/app/Console/Commands/OpenVidu/Sessions.php --- a/src/app/Console/Commands/OpenVidu/Sessions.php +++ b/src/app/Console/Commands/OpenVidu/Sessions.php @@ -20,16 +20,6 @@ */ protected $description = 'List OpenVidu sessions'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * diff --git a/src/app/Console/Commands/PackageSkus.php b/src/app/Console/Commands/PackageSkus.php --- a/src/app/Console/Commands/PackageSkus.php +++ b/src/app/Console/Commands/PackageSkus.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; +use App\Console\Command; use App\Package; -use Illuminate\Console\Command; class PackageSkus extends Command { @@ -21,16 +21,6 @@ */ protected $description = "List SKUs for packages."; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -38,7 +28,7 @@ */ public function handle() { - $packages = Package::all(); + $packages = Package::withEnvTenant()->get(); foreach ($packages as $package) { $this->info(sprintf("Package: %s", $package->title)); diff --git a/src/app/Console/Commands/PlanPackages.php b/src/app/Console/Commands/PlanPackages.php --- a/src/app/Console/Commands/PlanPackages.php +++ b/src/app/Console/Commands/PlanPackages.php @@ -38,7 +38,7 @@ */ public function handle() { - $plans = Plan::all(); + $plans = Plan::withEnvTenant()->get(); foreach ($plans as $plan) { $this->info(sprintf("Plan: %s", $plan->title)); diff --git a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php b/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php --- a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php +++ b/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php @@ -6,6 +6,8 @@ class CreateCommand extends ObjectCreateCommand { + protected $hidden = true; + protected $commandPrefix = 'scalpel'; protected $objectClass = \App\Discount::class; protected $objectName = 'discount'; diff --git a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php b/src/app/Console/Commands/Scalpel/Discount/DeleteCommand.php copy from src/app/Console/Commands/Scalpel/Discount/CreateCommand.php copy to src/app/Console/Commands/Scalpel/Discount/DeleteCommand.php --- a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php +++ b/src/app/Console/Commands/Scalpel/Discount/DeleteCommand.php @@ -2,10 +2,13 @@ namespace App\Console\Commands\Scalpel\Discount; -use App\Console\ObjectCreateCommand; +use App\Console\ObjectDeleteCommand; -class CreateCommand extends ObjectCreateCommand +class DeleteCommand extends ObjectDeleteCommand { + protected $dangerous = true; + protected $hidden = true; + protected $commandPrefix = 'scalpel'; protected $objectClass = \App\Discount::class; protected $objectName = 'discount'; diff --git a/src/app/Console/Commands/Scalpel/Discount/ReadCommand.php b/src/app/Console/Commands/Scalpel/Discount/ReadCommand.php --- a/src/app/Console/Commands/Scalpel/Discount/ReadCommand.php +++ b/src/app/Console/Commands/Scalpel/Discount/ReadCommand.php @@ -6,6 +6,8 @@ class ReadCommand extends ObjectReadCommand { + protected $hidden = true; + protected $commandPrefix = 'scalpel'; protected $objectClass = \App\Discount::class; protected $objectName = 'discount'; diff --git a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php b/src/app/Console/Commands/Scalpel/Discount/UpdateCommand.php copy from src/app/Console/Commands/Scalpel/Discount/CreateCommand.php copy to src/app/Console/Commands/Scalpel/Discount/UpdateCommand.php --- a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php +++ b/src/app/Console/Commands/Scalpel/Discount/UpdateCommand.php @@ -2,10 +2,12 @@ namespace App\Console\Commands\Scalpel\Discount; -use App\Console\ObjectCreateCommand; +use App\Console\ObjectUpdateCommand; -class CreateCommand extends ObjectCreateCommand +class UpdateCommand extends ObjectUpdateCommand { + protected $hidden = true; + protected $commandPrefix = 'scalpel'; protected $objectClass = \App\Discount::class; protected $objectName = 'discount'; diff --git a/src/app/Console/Commands/Scalpel/Domain/CreateCommand.php b/src/app/Console/Commands/Scalpel/Domain/CreateCommand.php --- a/src/app/Console/Commands/Scalpel/Domain/CreateCommand.php +++ b/src/app/Console/Commands/Scalpel/Domain/CreateCommand.php @@ -6,6 +6,8 @@ class CreateCommand extends ObjectCreateCommand { + protected $hidden = true; + protected $commandPrefix = 'scalpel'; protected $objectClass = \App\Domain::class; protected $objectName = 'domain'; diff --git a/src/app/Console/Commands/Scalpel/Domain/ReadCommand.php b/src/app/Console/Commands/Scalpel/Domain/ReadCommand.php --- a/src/app/Console/Commands/Scalpel/Domain/ReadCommand.php +++ b/src/app/Console/Commands/Scalpel/Domain/ReadCommand.php @@ -6,6 +6,8 @@ class ReadCommand extends ObjectReadCommand { + protected $hidden = true; + protected $commandPrefix = 'scalpel'; protected $objectClass = \App\Domain::class; protected $objectName = 'domain'; diff --git a/src/app/Console/Commands/Scalpel/Domain/UpdateCommand.php b/src/app/Console/Commands/Scalpel/Domain/UpdateCommand.php --- a/src/app/Console/Commands/Scalpel/Domain/UpdateCommand.php +++ b/src/app/Console/Commands/Scalpel/Domain/UpdateCommand.php @@ -6,6 +6,8 @@ class UpdateCommand extends ObjectUpdateCommand { + protected $hidden = true; + protected $commandPrefix = 'scalpel'; protected $objectClass = \App\Domain::class; protected $objectName = 'domain'; diff --git a/src/app/Console/Commands/Scalpel/Entitlement/CreateCommand.php b/src/app/Console/Commands/Scalpel/Entitlement/CreateCommand.php --- a/src/app/Console/Commands/Scalpel/Entitlement/CreateCommand.php +++ b/src/app/Console/Commands/Scalpel/Entitlement/CreateCommand.php @@ -6,6 +6,8 @@ class CreateCommand extends ObjectCreateCommand { + protected $hidden = true; + protected $commandPrefix = 'scalpel'; protected $objectClass = \App\Entitlement::class; protected $objectName = 'entitlement'; diff --git a/src/app/Console/Commands/Scalpel/Entitlement/ReadCommand.php b/src/app/Console/Commands/Scalpel/Entitlement/ReadCommand.php --- a/src/app/Console/Commands/Scalpel/Entitlement/ReadCommand.php +++ b/src/app/Console/Commands/Scalpel/Entitlement/ReadCommand.php @@ -6,6 +6,8 @@ class ReadCommand extends ObjectReadCommand { + protected $hidden = true; + protected $commandPrefix = 'scalpel'; protected $objectClass = \App\Entitlement::class; protected $objectName = 'entitlement'; diff --git a/src/app/Console/Commands/Scalpel/Entitlement/UpdateCommand.php b/src/app/Console/Commands/Scalpel/Entitlement/UpdateCommand.php --- a/src/app/Console/Commands/Scalpel/Entitlement/UpdateCommand.php +++ b/src/app/Console/Commands/Scalpel/Entitlement/UpdateCommand.php @@ -6,6 +6,8 @@ class UpdateCommand extends ObjectUpdateCommand { + protected $hidden = true; + protected $commandPrefix = 'scalpel'; protected $objectClass = \App\Entitlement::class; protected $objectName = 'entitlement'; diff --git a/src/app/Console/Commands/Scalpel/Group/CreateCommand.php b/src/app/Console/Commands/Scalpel/Group/CreateCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Scalpel/Group/CreateCommand.php @@ -0,0 +1,15 @@ +argument('sku')); - - if (!$sku) { - $sku = \App\Sku::where('title', $this->argument('sku'))->first(); - } + $sku = $this->getObject(\App\Sku::class, $this->argument('sku'), 'title'); if (!$sku) { $this->error("Unable to find the SKU."); diff --git a/src/app/Console/Commands/StripeInfo.php b/src/app/Console/Commands/StripeInfo.php --- a/src/app/Console/Commands/StripeInfo.php +++ b/src/app/Console/Commands/StripeInfo.php @@ -2,9 +2,9 @@ namespace App\Console\Commands; +use App\Console\Command; use App\Providers\PaymentProvider; use App\User; -use Illuminate\Console\Command; use Stripe as StripeAPI; class StripeInfo extends Command @@ -31,7 +31,7 @@ public function handle() { if ($this->argument('user')) { - $user = User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/UserAddAlias.php b/src/app/Console/Commands/UserAddAlias.php --- a/src/app/Console/Commands/UserAddAlias.php +++ b/src/app/Console/Commands/UserAddAlias.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; +use App\Console\Command; use App\Http\Controllers\API\V4\UsersController; -use Illuminate\Console\Command; class UserAddAlias extends Command { @@ -21,16 +21,6 @@ */ protected $description = 'Add an email alias to a user (forcefully)'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -38,7 +28,7 @@ */ public function handle() { - $user = \App\User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/UserAssignSku.php b/src/app/Console/Commands/UserAssignSku.php --- a/src/app/Console/Commands/UserAssignSku.php +++ b/src/app/Console/Commands/UserAssignSku.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class UserAssignSku extends Command { @@ -27,18 +27,14 @@ */ public function handle() { - $user = \App\User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { $this->error("Unable to find the user {$this->argument('user')}."); return 1; } - $sku = \App\Sku::find($this->argument('sku')); - - if (!$sku) { - $sku = \App\Sku::where('title', $this->argument('sku'))->first(); - } + $sku = $this->getObject(\App\Sku::class, $this->argument('sku'), 'title'); if (!$sku) { $this->error("Unable to find the SKU {$this->argument('sku')}."); diff --git a/src/app/Console/Commands/UserDelete.php b/src/app/Console/Commands/UserDelete.php --- a/src/app/Console/Commands/UserDelete.php +++ b/src/app/Console/Commands/UserDelete.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class UserDelete extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'Delete a user'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,7 +27,7 @@ */ public function handle() { - $user = \App\User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/UserDiscount.php b/src/app/Console/Commands/UserDiscount.php --- a/src/app/Console/Commands/UserDiscount.php +++ b/src/app/Console/Commands/UserDiscount.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class UserDiscount extends Command { @@ -20,16 +20,6 @@ */ protected $description = "Apply a discount to all of the user's wallets"; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,7 +27,7 @@ */ public function handle() { - $user = \App\User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; @@ -48,7 +38,7 @@ if ($this->argument('discount') === '0') { $discount = null; } else { - $discount = \App\Discount::find($this->argument('discount')); + $discount = $this->getObject(\App\Discount::class, $this->argument('discount')); if (!$discount) { return 1; diff --git a/src/app/Console/Commands/UserDomains.php b/src/app/Console/Commands/UserDomains.php --- a/src/app/Console/Commands/UserDomains.php +++ b/src/app/Console/Commands/UserDomains.php @@ -2,10 +2,7 @@ namespace App\Console\Commands; -use App\Domain; -use App\User; -use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; +use App\Console\Command; class UserDomains extends Command { @@ -23,16 +20,6 @@ */ protected $description = 'Command description'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -40,7 +27,7 @@ */ public function handle() { - $user = User::where('email', $this->argument('userid'))->first(); + $user = $this->getUser($this->argument('userid')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/UserEntitlements.php b/src/app/Console/Commands/UserEntitlements.php --- a/src/app/Console/Commands/UserEntitlements.php +++ b/src/app/Console/Commands/UserEntitlements.php @@ -2,9 +2,7 @@ namespace App\Console\Commands; -use App\Sku; -use App\User; -use Illuminate\Console\Command; +use App\Console\Command; class UserEntitlements extends Command { @@ -22,16 +20,6 @@ */ protected $description = "List a user's entitlements."; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -39,7 +27,7 @@ */ public function handle() { - $user = User::where('email', $this->argument('userid'))->first(); + $user = $this->getUser($this->argument('userid')); if (!$user) { return 1; @@ -58,7 +46,7 @@ } foreach ($skus_counted as $id => $qty) { - $sku = Sku::find($id); + $sku = \App\Sku::find($id); $this->info("SKU: {$sku->title} ({$qty})"); } } diff --git a/src/app/Console/Commands/UserForceDelete.php b/src/app/Console/Commands/UserForceDelete.php --- a/src/app/Console/Commands/UserForceDelete.php +++ b/src/app/Console/Commands/UserForceDelete.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; use Illuminate\Support\Facades\DB; class UserForceDelete extends Command @@ -28,7 +28,7 @@ */ public function handle() { - $user = \App\User::withTrashed()->where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user'), true); if (!$user) { return 1; diff --git a/src/app/Console/Commands/UserRestore.php b/src/app/Console/Commands/UserRestore.php --- a/src/app/Console/Commands/UserRestore.php +++ b/src/app/Console/Commands/UserRestore.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; use Illuminate\Support\Facades\DB; class UserRestore extends Command @@ -28,7 +28,7 @@ */ public function handle() { - $user = \App\User::withTrashed()->where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user'), true); if (!$user) { $this->error('User not found.'); diff --git a/src/app/Console/Commands/UserStatus.php b/src/app/Console/Commands/UserStatus.php --- a/src/app/Console/Commands/UserStatus.php +++ b/src/app/Console/Commands/UserStatus.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; +use App\Console\Command; use App\User; -use Illuminate\Console\Command; class UserStatus extends Command { @@ -21,16 +21,6 @@ */ protected $description = 'Display the status of a user'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -38,7 +28,7 @@ */ public function handle() { - $user = User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/UserSuspend.php b/src/app/Console/Commands/UserSuspend.php --- a/src/app/Console/Commands/UserSuspend.php +++ b/src/app/Console/Commands/UserSuspend.php @@ -3,7 +3,7 @@ namespace App\Console\Commands; use App\User; -use Illuminate\Console\Command; +use App\Console\Command; class UserSuspend extends Command { @@ -21,16 +21,6 @@ */ protected $description = 'Suspend a user'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -38,7 +28,7 @@ */ public function handle() { - $user = User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/UserUnsuspend.php b/src/app/Console/Commands/UserUnsuspend.php --- a/src/app/Console/Commands/UserUnsuspend.php +++ b/src/app/Console/Commands/UserUnsuspend.php @@ -2,8 +2,7 @@ namespace App\Console\Commands; -use App\User; -use Illuminate\Console\Command; +use App\Console\Command; class UserUnsuspend extends Command { @@ -21,16 +20,6 @@ */ protected $description = 'Remove a user suspension'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -38,7 +27,7 @@ */ public function handle() { - $user = User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/UserVerify.php b/src/app/Console/Commands/UserVerify.php --- a/src/app/Console/Commands/UserVerify.php +++ b/src/app/Console/Commands/UserVerify.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class UserVerify extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'Verify the state of a user account'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,7 +27,7 @@ */ public function handle() { - $user = \App\User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/UserWallets.php b/src/app/Console/Commands/UserWallets.php --- a/src/app/Console/Commands/UserWallets.php +++ b/src/app/Console/Commands/UserWallets.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class UserWallets extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'List wallets for a user'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,7 +27,7 @@ */ public function handle() { - $user = \App\User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; diff --git a/src/app/Console/Commands/WalletAddTransaction.php b/src/app/Console/Commands/WalletAddTransaction.php --- a/src/app/Console/Commands/WalletAddTransaction.php +++ b/src/app/Console/Commands/WalletAddTransaction.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class WalletAddTransaction extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'Add a transaction to a wallet'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,7 +27,7 @@ */ public function handle() { - $wallet = \App\Wallet::find($this->argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; diff --git a/src/app/Console/Commands/WalletBalances.php b/src/app/Console/Commands/WalletBalances.php --- a/src/app/Console/Commands/WalletBalances.php +++ b/src/app/Console/Commands/WalletBalances.php @@ -20,16 +20,6 @@ */ protected $description = 'Show the balance on wallets'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,13 +27,18 @@ */ public function handle() { - \App\Wallet::all()->each( + $wallets = \App\Wallet::select('wallets.*') + ->join('users', 'users.id', '=', 'wallets.user_id') + ->withEnvTenant('users') + ->all(); + + $wallets->each( function ($wallet) { if ($wallet->balance == 0) { return; } - $user = \App\User::where('id', $wallet->user_id)->first(); + $user = $wallet->owner; if (!$user) { return; diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php --- a/src/app/Console/Commands/WalletCharge.php +++ b/src/app/Console/Commands/WalletCharge.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; +use App\Console\Command; use App\Wallet; -use Illuminate\Console\Command; class WalletCharge extends Command { @@ -21,16 +21,6 @@ */ protected $description = 'Charge wallets'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -40,7 +30,7 @@ { if ($wallet = $this->argument('wallet')) { // Find specified wallet by ID - $wallet = Wallet::find($wallet); + $wallet = $this->getWallet($wallet); if (!$wallet || !$wallet->owner) { return 1; @@ -51,6 +41,7 @@ // Get all wallets, excluding deleted accounts $wallets = Wallet::select('wallets.*') ->join('users', 'users.id', '=', 'wallets.user_id') + ->withEnvTenant('users') ->whereNull('users.deleted_at') ->cursor(); } diff --git a/src/app/Console/Commands/WalletDiscount.php b/src/app/Console/Commands/WalletDiscount.php --- a/src/app/Console/Commands/WalletDiscount.php +++ b/src/app/Console/Commands/WalletDiscount.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class WalletDiscount extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'Apply a discount to a wallet'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,7 +27,7 @@ */ public function handle() { - $wallet = \App\Wallet::where('id', $this->argument('wallet'))->first(); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; @@ -48,7 +38,7 @@ if ($this->argument('discount') === '0') { $wallet->discount()->dissociate(); } else { - $discount = \App\Discount::find($this->argument('discount')); + $discount = $this->getObject(\App\Discount::class, $this->argument('discount')); if (!$discount) { return 1; diff --git a/src/app/Console/Commands/WalletExpected.php b/src/app/Console/Commands/WalletExpected.php --- a/src/app/Console/Commands/WalletExpected.php +++ b/src/app/Console/Commands/WalletExpected.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class WalletExpected extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'Show expected charges to wallets (for user)'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -38,8 +28,7 @@ public function handle() { if ($this->option('user')) { - $user = \App\User::where('email', $this->option('user')) - ->orWhere('id', $this->option('user'))->first(); + $user = $this->getUser($this->option('user')); if (!$user) { return 1; @@ -47,7 +36,10 @@ $wallets = $user->wallets; } else { - $wallets = \App\Wallet::all(); + $wallets = \App\Wallet::select('wallets.*') + ->join('users', 'users.id', '=', 'wallets.user_id') + ->withEnvTenant('users') + ->all(); } foreach ($wallets as $wallet) { diff --git a/src/app/Console/Commands/WalletGetBalance.php b/src/app/Console/Commands/WalletGetBalance.php --- a/src/app/Console/Commands/WalletGetBalance.php +++ b/src/app/Console/Commands/WalletGetBalance.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class WalletGetBalance extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'Display the balance of a wallet'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,7 +27,7 @@ */ public function handle() { - $wallet = \App\Wallet::find($this->argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; diff --git a/src/app/Console/Commands/WalletGetDiscount.php b/src/app/Console/Commands/WalletGetDiscount.php --- a/src/app/Console/Commands/WalletGetDiscount.php +++ b/src/app/Console/Commands/WalletGetDiscount.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class WalletGetDiscount extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'Display the existing discount to a wallet, if any.'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,7 +27,7 @@ */ public function handle() { - $wallet = \App\Wallet::find($this->argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; diff --git a/src/app/Console/Commands/WalletMandate.php b/src/app/Console/Commands/WalletMandate.php --- a/src/app/Console/Commands/WalletMandate.php +++ b/src/app/Console/Commands/WalletMandate.php @@ -2,8 +2,8 @@ namespace App\Console\Commands; +use App\Console\Command; use App\Http\Controllers\API\V4\PaymentsController; -use Illuminate\Console\Command; class WalletMandate extends Command { @@ -21,16 +21,6 @@ */ protected $description = 'Show expected charges to wallets'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -38,7 +28,7 @@ */ public function handle() { - $wallet = \App\Wallet::find($this->argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; diff --git a/src/app/Console/Commands/WalletSetBalance.php b/src/app/Console/Commands/WalletSetBalance.php --- a/src/app/Console/Commands/WalletSetBalance.php +++ b/src/app/Console/Commands/WalletSetBalance.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class WalletSetBalance extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'Set the balance of a wallet'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,13 +27,13 @@ */ public function handle() { - $wallet = \App\Wallet::find($this->argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; } - $wallet->balance = (int)($this->argument('balance')); + $wallet->balance = (int) $this->argument('balance'); $wallet->save(); } } diff --git a/src/app/Console/Commands/WalletSetDiscount.php b/src/app/Console/Commands/WalletSetDiscount.php --- a/src/app/Console/Commands/WalletSetDiscount.php +++ b/src/app/Console/Commands/WalletSetDiscount.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class WalletSetDiscount extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'Apply a discount to a wallet'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,7 +27,7 @@ */ public function handle() { - $wallet = \App\Wallet::where('id', $this->argument('wallet'))->first(); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; @@ -48,7 +38,7 @@ if ($this->argument('discount') === '0') { $wallet->discount()->dissociate(); } else { - $discount = \App\Discount::find($this->argument('discount')); + $discount = $this->getObject(\App\Discount::class, $this->argument('discount')); if (!$discount) { return 1; diff --git a/src/app/Console/Commands/WalletTransactions.php b/src/app/Console/Commands/WalletTransactions.php --- a/src/app/Console/Commands/WalletTransactions.php +++ b/src/app/Console/Commands/WalletTransactions.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class WalletTransactions extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'List the transactions against a wallet.'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,13 +27,13 @@ */ public function handle() { - $wallet = \App\Wallet::where('id', $this->argument('wallet'))->first(); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; } - foreach ($wallet->transactions()->orderBy('created_at')->get() as $transaction) { + $wallet->transactions()->orderBy('created_at')->each(function ($transaction) { $this->info( sprintf( "%s: %s %s", @@ -67,6 +57,6 @@ ); } } - } + }); } } diff --git a/src/app/Console/Commands/WalletUntil.php b/src/app/Console/Commands/WalletUntil.php --- a/src/app/Console/Commands/WalletUntil.php +++ b/src/app/Console/Commands/WalletUntil.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class WalletUntil extends Command { @@ -20,16 +20,6 @@ */ protected $description = 'Show until when the balance on a wallet lasts.'; - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } - /** * Execute the console command. * @@ -37,7 +27,7 @@ */ public function handle() { - $wallet = \App\Wallet::find($this->argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; diff --git a/src/app/Console/ObjectUpdateCommand.php b/src/app/Console/ObjectDeleteCommand.php copy from src/app/Console/ObjectUpdateCommand.php copy to src/app/Console/ObjectDeleteCommand.php --- a/src/app/Console/ObjectUpdateCommand.php +++ b/src/app/Console/ObjectDeleteCommand.php @@ -8,13 +8,13 @@ /** * This abstract class provides a means to treat objects in our model using CRUD. */ -abstract class ObjectUpdateCommand extends ObjectCommand +abstract class ObjectDeleteCommand extends ObjectCommand { public function __construct() { - $this->description = "Update a {$this->objectName}"; + $this->description = "Delete a {$this->objectName}"; $this->signature = sprintf( - "%s%s:update {%s}", + "%s%s:delete {%s}", $this->commandPrefix ? $this->commandPrefix . ":" : "", $this->objectName, $this->objectName @@ -36,10 +36,6 @@ $classes = class_uses_recursive($this->objectClass); - if (in_array(SoftDeletes::class, $classes)) { - $this->signature .= " {--with-deleted : Include deleted {$this->objectName}s}"; - } - parent::__construct(); } @@ -73,6 +69,12 @@ */ public function handle() { + $result = parent::handle(); + + if (!$result) { + return 1; + } + $argument = $this->argument($this->objectName); $object = $this->getObject($this->objectClass, $argument, $this->objectTitle); @@ -82,18 +84,12 @@ return 1; } - foreach ($this->getProperties() as $property => $value) { - if ($property == "deleted_at" && $value == "null") { - $value = null; - } - - $object->{$property} = $value; + if ($this->commandPrefix == 'scalpel') { + $this->objectClass::withoutEvents( + function () use ($object) { + $object->delete(); + } + ); } - - $object->timestamps = false; - - $object->save(['timestamps' => false]); - - $this->cacheRefresh($object); } } diff --git a/src/app/Console/ObjectRelationListCommand.php b/src/app/Console/ObjectRelationListCommand.php --- a/src/app/Console/ObjectRelationListCommand.php +++ b/src/app/Console/ObjectRelationListCommand.php @@ -67,19 +67,16 @@ return 1; } - if ($result instanceof \Illuminate\Database\Eloquent\Collection) { - $result->each( - function ($entry) { - $this->info($this->toString($entry)); - } - ); - } elseif ($result instanceof \Illuminate\Database\Eloquent\Relations\Relation) { - $result->each( - function ($entry) { - $this->info($this->toString($entry)); - } - ); - } elseif (is_array($result)) { + // Convert query builder into a collection + if ($result instanceof \Illuminate\Database\Eloquent\Relations\Relation) { + $result = $result->get(); + } + + // Print the result + if ( + ($result instanceof \Illuminate\Database\Eloquent\Collection) + || is_array($result) + ) { foreach ($result as $entry) { $this->info($this->toString($entry)); } diff --git a/src/app/Console/ObjectUpdateCommand.php b/src/app/Console/ObjectUpdateCommand.php --- a/src/app/Console/ObjectUpdateCommand.php +++ b/src/app/Console/ObjectUpdateCommand.php @@ -92,7 +92,15 @@ $object->timestamps = false; - $object->save(['timestamps' => false]); + if ($this->commandPrefix == 'scalpel') { + $this->objectClass::withoutEvents( + function () use ($object) { + $object->save(); + } + ); + } else { + $object->save(); + } $this->cacheRefresh($object); } diff --git a/src/app/Discount.php b/src/app/Discount.php --- a/src/app/Discount.php +++ b/src/app/Discount.php @@ -7,6 +7,12 @@ /** * The eloquent definition of a Discount. + * + * @property bool $active + * @property string $code + * @property string $description + * @property int $discount + * @property int $tenant_id */ class Discount extends Model { @@ -53,6 +59,16 @@ $this->attributes['discount'] = $discount; } + /** + * The tenant for this discount. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } + /** * List of wallets with this discount assigned. * diff --git a/src/app/Domain.php b/src/app/Domain.php --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -10,6 +10,9 @@ * The eloquent definition of a Domain. * * @property string $namespace + * @property int $status + * @property int $tenant_id + * @property int $type */ class Domain extends Model { @@ -86,6 +89,7 @@ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), + 'fee' => $sku->pivot->fee(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] @@ -107,13 +111,13 @@ } /** - * Return list of public+active domain names + * Return list of public+active domain names (for current tenant) */ public static function getPublicDomains(): array { - $where = sprintf('(type & %s)', Domain::TYPE_PUBLIC); - - return self::whereRaw($where)->get(['namespace'])->pluck('namespace')->toArray(); + return self::withEnvTenant() + ->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) + ->get(['namespace'])->pluck('namespace')->toArray(); } /** @@ -376,6 +380,16 @@ $this->save(); } + /** + * The tenant for this domain. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } + /** * Unsuspend this domain. * diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -11,10 +11,18 @@ * * Owned by a {@link \App\User}, billed to a {@link \App\Wallet}. * - * @property \App\User $owner The owner of this entitlement (subject). - * @property \App\Sku $sku The SKU to which this entitlement applies. - * @property \App\Wallet $wallet The wallet to which this entitlement is charged. - * @property \App\Domain|\App\User $entitleable The entitled object (receiver of the entitlement). + * @property int $cost + * @property ?string $description + * @property \App\Domain|\App\User $entitleable The entitled object (receiver of the entitlement). + * @property int $entitleable_id + * @property string $entitleable_type + * @property int $fee + * @property string $id + * @property \App\User $owner The owner of this entitlement (subject). + * @property \App\Sku $sku The SKU to which this entitlement applies. + * @property string $sku_id + * @property \App\Wallet $wallet The wallet to which this entitlement is charged. + * @property string $wallet_id */ class Entitlement extends Model { @@ -45,11 +53,13 @@ 'entitleable_id', 'entitleable_type', 'cost', - 'description' + 'description', + 'fee', ]; protected $casts = [ 'cost' => 'integer', + 'fee' => 'integer' ]; /** diff --git a/src/app/Group.php b/src/app/Group.php --- a/src/app/Group.php +++ b/src/app/Group.php @@ -9,10 +9,11 @@ /** * The eloquent definition of a Group. * - * @property int $id The group identifier - * @property string $email An email address - * @property string $members A comma-separated list of email addresses - * @property int $status The group status + * @property int $id The group identifier + * @property string $email An email address + * @property string $members A comma-separated list of email addresses + * @property int $status The group status + * @property int $tenant_id Tenant identifier */ class Group extends Model { @@ -64,6 +65,7 @@ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => Group::class ]); @@ -250,6 +252,16 @@ $this->save(); } + /** + * The tenant for this group. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } + /** * Unsuspend this group. * diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -12,6 +12,7 @@ use App\Rules\UserEmailDomain; use App\Rules\UserEmailLocal; use App\SignupCode; +use App\SignupInvitation; use App\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -111,6 +112,33 @@ return response()->json(['status' => 'success', 'code' => $code->code]); } + /** + * Returns signup invitation information. + * + * @param string $id Signup invitation identifier + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function invitation($id) + { + $invitation = SignupInvitation::withEnvTenant()->find($id); + + if (empty($invitation) || $invitation->isCompleted()) { + return $this->errorResponse(404); + } + + $has_domain = $this->getPlan()->hasDomain(); + + $result = [ + 'id' => $id, + 'is_domain' => $has_domain, + 'domains' => $has_domain ? [] : Domain::getPublicDomains(), + ]; + + return response()->json($result); + } + + /** * Validation of the verification code. * @@ -188,10 +216,50 @@ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - // Validate verification codes (again) - $v = $this->verify($request); - if ($v->status() !== 200) { - return $v; + // Signup via invitation + if ($request->invitation) { + $invitation = SignupInvitation::withEnvTenant()->find($request->invitation); + + if (empty($invitation) || $invitation->isCompleted()) { + return $this->errorResponse(404); + } + + // Check required fields + $v = Validator::make( + $request->all(), + [ + 'first_name' => 'max:128', + 'last_name' => 'max:128', + 'voucher' => 'max:32', + ] + ); + + $errors = $v->fails() ? $v->errors()->toArray() : []; + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $settings = [ + 'external_email' => $invitation->email, + 'first_name' => $request->first_name, + 'last_name' => $request->last_name, + ]; + } else { + // Validate verification codes (again) + $v = $this->verify($request); + if ($v->status() !== 200) { + return $v; + } + + // Get user name/email from the verification code database + $code_data = $v->getData(); + + $settings = [ + 'external_email' => $code_data->email, + 'first_name' => $code_data->first_name, + 'last_name' => $code_data->last_name, + ]; } // Find the voucher discount @@ -217,10 +285,6 @@ return response()->json(['status' => 'error', 'errors' => $errors], 422); } - // Get user name/email from the verification code database - $code_data = $v->getData(); - $user_email = $code_data->email; - // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($login); $domain_name = Str::lower($domain_name); @@ -252,14 +316,19 @@ $user->assignPlan($plan, $domain); // Save the external email and plan in user settings - $user->setSettings([ - 'external_email' => $user_email, - 'first_name' => $code_data->first_name, - 'last_name' => $code_data->last_name, - ]); + $user->setSettings($settings); + + // Update the invitation + if (!empty($invitation)) { + $invitation->status = SignupInvitation::STATUS_COMPLETED; + $invitation->user_id = $user->id; + $invitation->save(); + } // Remove the verification code - $this->code->delete(); + if ($this->code) { + $this->code->delete(); + } DB::commit(); diff --git a/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php b/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php --- a/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php @@ -16,7 +16,10 @@ { $discounts = []; - Discount::where('active', true)->orderBy('discount')->get() + Discount::withEnvTenant() + ->where('active', true) + ->orderBy('discount') + ->get() ->map(function ($discount) use (&$discounts) { $label = $discount->discount . '% - ' . $discount->description; diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -20,7 +20,7 @@ $result = collect([]); if ($owner) { - if ($owner = User::find($owner)) { + if ($owner = User::withEnvTenant()->find($owner)) { foreach ($owner->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); @@ -33,7 +33,7 @@ $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { - if ($domain = Domain::where('namespace', $search)->first()) { + if ($domain = Domain::withEnvTenant()->where('namespace', $search)->first()) { $result->push($domain); } } @@ -64,7 +64,7 @@ */ public function suspend(Request $request, $id) { - $domain = Domain::find($id); + $domain = Domain::withEnvTenant()->find($id); if (empty($domain) || $domain->isPublic()) { return $this->errorResponse(404); @@ -88,7 +88,7 @@ */ public function unsuspend(Request $request, $id) { - $domain = Domain::find($id); + $domain = Domain::withEnvTenant()->find($id); if (empty($domain) || $domain->isPublic()) { return $this->errorResponse(404); diff --git a/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php b/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php deleted file mode 100644 --- a/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php +++ /dev/null @@ -1,7 +0,0 @@ -find($owner)) { foreach ($owner->wallets as $wallet) { $wallet->entitlements()->where('entitleable_type', Group::class)->get() ->each(function ($entitlement) use ($result) { @@ -31,7 +31,7 @@ $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { - if ($group = Group::where('email', $search)->first()) { + if ($group = Group::withEnvTenant()->where('email', $search)->first()) { $result->push($group); } } @@ -78,7 +78,7 @@ */ public function suspend(Request $request, $id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); @@ -102,7 +102,7 @@ */ public function unsuspend(Request $request, $id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); diff --git a/src/app/Http/Controllers/API/V4/Admin/StatsController.php b/src/app/Http/Controllers/API/V4/Admin/StatsController.php --- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php @@ -6,6 +6,7 @@ use App\User; use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; class StatsController extends \App\Http\Controllers\Controller @@ -18,6 +19,14 @@ public const COLOR_BLUE_DARK = '#0056b3'; public const COLOR_ORANGE = '#f1a539'; + /** @var array List of enabled charts */ + protected $charts = [ + 'discounts', + 'income', + 'users', + 'users-all', + ]; + /** * Fetch chart data * @@ -33,7 +42,7 @@ $method = 'chart' . implode('', array_map('ucfirst', explode('-', $chart))); - if (!method_exists($this, $method)) { + if (!in_array($chart, $this->charts) || !method_exists($this, $method)) { return $this->errorResponse(404); } @@ -53,9 +62,14 @@ ->join('users', 'users.id', '=', 'wallets.user_id') ->where('discount', '>', 0) ->whereNull('users.deleted_at') - ->groupBy('discounts.discount') - ->pluck('cnt', 'discount') - ->all(); + ->groupBy('discounts.discount'); + + $addTenantScope = function ($builder, $tenantId) { + return $builder->where('users.tenant_id', $tenantId); + }; + + $discounts = $this->applyTenantScope($discounts, $addTenantScope) + ->pluck('cnt', 'discount')->all(); $labels = array_keys($discounts); $discounts = array_values($discounts); @@ -119,7 +133,20 @@ ->where('updated_at', '>=', $start->toDateString()) ->where('status', PaymentProvider::STATUS_PAID) ->whereIn('type', [PaymentProvider::TYPE_ONEOFF, PaymentProvider::TYPE_RECURRING]) - ->groupByRaw('1') + ->groupByRaw('1'); + + $addTenantScope = function ($builder, $tenantId) { + $where = '`wallet_id` IN (' + . 'select `id` from `wallets` ' + . 'join `users` on (`wallets`.`user_id` = `users`.`id`) ' + . 'where `payments`.`wallet_id` = `wallets`.`id` ' + . 'and `users`.`tenant_id` = ' . intval($tenantId) + . ')'; + + return $builder->whereRaw($where); + }; + + $payments = $this->applyTenantScope($payments, $addTenantScope) ->pluck('amount', 'period') ->map(function ($amount) { return $amount / 100; @@ -185,14 +212,15 @@ $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); + + $created = $this->applyTenantScope($created)->get(); + $deleted = $this->applyTenantScope($deleted)->get(); $empty = array_fill_keys($labels, 0); $created = array_values(array_merge($empty, $created->pluck('cnt', 'period')->all())); @@ -260,16 +288,16 @@ $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); - $count = DB::table('users')->whereNull('deleted_at')->count(); + $created = $this->applyTenantScope($created)->get(); + $deleted = $this->applyTenantScope($deleted)->get(); + $count = $this->applyTenantScope(DB::table('users')->whereNull('deleted_at'))->count(); $empty = array_fill_keys($labels, 0); $created = array_merge($empty, $created->pluck('cnt', 'period')->all()); @@ -313,4 +341,29 @@ ] ]; } + + /** + * Add tenant scope to the queries when needed + * + * @param \Illuminate\Database\Query\Builder $query The query + * @param callable $addQuery Additional tenant-scope query-modifier + * + * @return \Illuminate\Database\Query\Builder + */ + protected function applyTenantScope($query, $addQuery = null) + { + $user = Auth::guard()->user(); + + if ($user->role == 'reseller') { + if ($addQuery) { + $query = $addQuery($query, \config('app.tenant_id')); + } else { + $query = $query->withEnvTenant(); + } + } + + // TODO: Tenant selector for admins + + return $query; + } } diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -13,6 +13,18 @@ class UsersController extends \App\Http\Controllers\API\V4\UsersController { + /** + * Delete a user. + * + * @param int $id User identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function destroy($id) + { + return $this->errorResponse(404); + } + /** * Searching of user accounts. * @@ -25,13 +37,21 @@ $result = collect([]); if ($owner) { - if ($owner = User::find($owner)) { - $result = $owner->users(false)->orderBy('email')->get(); + $owner = User::where('id', $owner) + ->withEnvTenant() + ->whereNull('role') + ->first(); + + if ($owner) { + $result = $owner->users(false)->whereNull('role')->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email $result = User::withTrashed()->where('email', $search) - ->orderBy('email')->get(); + ->withEnvTenant() + ->whereNull('role') + ->orderBy('email') + ->get(); if ($result->isEmpty()) { // Search by an alias @@ -39,7 +59,9 @@ // Search by an external email $ext_user_ids = UserSetting::where('key', 'external_email') - ->where('value', $search)->get()->pluck('user_id'); + ->where('value', $search) + ->get() + ->pluck('user_id'); $user_ids = $user_ids->merge($ext_user_ids)->unique(); @@ -50,19 +72,35 @@ if (!$user_ids->isEmpty()) { $result = User::withTrashed()->whereIn('id', $user_ids) - ->orderBy('email')->get(); + ->withEnvTenant() + ->whereNull('role') + ->orderBy('email') + ->get(); } } } elseif (is_numeric($search)) { // Search by user ID - if ($user = User::withTrashed()->find($search)) { + $user = User::withTrashed()->where('id', $search) + ->withEnvTenant() + ->whereNull('role') + ->first(); + + if ($user) { $result->push($user); } } elseif (!empty($search)) { // Search by domain - if ($domain = Domain::withTrashed()->where('namespace', $search)->first()) { - if ($wallet = $domain->wallet()) { - $result->push($wallet->owner()->withTrashed()->first()); + $domain = Domain::withTrashed()->where('namespace', $search) + ->withEnvTenant() + ->first(); + + if ($domain) { + if ( + ($wallet = $domain->wallet()) + && ($owner = $wallet->owner()->withTrashed()->withEnvTenant()->first()) + && empty($owner->role) + ) { + $result->push($owner); } } } @@ -93,9 +131,9 @@ */ public function reset2FA(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } @@ -113,6 +151,18 @@ ]); } + /** + * Create a new user record. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function store(Request $request) + { + return $this->errorResponse(404); + } + /** * Suspend the user * @@ -123,9 +173,9 @@ */ public function suspend(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } @@ -147,9 +197,9 @@ */ public function unsuspend(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } @@ -171,9 +221,9 @@ */ public function update(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php --- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -8,6 +8,7 @@ use App\Transaction; use App\Wallet; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; @@ -24,7 +25,7 @@ { $wallet = Wallet::find($id); - if (empty($wallet)) { + if (empty($wallet) || !Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(404); } @@ -44,6 +45,7 @@ $result['provider'] = $provider->name(); $result['providerLink'] = $provider->customerLink($wallet); + $result['notice'] = $this->getWalletNotice($wallet); // for resellers return response()->json($result); } @@ -59,8 +61,9 @@ public function oneOff(Request $request, $id) { $wallet = Wallet::find($id); + $user = Auth::guard()->user(); - if (empty($wallet)) { + if (empty($wallet) || !$user->canRead($wallet)) { return $this->errorResponse(404); } @@ -96,6 +99,14 @@ ] ); + if ($user->role == 'reseller') { + if ($user->tenant && ($tenant_wallet = $user->tenant->wallet())) { + $desc = ($amount > 0 ? 'Awarded' : 'Penalized') . " user {$wallet->owner->email}"; + $method = $amount > 0 ? 'debit' : 'credit'; + $tenant_wallet->{$method}(abs($amount), $desc); + } + } + DB::commit(); $response = [ @@ -119,7 +130,7 @@ { $wallet = Wallet::find($id); - if (empty($wallet)) { + if (empty($wallet) || !Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(404); } @@ -127,7 +138,7 @@ if (empty($request->discount)) { $wallet->discount()->dissociate(); $wallet->save(); - } elseif ($discount = Discount::find($request->discount)) { + } elseif ($discount = Discount::withEnvTenant()->find($request->discount)) { $wallet->discount()->associate($discount); $wallet->save(); } diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -117,7 +117,7 @@ */ public function show($id) { - $domain = Domain::findOrFail($id); + $domain = Domain::withEnvTenant()->findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::guard()->user()->canRead($domain)) { @@ -152,7 +152,7 @@ */ public function status($id) { - $domain = Domain::find($id); + $domain = Domain::withEnvTenant()->findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::guard()->user()->canRead($domain)) { diff --git a/src/app/Http/Controllers/API/V4/EntitlementsController.php b/src/app/Http/Controllers/API/V4/EntitlementsController.php deleted file mode 100644 --- a/src/app/Http/Controllers/API/V4/EntitlementsController.php +++ /dev/null @@ -1,97 +0,0 @@ -errorResponse(404); - } - - /** - * Remove the specified resource from storage. - * - * @param int $id - * - * @return \Illuminate\Http\JsonResponse - */ - public function destroy($id) - { - // TODO - return $this->errorResponse(404); - } - - /** - * Show the form for editing the specified resource. - * - * @param int $id - * - * @return \Illuminate\Http\JsonResponse - */ - public function edit($id) - { - // TODO - return $this->errorResponse(404); - } - - /** - * Display a listing of the resource. - * - * @return \Illuminate\Http\JsonResponse - */ - public function index() - { - // TODO - return $this->errorResponse(404); - } - - /** - * Store a newly created resource in storage. - * - * @param \Illuminate\Http\Request $request - * - * @return \Illuminate\Http\JsonResponse - */ - public function store(Request $request) - { - // TODO - return $this->errorResponse(404); - } - - /** - * Display the specified resource. - * - * @param int $id - * - * @return \Illuminate\Http\JsonResponse - */ - public function show($id) - { - // TODO - return $this->errorResponse(404); - } - - /** - * Update the specified resource in storage. - * - * @param \Illuminate\Http\Request $request - * @param int $id - * - * @return \Illuminate\Http\JsonResponse - */ - public function update(Request $request, $id) - { - // TODO - return $this->errorResponse(404); - } -} diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php --- a/src/app/Http/Controllers/API/V4/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/GroupsController.php @@ -32,7 +32,7 @@ */ public function destroy($id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); @@ -96,7 +96,7 @@ */ public function show($id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); @@ -123,7 +123,7 @@ */ public function status($id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); @@ -308,7 +308,7 @@ */ public function update(Request $request, $id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); diff --git a/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php b/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php @@ -0,0 +1,7 @@ +errorResponse(404); + } + + /** + * Remove the specified invitation. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function destroy($id) + { + $invitation = SignupInvitation::withUserTenant()->find($id); + + if (empty($invitation)) { + return $this->errorResponse(404); + } + + $invitation->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => trans('app.signup-invitation-delete-success'), + ]); + } + + /** + * Show the form for editing the specified resource. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function edit($id) + { + return $this->errorResponse(404); + } + + /** + * Display a listing of the resource. + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $pageSize = 10; + $search = request()->input('search'); + $page = intval(request()->input('page')) ?: 1; + $hasMore = false; + + $result = SignupInvitation::withUserTenant() + ->latest() + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)); + + if ($search) { + if (strpos($search, '@')) { + $result->where('email', $search); + } else { + $result->whereLike('email', $search); + } + } + + $result = $result->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + $result = $result->map(function ($invitation) { + return $this->invitationToArray($invitation); + }); + + return response()->json([ + 'status' => 'success', + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + 'page' => $page, + ]); + } + + /** + * Resend the specified invitation. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function resend($id) + { + $invitation = SignupInvitation::withUserTenant()->find($id); + + if (empty($invitation)) { + return $this->errorResponse(404); + } + + if ($invitation->isFailed() || $invitation->isSent()) { + // Note: The email sending job will be dispatched by the observer + $invitation->status = SignupInvitation::STATUS_NEW; + $invitation->save(); + } + + return response()->json([ + 'status' => 'success', + 'message' => trans('app.signup-invitation-resend-success'), + 'invitation' => $this->invitationToArray($invitation), + ]); + } + + /** + * Store a newly created resource in storage. + * + * @param \Illuminate\Http\Request $request + * + * @return \Illuminate\Http\JsonResponse + */ + public function store(Request $request) + { + $errors = []; + $invitations = []; + + if (!empty($request->file) && is_object($request->file)) { + // Expected a text/csv file with multiple email addresses + if (!$request->file->isValid()) { + $errors = ['file' => [$request->file->getErrorMessage()]]; + } else { + $fh = fopen($request->file->getPathname(), 'r'); + $line_number = 0; + $error = null; + + while ($line = fgetcsv($fh)) { + $line_number++; + + // @phpstan-ignore-next-line + if (count($line) >= 1 && $line[0]) { + $email = trim($line[0]); + + if (strpos($email, '@')) { + $v = Validator::make(['email' => $email], ['email' => 'email:filter|required']); + + if ($v->fails()) { + $args = ['email' => $email, 'line' => $line_number]; + $error = trans('app.signup-invitations-csv-invalid-email', $args); + break; + } + + $invitations[] = ['email' => $email]; + } + } + } + + fclose($fh); + + if ($error) { + $errors = ['file' => $error]; + } elseif (empty($invitations)) { + $errors = ['file' => trans('app.signup-invitations-csv-empty')]; + } + } + } else { + // Expected 'email' field with an email address + $v = Validator::make($request->all(), ['email' => 'email|required']); + + if ($v->fails()) { + $errors = $v->errors()->toArray(); + } else { + $invitations[] = ['email' => $request->email]; + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $count = 0; + foreach ($invitations as $idx => $invitation) { + SignupInvitation::create($invitation); + $count++; + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans_choice('app.signup-invitations-created', $count, ['count' => $count]), + 'count' => $count, + ]); + } + + /** + * Display the specified resource. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + return $this->errorResponse(404); + } + + /** + * Update the specified resource in storage. + * + * @param \Illuminate\Http\Request $request + * @param int $id + * + * @return \Illuminate\Http\JsonResponse + */ + public function update(Request $request, $id) + { + return $this->errorResponse(404); + } + + /** + * Convert an invitation object to an array for output + * + * @param \App\SignupInvitation $invitation The signup invitation object + * + * @return array + */ + protected static function invitationToArray(SignupInvitation $invitation): array + { + return [ + 'id' => $invitation->id, + 'email' => $invitation->email, + 'isNew' => $invitation->isNew(), + 'isSent' => $invitation->isSent(), + 'isFailed' => $invitation->isFailed(), + 'isCompleted' => $invitation->isCompleted(), + 'created' => $invitation->created_at->toDateTimeString(), + ]; + } +} diff --git a/src/app/Http/Controllers/API/V4/Reseller/PackagesController.php b/src/app/Http/Controllers/API/V4/Reseller/PackagesController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/PackagesController.php @@ -0,0 +1,7 @@ +find($id); if (empty($user)) { return $this->errorResponse(404); @@ -186,7 +186,7 @@ $data['name'] = $sku->name; $data['description'] = $sku->description; - unset($data['handler_class'], $data['created_at'], $data['updated_at']); + unset($data['handler_class'], $data['created_at'], $data['updated_at'], $data['fee'], $data['tenant_id']); return $data; } diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php --- a/src/app/Http/Controllers/Controller.php +++ b/src/app/Http/Controllers/Controller.php @@ -24,7 +24,7 @@ * * @return \Illuminate\Http\JsonResponse */ - protected function errorResponse(int $code, string $message = null, array $data = []) + public static function errorResponse(int $code, string $message = null, array $data = []) { $errors = [ 400 => "Bad request", diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php --- a/src/app/Http/Kernel.php +++ b/src/app/Http/Kernel.php @@ -64,6 +64,7 @@ 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + 'reseller' => \App\Http\Middleware\AuthenticateReseller::class, 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, @@ -80,11 +81,11 @@ \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\AuthenticateAdmin::class, + \App\Http\Middleware\AuthenticateReseller::class, \App\Http\Middleware\Authenticate::class, \Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Auth\Middleware\Authorize::class, - \App\Http\Middleware\AuthenticateAdmin::class, ]; /** diff --git a/src/app/Http/Middleware/AuthenticateAdmin.php b/src/app/Http/Middleware/AuthenticateAdmin.php --- a/src/app/Http/Middleware/AuthenticateAdmin.php +++ b/src/app/Http/Middleware/AuthenticateAdmin.php @@ -18,7 +18,7 @@ $user = auth()->user(); if (!$user) { - abort(403, "Unauthorized"); + abort(401, "Unauthorized"); } if ($user->role !== "admin") { diff --git a/src/app/Http/Middleware/AuthenticateAdmin.php b/src/app/Http/Middleware/AuthenticateReseller.php copy from src/app/Http/Middleware/AuthenticateAdmin.php copy to src/app/Http/Middleware/AuthenticateReseller.php --- a/src/app/Http/Middleware/AuthenticateAdmin.php +++ b/src/app/Http/Middleware/AuthenticateReseller.php @@ -4,7 +4,7 @@ use Closure; -class AuthenticateAdmin +class AuthenticateReseller { /** * Handle an incoming request. @@ -18,10 +18,14 @@ $user = auth()->user(); if (!$user) { + abort(401, "Unauthorized"); + } + + if ($user->role !== "reseller") { abort(403, "Unauthorized"); } - if ($user->role !== "admin") { + if ($user->tenant_id != \config('app.tenant_id')) { abort(403, "Unauthorized"); } diff --git a/src/app/Jobs/SignupInvitationEmail.php b/src/app/Jobs/SignupInvitationEmail.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/SignupInvitationEmail.php @@ -0,0 +1,75 @@ +invitation = $invitation; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + Mail::to($this->invitation->email)->send(new SignupInvitationMail($this->invitation)); + + // Update invitation status + $this->invitation->status = SignupInvitation::STATUS_SENT; + $this->invitation->save(); + } + + /** + * The job failed to process. + * + * @param \Exception $exception + * + * @return void + */ + public function failed(\Exception $exception) + { + if ($this->attempts() >= $this->tries) { + // Update invitation status + $this->invitation->status = SignupInvitation::STATUS_FAILED; + $this->invitation->save(); + } + } +} diff --git a/src/app/Mail/SignupInvitation.php b/src/app/Mail/SignupInvitation.php new file mode 100644 --- /dev/null +++ b/src/app/Mail/SignupInvitation.php @@ -0,0 +1,72 @@ +invitation = $invitation; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $href = Utils::serviceUrl('/signup/invite/' . $this->invitation->id); + + $this->view('emails.html.signup_invitation') + ->text('emails.plain.signup_invitation') + ->subject(__('mail.signupinvitation-subject', ['site' => \config('app.name')])) + ->with([ + 'site' => \config('app.name'), + 'href' => $href, + ]); + + return $this; + } + + /** + * Render the mail template with fake data + * + * @param string $type Output format ('html' or 'text') + * + * @return string HTML or Plain Text output + */ + public static function fakeRender(string $type = 'html'): string + { + $invitation = new SI([ + 'email' => 'test@external.org', + ]); + + $invitation->id = Utils::uuidStr(); + + $mail = new self($invitation); + + return Helper::render($mail, $type); + } +} diff --git a/src/app/Observers/DiscountObserver.php b/src/app/Observers/DiscountObserver.php --- a/src/app/Observers/DiscountObserver.php +++ b/src/app/Observers/DiscountObserver.php @@ -25,5 +25,7 @@ break; } } + + $discount->tenant_id = \config('app.tenant_id'); } } diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -27,6 +27,8 @@ $domain->namespace = \strtolower($domain->namespace); $domain->status |= Domain::STATUS_NEW; + + $domain->tenant_id = \config('app.tenant_id'); } /** diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -123,7 +123,6 @@ return; } - $cost = 0; $now = Carbon::now(); // get the discount rate applied to the wallet. @@ -131,7 +130,8 @@ // just in case this had not been billed yet, ever $diffInMonths = $entitlement->updated_at->diffInMonths($now); - $cost += (int) ($entitlement->cost * $discount * $diffInMonths); + $cost = (int) ($entitlement->cost * $discount * $diffInMonths); + $fee = (int) ($entitlement->fee * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); @@ -153,8 +153,18 @@ } $pricePerDay = $entitlement->cost / $daysInMonth; + $feePerDay = $entitlement->fee / $daysInMonth; $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); + $fee += (int) (round($feePerDay * $diffInDays, 0)); + + $profit = $cost - $fee; + + if ($profit != 0 && $owner->tenant && ($wallet = $owner->tenant->wallet())) { + $desc = "Charged user {$owner->email}"; + $method = $profit > 0 ? 'credit' : 'debit'; + $wallet->{$method}(abs($profit), $desc); + } if ($cost == 0) { return; diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php --- a/src/app/Observers/GroupObserver.php +++ b/src/app/Observers/GroupObserver.php @@ -25,6 +25,8 @@ } $group->status |= Group::STATUS_NEW | Group::STATUS_ACTIVE; + + $group->tenant_id = \config('app.tenant_id'); } /** diff --git a/src/app/Observers/PackageObserver.php b/src/app/Observers/PackageObserver.php --- a/src/app/Observers/PackageObserver.php +++ b/src/app/Observers/PackageObserver.php @@ -27,5 +27,7 @@ break; } } + + $package->tenant_id = \config('app.tenant_id'); } } diff --git a/src/app/Observers/PackageSkuObserver.php b/src/app/Observers/PackageSkuObserver.php --- a/src/app/Observers/PackageSkuObserver.php +++ b/src/app/Observers/PackageSkuObserver.php @@ -6,6 +6,25 @@ class PackageSkuObserver { + /** + * Handle the "creating" event on an PackageSku relation. + * + * Ensures that the entries belong to the same tenant. + * + * @param \App\PackageSku $packageSku The package-sku relation + * + * @return void + */ + public function creating(PackageSku $packageSku) + { + $package = $packageSku->package; + $sku = $packageSku->sku; + + if ($package->tenant_id != $sku->tenant_id) { + throw new \Exception("Package and SKU owned by different tenants"); + } + } + /** * Handle the "created" event on an PackageSku relation * diff --git a/src/app/Observers/PlanObserver.php b/src/app/Observers/PlanObserver.php --- a/src/app/Observers/PlanObserver.php +++ b/src/app/Observers/PlanObserver.php @@ -27,5 +27,7 @@ break; } } + + $plan->tenant_id = \config('app.tenant_id'); } } diff --git a/src/app/Observers/PlanPackageObserver.php b/src/app/Observers/PlanPackageObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/PlanPackageObserver.php @@ -0,0 +1,27 @@ +package; + $plan = $planPackage->plan; + + if ($package->tenant_id != $plan->tenant_id) { + throw new \Exception("Package and Plan owned by different tenants"); + } + } +} diff --git a/src/app/Observers/SignupInvitationObserver.php b/src/app/Observers/SignupInvitationObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/SignupInvitationObserver.php @@ -0,0 +1,65 @@ +{$invitation->getKeyName()} = $allegedly_unique; + break; + } + } + + $invitation->status = SI::STATUS_NEW; + + $invitation->tenant_id = \config('app.tenant_id'); + } + + /** + * Handle the invitation "created" event. + * + * @param \App\SignupInvitation $invitation The invitation object + * + * @return void + */ + public function created(SI $invitation) + { + \App\Jobs\SignupInvitationEmail::dispatch($invitation); + } + + /** + * Handle the invitation "updated" event. + * + * @param \App\SignupInvitation $invitation The invitation object + * + * @return void + */ + public function updated(SI $invitation) + { + $oldStatus = $invitation->getOriginal('status'); + + // Resend the invitation + if ( + $invitation->status == SI::STATUS_NEW + && ($oldStatus == SI::STATUS_FAILED || $oldStatus == SI::STATUS_SENT) + ) { + \App\Jobs\SignupInvitationEmail::dispatch($invitation); + } + } +} diff --git a/src/app/Observers/SkuObserver.php b/src/app/Observers/SkuObserver.php --- a/src/app/Observers/SkuObserver.php +++ b/src/app/Observers/SkuObserver.php @@ -22,5 +22,9 @@ break; } } + + $sku->tenant_id = \config('app.tenant_id'); + + // TODO: We should make sure that tenant_id + title is unique } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -38,7 +38,7 @@ // only users that are not imported get the benefit of the doubt. $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE; - // can't dispatch job here because it'll fail serialization + $user->tenant_id = \config('app.tenant_id'); } /** @@ -108,6 +108,18 @@ } }); } + + // Debit the reseller's wallet with the user negative balance + $balance = 0; + foreach ($user->wallets as $wallet) { + // Note: here we assume all user wallets are using the same currency. + // It might get changed in the future + $balance += $wallet->balance; + } + + if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { + $wallet->debit($balance * -1, "Deleted user {$user->email}"); + } } /** diff --git a/src/app/Package.php b/src/app/Package.php --- a/src/app/Package.php +++ b/src/app/Package.php @@ -21,6 +21,13 @@ * * Free package: mailbox + quota. * * Selecting a package will therefore create a set of entitlments from SKUs. + * + * @property string $description + * @property int $discount_rate + * @property string $id + * @property string $name + * @property ?int $tenant_id + * @property string $title */ class Package extends Model { @@ -69,7 +76,10 @@ return $costs; } - public function isDomain() + /** + * Checks whether the package contains a domain SKU. + */ + public function isDomain(): bool { foreach ($this->skus as $sku) { if ($sku->handler_class::entitleableClass() == \App\Domain::class) { @@ -94,4 +104,14 @@ ['qty'] ); } + + /** + * The tenant for this package. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } } diff --git a/src/app/PackageSku.php b/src/app/PackageSku.php --- a/src/app/PackageSku.php +++ b/src/app/PackageSku.php @@ -35,30 +35,50 @@ */ public function cost() { - $costs = 0; - $units = $this->qty - $this->sku->units_free; if ($units < 0) { - \Log::debug( - "Package {$this->package_id} is misconfigured for more free units than qty." - ); - $units = 0; } + // FIXME: Why package_skus.cost value is not used anywhere? + $ppu = $this->sku->cost * ((100 - $this->package->discount_rate) / 100); - $costs += $units * $ppu; + return $units * $ppu; + } + + /** + * Under this package, what fee this SKU has? + * + * @return int The fee for this SKU under this package in cents. + */ + public function fee() + { + $units = $this->qty - $this->sku->units_free; + + if ($units < 0) { + $units = 0; + } - return $costs; + return $this->sku->fee * $units; } + /** + * The package for this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ public function package() { return $this->belongsTo('App\Package'); } + /** + * The SKU for this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ public function sku() { return $this->belongsTo('App\Sku'); diff --git a/src/app/Plan.php b/src/app/Plan.php --- a/src/app/Plan.php +++ b/src/app/Plan.php @@ -13,7 +13,16 @@ * A "Family Plan" as such may exist of "2 or more Kolab packages", * and apply a discount for the third and further Kolab packages. * + * @property string $description + * @property int $discount_qty + * @property int $discount_rate + * @property string $id + * @property string $name * @property \App\Package[] $packages + * @property datetime $promo_from + * @property datetime $promo_to + * @property ?int $tenant_id + * @property string $title */ class Plan extends Model { @@ -105,4 +114,14 @@ return false; } + + /** + * The tenant for this plan. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } } diff --git a/src/app/PlanPackage.php b/src/app/PlanPackage.php --- a/src/app/PlanPackage.php +++ b/src/app/PlanPackage.php @@ -15,6 +15,7 @@ * @property int $qty_max * @property int $qty_min * @property \App\Package $package + * @property \App\Plan $plan */ class PlanPackage extends Pivot { @@ -54,8 +55,23 @@ return $costs; } + /** + * The package in this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ public function package() { return $this->belongsTo('App\Package'); } + + /** + * The plan in this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function plan() + { + return $this->belongsTo('App\Plan'); + } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -34,7 +35,9 @@ \App\Package::observe(\App\Observers\PackageObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\Plan::observe(\App\Observers\PlanObserver::class); + \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); + \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\Sku::observe(\App\Observers\SkuObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); \App\User::observe(\App\Observers\UserObserver::class); @@ -57,5 +60,50 @@ $path = trim($path, '/\'"'); return ""; }); + + // Query builder 'withEnvTenant' macro + Builder::macro('withEnvTenant', function (string $table = null) { + $tenant_id = \config('app.tenant_id'); + + if ($tenant_id) { + /** @var Builder $this */ + return $this->where(($table ? "$table." : '') . 'tenant_id', $tenant_id); + } + + /** @var Builder $this */ + return $this->whereNull(($table ? "$table." : '') . 'tenant_id'); + }); + + // Query builder 'withUserTenant' macro + Builder::macro('withUserTenant', function (string $table = null) { + $tenant_id = auth()->user()->tenant_id; + + if ($tenant_id) { + /** @var Builder $this */ + return $this->where(($table ? "$table." : '') . 'tenant_id', $tenant_id); + } + + /** @var Builder $this */ + return $this->whereNull(($table ? "$table." : '') . 'tenant_id'); + }); + + // Query builder 'whereLike' mocro + Builder::macro('whereLike', function (string $column, string $search, int $mode = 0) { + $search = addcslashes($search, '%_'); + + switch ($mode) { + case 2: + $search .= '%'; + break; + case 1: + $search = '%' . $search; + break; + default: + $search = '%' . $search . '%'; + } + + /** @var Builder $this */ + return $this->where($column, 'like', $search); + }); } } diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -68,7 +68,7 @@ 'sequenceType' => 'first', 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), - 'redirectUrl' => Utils::serviceUrl('/wallet'), + 'redirectUrl' => self::redirectUrl(), 'locale' => 'en_US', 'method' => $payment['methodId'] ]; @@ -198,7 +198,7 @@ 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', 'method' => $payment['methodId'], - 'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments + 'redirectUrl' => self::redirectUrl() // required for non-recurring payments ]; // TODO: Additional payment parameters for better fraud protection: diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php --- a/src/app/Providers/Payment/Stripe.php +++ b/src/app/Providers/Payment/Stripe.php @@ -70,8 +70,8 @@ $request = [ 'customer' => $customer_id, - 'cancel_url' => Utils::serviceUrl('/wallet'), // required - 'success_url' => Utils::serviceUrl('/wallet'), // required + 'cancel_url' => self::redirectUrl(), // required + 'success_url' => self::redirectUrl(), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'mode' => 'setup', @@ -188,8 +188,8 @@ $request = [ 'customer' => $customer_id, - 'cancel_url' => Utils::serviceUrl('/wallet'), // required - 'success_url' => Utils::serviceUrl('/wallet'), // required + 'cancel_url' => self::redirectUrl(), // required + 'success_url' => self::redirectUrl(), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'line_items' => [ diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -261,6 +261,8 @@ $refund['status'] = self::STATUS_PAID; $refund['amount'] = -1 * $amount; + // FIXME: Refunds/chargebacks are out of the reseller comissioning for now + $this->storePayment($refund, $wallet->id); } @@ -385,4 +387,22 @@ return $methods; } + + /** + * Returns the full URL for the wallet page, used when returning from an external payment page. + * Depending on the request origin it will return a URL for the User or Reseller UI. + * + * @return string The redirect URL + */ + public static function redirectUrl(): string + { + $url = \App\Utils::serviceUrl('/wallet'); + $domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); + + if (strpos($domain, 'reseller') === 0) { + $url = preg_replace('|^(https?://)([^/]+)|', '\\1' . $domain, $url); + } + + return $url; + } } diff --git a/src/app/SignupInvitation.php b/src/app/SignupInvitation.php new file mode 100644 --- /dev/null +++ b/src/app/SignupInvitation.php @@ -0,0 +1,109 @@ +status & self::STATUS_COMPLETED) > 0; + } + + /** + * Returns whether this invitation sending failed. + * + * @return bool + */ + public function isFailed(): bool + { + return ($this->status & self::STATUS_FAILED) > 0; + } + + /** + * Returns whether this invitation is new. + * + * @return bool + */ + public function isNew(): bool + { + return ($this->status & self::STATUS_NEW) > 0; + } + + /** + * Returns whether this invitation has been sent. + * + * @return bool + */ + public function isSent(): bool + { + return ($this->status & self::STATUS_SENT) > 0; + } + + /** + * The tenant for this invitation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } + + /** + * The account to which the invitation was used for. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo('App\User', 'user_id', 'id'); + } +} diff --git a/src/app/Sku.php b/src/app/Sku.php --- a/src/app/Sku.php +++ b/src/app/Sku.php @@ -7,6 +7,18 @@ /** * The eloquent definition of a Stock Keeping Unit (SKU). + * + * @property bool $active + * @property int $cost + * @property string $description + * @property int $fee The fee that the tenant pays to us + * @property string $handler_class + * @property string $id + * @property string $name + * @property string $period + * @property ?int $tenant_id + * @property string $title + * @property int $units_free */ class Sku extends Model { @@ -23,6 +35,7 @@ 'active', 'cost', 'description', + 'fee', 'handler_class', 'name', // persist for annual domain registration @@ -59,4 +72,14 @@ 'package_skus' )->using('App\PackageSku')->withPivot(['cost', 'qty']); } + + /** + * The tenant for this SKU. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } } diff --git a/src/app/Tenant.php b/src/app/Tenant.php new file mode 100644 --- /dev/null +++ b/src/app/Tenant.php @@ -0,0 +1,52 @@ +hasMany('App\Discount'); + } + + /** + * SignupInvitations assigned to this tenant. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function signupInvitations() + { + return $this->hasMany('App\SignupInvitation'); + } + + /* + * Returns the wallet of the tanant (reseller's wallet). + * + * @return ?\App\Wallet A wallet object + */ + public function wallet(): ?Wallet + { + $user = \App\User::where('role', 'reseller')->where('tenant_id', $this->id)->first(); + + return $user ? $user->wallets->first() : null; + } +} diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -20,6 +20,7 @@ * @property int $id * @property string $password * @property int $status + * @property int $tenant_id */ class User extends Authenticatable implements JWTSubject { @@ -56,7 +57,7 @@ 'email', 'password', 'password_ldap', - 'status' + 'status', ]; /** @@ -125,6 +126,7 @@ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), + 'fee' => $sku->pivot->fee(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] @@ -178,6 +180,7 @@ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); @@ -192,7 +195,7 @@ /** * Check if current user can delete another object. * - * @param \App\User|\App\Domain $object A user|domain object + * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ @@ -213,13 +216,13 @@ /** * Check if current user can read data of another object. * - * @param \App\User|\App\Domain|\App\Wallet $object A user|domain|wallet object + * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { - if ($this->role == "admin") { + if ($this->role == 'admin') { return true; } @@ -227,6 +230,18 @@ return true; } + if ($this->role == 'reseller') { + if ($object instanceof User && $object->role == 'admin') { + return false; + } + + if ($object instanceof Wallet && !empty($object->owner)) { + $object = $object->owner; + } + + return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; + } + if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } @@ -237,26 +252,38 @@ $wallet = $object->wallet(); - return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); + return $wallet && ($this->wallets->contains($wallet) || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * - * @param \App\User|\App\Domain $object A user|domain object + * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { - if (!method_exists($object, 'wallet')) { - return false; + if ($object instanceof User && $this->id == $object->id) { + return true; } - if ($object instanceof User && $this->id == $object->id) { + if ($this->role == 'admin') { return true; } + if ($this->role == 'reseller') { + if ($object instanceof User && $object->role == 'admin') { + return false; + } + + if ($object instanceof Wallet && !empty($object->owner)) { + $object = $object->owner; + } + + return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; + } + return $this->canDelete($object); } @@ -276,12 +303,19 @@ /** * List the domains to which this user is entitled. + * Note: Active public domains are also returned (for the user tenant). * - * @return Domain[] + * @return Domain[] List of Domain objects */ - public function domains() + public function domains(): array { - $domains = Domain::whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) + if ($this->tenant_id) { + $domains = Domain::where('tenant_id', $this->tenant_id); + } else { + $domains = Domain::withEnvTenant(); + } + + $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE)) ->get() ->all(); @@ -579,6 +613,16 @@ $this->save(); } + /** + * The tenant for this user account. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } + /** * Unsuspend this domain. * diff --git a/src/app/Utils.php b/src/app/Utils.php --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -363,7 +363,6 @@ $countries = include resource_path('countries.php'); $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); $sys_domain = \config('app.domain'); - $path = request()->path(); $opts = [ 'app.name', 'app.url', @@ -382,6 +381,8 @@ if ($req_domain == "admin.$sys_domain") { $env['jsapp'] = 'admin.js'; + } elseif ($req_domain == "reseller.$sys_domain") { + $env['jsapp'] = 'reseller.js'; } $env['paymentProvider'] = \config('services.payment_provider'); diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -76,6 +76,7 @@ return 0; } + $profit = 0; $charges = 0; $discount = $this->getDiscountRate(); @@ -101,8 +102,10 @@ $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); + $fee = (int) ($entitlement->fee * $diff); $charges += $cost; + $profit += $cost - $fee; // if we're in dry-run, you know... if (!$apply) { @@ -126,7 +129,17 @@ } if ($apply) { - $this->debit($charges, $entitlementTransactions); + $this->debit($charges, '', $entitlementTransactions); + + // Credit/debit the reseller + if ($profit != 0 && $this->owner->tenant) { + // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) + if ($wallet = $this->owner->tenant->wallet()) { + $desc = "Charged user {$this->owner->email}"; + $method = $profit > 0 ? 'credit' : 'debit'; + $wallet->{$method}(abs($profit), $desc); + } + } } DB::commit(); @@ -234,12 +247,13 @@ /** * Deduct an amount of pecunia from this wallet's balance. * - * @param int $amount The amount of pecunia to deduct (in cents). - * @param array $eTIDs List of transaction IDs for the individual entitlements that make up - * this debit record, if any. + * @param int $amount The amount of pecunia to deduct (in cents). + * @param string $description The transaction description + * @param array $eTIDs List of transaction IDs for the individual entitlements + * that make up this debit record, if any. * @return Wallet Self */ - public function debit(int $amount, array $eTIDs = []): Wallet + public function debit(int $amount, string $description = '', array $eTIDs = []): Wallet { if ($amount == 0) { return $this; @@ -254,11 +268,14 @@ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_DEBIT, - 'amount' => $amount * -1 + 'amount' => $amount * -1, + 'description' => $description ] ); - \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); + if (!empty($eTIDs)) { + \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); + } return $this; } diff --git a/src/config/app.php b/src/config/app.php --- a/src/config/app.php +++ b/src/config/app.php @@ -65,6 +65,8 @@ 'theme' => env('APP_THEME', 'default'), + 'tenant_id' => env('APP_TENANT_ID', null), + /* |-------------------------------------------------------------------------- | Application Domain diff --git a/src/config/database.php b/src/config/database.php --- a/src/config/database.php +++ b/src/config/database.php @@ -62,6 +62,7 @@ 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, + 'timezone' => '+00:00', 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), diff --git a/src/database/migrations/2019_12_10_100355_create_package_skus_table.php b/src/database/migrations/2019_12_10_100355_create_package_skus_table.php --- a/src/database/migrations/2019_12_10_100355_create_package_skus_table.php +++ b/src/database/migrations/2019_12_10_100355_create_package_skus_table.php @@ -21,7 +21,6 @@ $table->string('package_id', 36); $table->string('sku_id', 36); $table->integer('qty')->default(1); - $table->integer('cost')->default(0)->nullable(); $table->foreign('package_id')->references('id')->on('packages') diff --git a/src/database/migrations/2020_05_05_095212_create_tenants_table.php b/src/database/migrations/2020_05_05_095212_create_tenants_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2020_05_05_095212_create_tenants_table.php @@ -0,0 +1,84 @@ +bigIncrements('id'); + $table->string('title', 32); + $table->timestamps(); + } + ); + + \App\Tenant::create(['title' => 'Kolab Now']); + + foreach (['users', 'discounts', 'domains', 'plans', 'packages', 'skus'] as $table_name) { + Schema::table( + $table_name, + function (Blueprint $table) { + $table->bigInteger('tenant_id')->unsigned()->nullable(); + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); + } + ); + + if ($tenant_id = \config('app.tenant_id')) { + DB::statement("UPDATE `{$table_name}` SET `tenant_id` = {$tenant_id}"); + } + } + + // Add fee column + foreach (['entitlements', 'skus'] as $table) { + Schema::table( + $table, + function (Blueprint $table) { + $table->integer('fee')->nullable(); + } + ); + } + + // FIXME: Should we also have package_skus.fee ? + // We have package_skus.cost, but I think it is not used anywhere. + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + foreach (['users', 'discounts', 'domains', 'plans', 'packages', 'skus'] as $table_name) { + Schema::table( + $table_name, + function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropColumn('tenant_id'); + } + ); + } + + foreach (['entitlements', 'skus'] as $table) { + Schema::table( + $table, + function (Blueprint $table) { + $table->dropColumn('fee'); + } + ); + } + + Schema::dropIfExists('tenants'); + } +} diff --git a/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php b/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php --- a/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php +++ b/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php @@ -15,14 +15,20 @@ public function up() { $beta_sku = \App\Sku::where('title', 'beta')->first(); - $beta_sku->name = 'Private Beta (invitation only)'; - $beta_sku->description = 'Access to the private beta program subscriptions'; - $beta_sku->save(); + + if ($beta_sku) { + $beta_sku->name = 'Private Beta (invitation only)'; + $beta_sku->description = 'Access to the private beta program subscriptions'; + $beta_sku->save(); + } $meet_sku = \App\Sku::where('title', 'meet')->first(); - $meet_sku->name = 'Voice & Video Conferencing (public beta)'; - $meet_sku->handler_class = 'App\Handlers\Meet'; - $meet_sku->save(); + + if ($meet_sku) { + $meet_sku->name = 'Voice & Video Conferencing (public beta)'; + $meet_sku->handler_class = 'App\Handlers\Meet'; + $meet_sku->save(); + } } /** diff --git a/src/database/migrations/2021_03_26_080000_create_signup_invitations_table.php b/src/database/migrations/2021_03_26_080000_create_signup_invitations_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2021_03_26_080000_create_signup_invitations_table.php @@ -0,0 +1,49 @@ +string('id', 36); + $table->string('email'); + $table->smallInteger('status'); + $table->bigInteger('user_id')->nullable(); + $table->bigInteger('tenant_id')->unsigned()->nullable(); + $table->timestamps(); + + $table->primary('id'); + + $table->index('email'); + $table->index('created_at'); + + $table->foreign('tenant_id')->references('id')->on('tenants') + ->onUpdate('cascade')->onDelete('set null'); + $table->foreign('user_id')->references('id')->on('users') + ->onUpdate('cascade')->onDelete('set null'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('signup_invitations'); + } +} diff --git a/src/database/migrations/2021_05_12_150000_groups_add_tenant_id.php b/src/database/migrations/2021_05_12_150000_groups_add_tenant_id.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2021_05_12_150000_groups_add_tenant_id.php @@ -0,0 +1,46 @@ +bigInteger('tenant_id')->unsigned()->nullable(); + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); + } + ); + + if ($tenant_id = \config('app.tenant_id')) { + DB::statement("UPDATE `groups` SET `tenant_id` = {$tenant_id}"); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'groups', + function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropColumn('tenant_id'); + } + ); + } +} diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php --- a/src/database/seeds/DatabaseSeeder.php +++ b/src/database/seeds/DatabaseSeeder.php @@ -14,6 +14,7 @@ { // Define seeders order $seeders = [ + 'TenantSeeder', 'DiscountSeeder', 'DomainSeeder', 'SkuSeeder', diff --git a/src/database/seeds/local/DomainSeeder.php b/src/database/seeds/local/DomainSeeder.php --- a/src/database/seeds/local/DomainSeeder.php +++ b/src/database/seeds/local/DomainSeeder.php @@ -63,5 +63,19 @@ ] ); } + + // example tenant domain, note that 'tenant_id' is not a fillable. + $domain = Domain::create( + [ + 'namespace' => 'example-tenant.dev-local', + 'status' => Domain::STATUS_CONFIRMED + Domain::STATUS_ACTIVE, + 'type' => Domain::TYPE_PUBLIC + ] + ); + + $tenant = \App\Tenant::where('title', 'Sample Tenant')->first(); + + $domain->tenant_id = $tenant->id; + $domain->save(); } } diff --git a/src/database/seeds/local/TenantSeeder.php b/src/database/seeds/local/TenantSeeder.php new file mode 100644 --- /dev/null +++ b/src/database/seeds/local/TenantSeeder.php @@ -0,0 +1,29 @@ + 'Kolab Now' + ]); + } + + if (!Tenant::find(2)) { + Tenant::create([ + 'title' => 'Sample Tenant' + ]); + } + } +} diff --git a/src/database/seeds/local/UserSeeder.php b/src/database/seeds/local/UserSeeder.php --- a/src/database/seeds/local/UserSeeder.php +++ b/src/database/seeds/local/UserSeeder.php @@ -145,5 +145,30 @@ $jeroen->role = 'admin'; $jeroen->save(); + + $tenant1 = \App\Tenant::where('title', 'Kolab Now')->first(); + $tenant2 = \App\Tenant::where('title', 'Sample Tenant')->first(); + + $reseller1 = User::create( + [ + 'email' => 'reseller@kolabnow.com', + 'password' => 'reseller', + ] + ); + + $reseller1->tenant_id = $tenant1->id; + $reseller1->role = 'reseller'; + $reseller1->save(); + + $reseller2 = User::create( + [ + 'email' => 'reseller@reseller.com', + 'password' => 'reseller', + ] + ); + + $reseller2->tenant_id = $tenant2->id; + $reseller2->role = 'reseller'; + $reseller2->save(); } } diff --git a/src/phpstan.neon b/src/phpstan.neon --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -4,8 +4,12 @@ ignoreErrors: - '#Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable#' - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' + - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenant\(\)#' + - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withUserTenant\(\)#' - '#Call to an undefined method Tests\\Browser::#' level: 4 + parallel: + processTimeout: 300.0 paths: - app/ - tests/ diff --git a/src/resources/js/admin.js b/src/resources/js/admin/app.js rename from src/resources/js/admin.js rename to src/resources/js/admin/app.js --- a/src/resources/js/admin.js +++ b/src/resources/js/admin/app.js @@ -2,9 +2,9 @@ * Application code for the admin UI */ -import routes from './routes-admin.js' +import routes from './routes.js' window.routes = routes window.isAdmin = true -require('./app') +require('../app') diff --git a/src/resources/js/routes-admin.js b/src/resources/js/admin/routes.js rename from src/resources/js/routes-admin.js rename to src/resources/js/admin/routes.js --- a/src/resources/js/routes-admin.js +++ b/src/resources/js/admin/routes.js @@ -1,11 +1,11 @@ -import DashboardComponent from '../vue/Admin/Dashboard' -import DistlistComponent from '../vue/Admin/Distlist' -import DomainComponent from '../vue/Admin/Domain' -import LoginComponent from '../vue/Login' -import LogoutComponent from '../vue/Logout' -import PageComponent from '../vue/Page' -import StatsComponent from '../vue/Admin/Stats' -import UserComponent from '../vue/Admin/User' +import DashboardComponent from '../../vue/Admin/Dashboard' +import DistlistComponent from '../../vue/Admin/Distlist' +import DomainComponent from '../../vue/Admin/Domain' +import LoginComponent from '../../vue/Login' +import LogoutComponent from '../../vue/Logout' +import PageComponent from '../../vue/Page' +import StatsComponent from '../../vue/Admin/Stats' +import UserComponent from '../../vue/Admin/User' const routes = [ { diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -79,7 +79,7 @@ router: window.router, data() { return { - isAdmin: window.isAdmin, + isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] @@ -462,7 +462,7 @@ } if (input.length) { - // Create an error message\ + // Create an error message // API responses can use a string, array or object let msg_text = '' if ($.type(msg) !== 'string') { diff --git a/src/resources/js/reseller/app.js b/src/resources/js/reseller/app.js new file mode 100644 --- /dev/null +++ b/src/resources/js/reseller/app.js @@ -0,0 +1,10 @@ +/** + * Application code for the reseller UI + */ + +import routes from './routes.js' + +window.routes = routes +window.isReseller = true + +require('../app') diff --git a/src/resources/js/routes-admin.js b/src/resources/js/reseller/routes.js rename from src/resources/js/routes-admin.js rename to src/resources/js/reseller/routes.js --- a/src/resources/js/routes-admin.js +++ b/src/resources/js/reseller/routes.js @@ -1,11 +1,13 @@ -import DashboardComponent from '../vue/Admin/Dashboard' -import DistlistComponent from '../vue/Admin/Distlist' -import DomainComponent from '../vue/Admin/Domain' -import LoginComponent from '../vue/Login' -import LogoutComponent from '../vue/Logout' -import PageComponent from '../vue/Page' -import StatsComponent from '../vue/Admin/Stats' -import UserComponent from '../vue/Admin/User' +import DashboardComponent from '../../vue/Reseller/Dashboard' +import DistlistComponent from '../../vue/Admin/Distlist' +import DomainComponent from '../../vue/Admin/Domain' +import InvitationsComponent from '../../vue/Reseller/Invitations' +import LoginComponent from '../../vue/Login' +import LogoutComponent from '../../vue/Logout' +import PageComponent from '../../vue/Page' +import StatsComponent from '../../vue/Reseller/Stats' +import UserComponent from '../../vue/Admin/User' +import WalletComponent from '../../vue/Wallet' const routes = [ { @@ -40,6 +42,12 @@ name: 'logout', component: LogoutComponent }, + { + path: '/invitations', + name: 'invitations', + component: InvitationsComponent, + meta: { requiresAuth: true } + }, { path: '/stats', name: 'stats', @@ -52,6 +60,12 @@ component: UserComponent, meta: { requiresAuth: true } }, + { + path: '/wallet', + name: 'wallet', + component: WalletComponent, + meta: { requiresAuth: true } + }, { name: '404', path: '*', diff --git a/src/resources/js/user.js b/src/resources/js/user/app.js rename from src/resources/js/user.js rename to src/resources/js/user/app.js --- a/src/resources/js/user.js +++ b/src/resources/js/user/app.js @@ -2,9 +2,10 @@ * Application code for the user UI */ -import routes from './routes-user.js' +import routes from './routes.js' window.routes = routes window.isAdmin = false +window.isReseller = false -require('./app') +require('../app') diff --git a/src/resources/js/routes-user.js b/src/resources/js/user/routes.js rename from src/resources/js/routes-user.js rename to src/resources/js/user/routes.js --- a/src/resources/js/routes-user.js +++ b/src/resources/js/user/routes.js @@ -1,24 +1,24 @@ -import DashboardComponent from '../vue/Dashboard' -import DistlistInfoComponent from '../vue/Distlist/Info' -import DistlistListComponent from '../vue/Distlist/List' -import DomainInfoComponent from '../vue/Domain/Info' -import DomainListComponent from '../vue/Domain/List' -import LoginComponent from '../vue/Login' -import LogoutComponent from '../vue/Logout' -import MeetComponent from '../vue/Rooms' -import PageComponent from '../vue/Page' -import PasswordResetComponent from '../vue/PasswordReset' -import SignupComponent from '../vue/Signup' -import UserInfoComponent from '../vue/User/Info' -import UserListComponent from '../vue/User/List' -import UserProfileComponent from '../vue/User/Profile' -import UserProfileDeleteComponent from '../vue/User/ProfileDelete' -import WalletComponent from '../vue/Wallet' +import DashboardComponent from '../../vue/Dashboard' +import DistlistInfoComponent from '../../vue/Distlist/Info' +import DistlistListComponent from '../../vue/Distlist/List' +import DomainInfoComponent from '../../vue/Domain/Info' +import DomainListComponent from '../../vue/Domain/List' +import LoginComponent from '../../vue/Login' +import LogoutComponent from '../../vue/Logout' +import MeetComponent from '../../vue/Rooms' +import PageComponent from '../../vue/Page' +import PasswordResetComponent from '../../vue/PasswordReset' +import SignupComponent from '../../vue/Signup' +import UserInfoComponent from '../../vue/User/Info' +import UserListComponent from '../../vue/User/List' +import UserProfileComponent from '../../vue/User/Profile' +import UserProfileDeleteComponent from '../../vue/User/ProfileDelete' +import WalletComponent from '../../vue/Wallet' // Here's a list of lazy-loaded components // Note: you can pack multiple components into the same chunk, webpackChunkName // is also used to get a sensible file name instead of numbers -const RoomComponent = () => import(/* webpackChunkName: "room" */ '../vue/Meet/Room.vue') +const RoomComponent = () => import(/* webpackChunkName: "room" */ '../../vue/Meet/Room.vue') const routes = [ { @@ -90,6 +90,11 @@ component: MeetComponent, meta: { requiresAuth: true } }, + { + path: '/signup/invite/:param', + name: 'signup-invite', + component: SignupComponent + }, { path: '/signup/:param?', alias: '/signup/voucher/:param', diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -57,6 +57,12 @@ 'search-foundxgroups' => ':x distribution lists have been found.', 'search-foundxusers' => ':x user accounts have been found.', + 'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.', + 'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.', + 'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.', + 'signup-invitation-delete-success' => 'Invitation deleted successfully.', + 'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.', + 'support-request-success' => 'Support request submitted successfully.', 'support-request-error' => 'Failed to submit the support request.', diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -75,6 +75,11 @@ 'signupcode-body1' => "This is your verification code for the :site registration process:", 'signupcode-body2' => "You can also click the link below to continue the registration process:", + 'signupinvitation-subject' => ":site Invitation", + 'signupinvitation-header' => "Hi,", + 'signupinvitation-body1' => "You have been invited to join :site. Click the link below to sign up.", + 'signupinvitation-body2' => "", + 'suspendeddebtor-subject' => ":site Account Suspended", 'suspendeddebtor-body' => "You have been behind on paying for your :site account " ."for over :days days. Your account has been suspended.", diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -123,7 +123,10 @@ } table { - td.buttons, + th { + white-space: nowrap; + } + td.email, td.price, td.datetime, @@ -132,6 +135,7 @@ white-space: nowrap; } + td.buttons, th.price, td.price { width: 1%; @@ -287,6 +291,13 @@ opacity: 0.6; } + // Some icons are too big, scale them down + &.link-invitations { + svg { + transform: scale(0.9); + } + } + .badge { position: absolute; top: 0.5rem; diff --git a/src/resources/themes/default/theme.json b/src/resources/themes/default/theme.json --- a/src/resources/themes/default/theme.json +++ b/src/resources/themes/default/theme.json @@ -3,23 +3,27 @@ { "label": "explore", "location": "https://kolabnow.com/", - "admin": true + "admin": true, + "reseller": true }, { "label": "blog", "location": "https://blogs.kolabnow.com/", - "admin": true + "admin": true, + "reseller": true }, { "label": "support", "location": "/support", "page": "support", - "admin": true + "admin": true, + "reseller": true }, { "label": "tos", "location": "https://kolabnow.com/tos", - "footer": true + "footer": true, + "reseller": true } ], "faq": { diff --git a/src/resources/views/emails/html/signup_invitation.blade.php b/src/resources/views/emails/html/signup_invitation.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/signup_invitation.blade.php @@ -0,0 +1,18 @@ + + + + + + +

{{ __('mail.signupinvitation-header') }}

+ +

{{ __('mail.signupinvitation-body1', ['site' => $site]) }}

+ +

{!! $href !!}

+ +

{{ __('mail.signupinvitation-body2') }}

+ +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/plain/signup_invitation.blade.php b/src/resources/views/emails/plain/signup_invitation.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/plain/signup_invitation.blade.php @@ -0,0 +1,11 @@ +{!! __('mail.signupinvitation-header') !!} + +{!! __('mail.signupinvitation-body1', ['site' => $site]) !!} + +{!! $href !!} + +{!! __('mail.signupinvitation-body2') !!} + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/vue/Admin/Dashboard.vue b/src/resources/vue/Admin/Dashboard.vue --- a/src/resources/vue/Admin/Dashboard.vue +++ b/src/resources/vue/Admin/Dashboard.vue @@ -1,42 +1,6 @@ diff --git a/src/resources/vue/Admin/Stats.vue b/src/resources/vue/Admin/Stats.vue --- a/src/resources/vue/Admin/Stats.vue +++ b/src/resources/vue/Admin/Stats.vue @@ -9,11 +9,12 @@ export default { data() { return { - charts: {} + charts: {}, + chartTypes: ['users', 'users-all', 'income', 'discounts'] } }, mounted() { - ['users', 'users-all', 'income', 'discounts'].forEach(chart => this.loadChart(chart)) + this.chartTypes.forEach(chart => this.loadChart(chart)) }, methods: { drawChart(name, data) { diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -23,7 +23,7 @@ -
+
@@ -43,8 +43,8 @@
diff --git a/src/resources/vue/Reseller/Dashboard.vue b/src/resources/vue/Reseller/Dashboard.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Reseller/Dashboard.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/resources/vue/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Reseller/Invitations.vue @@ -0,0 +1,283 @@ + + + diff --git a/src/resources/vue/Reseller/Stats.vue b/src/resources/vue/Reseller/Stats.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Reseller/Stats.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue --- a/src/resources/vue/Signup.vue +++ b/src/resources/vue/Signup.vue @@ -1,6 +1,6 @@