├── src ├── Helpers │ └── helpers.php ├── Models │ ├── PointContract.php │ └── Point.php ├── Exceptions │ └── PointModelMissingException.php ├── Royalty.php ├── Console │ ├── stubs │ │ └── action.stub │ ├── RoyaltyActions.php │ ├── RoyaltySetup.php │ └── RoyaltyAction.php ├── Facades │ └── Royalty.php ├── Actions │ └── ActionAbstract.php ├── EventServiceProvider.php ├── Traits │ └── CollectsPoints.php ├── Events │ └── PointsGiven.php ├── Formatters │ └── PointsFormatter.php └── RoyaltyServiceProvider.php ├── .gitignore ├── tests ├── Points │ └── Actions │ │ ├── Subscriber.php │ │ ├── CompletedTask.php │ │ └── DeleteablePoint.php ├── database │ ├── factories │ │ ├── PointFactory.php │ │ └── UserTestFactory.php │ ├── migrations │ │ ├── 2020_02_19_125056_create_points_table.php │ │ └── 2020_02_19_130849_create_point_user_table.php │ └── seeds │ │ └── PointTableSeeder.php ├── Unit │ ├── ActionTest.php │ └── PointTest.php ├── Models │ └── User.php ├── TestCase.php └── Feature │ └── CollectPointsTest.php ├── phpunit.xml ├── database └── migrations │ ├── create_points_table.php.stub │ └── create_point_user_table.php.stub ├── resources └── js │ └── components │ └── RoyaltyBadge.vue.stub ├── LICENSE.md ├── config └── laravel-royalty.php ├── composer.json └── README.md /src/Helpers/helpers.php: -------------------------------------------------------------------------------- 1 | define(Point::class, function (Faker $faker) { 9 | return [ 10 | 'name' => $name = $faker->unique()->colorName, 11 | 'key' => \Illuminate\Support\Str::slug($name), 12 | 'points' => $faker->randomElement(range(10, 100, 20)), 13 | 'description' => $faker->text(60), 14 | ]; 15 | }); 16 | -------------------------------------------------------------------------------- /src/Actions/ActionAbstract.php: -------------------------------------------------------------------------------- 1 | key())->first(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./src 6 | 7 | 8 | 9 | 10 | ./tests/ 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/EventServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 18 | // 19 | ], 20 | ]; 21 | 22 | /** 23 | * Register any events for your application. 24 | * 25 | * @return void 26 | */ 27 | public function boot() 28 | { 29 | parent::boot(); 30 | 31 | // 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Unit/ActionTest.php: -------------------------------------------------------------------------------- 1 | assertTrue(get_parent_class(new Subscriber()) == ActionAbstract::class); 19 | } 20 | /** 21 | * Test an action has a "key" method. 22 | * 23 | * @test 24 | */ 25 | public function action_has_key() 26 | { 27 | $action = new Subscriber(); 28 | 29 | $this->assertTrue(method_exists($action, 'key')); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Models/User.php: -------------------------------------------------------------------------------- 1 | 'datetime', 39 | ]; 40 | } 41 | -------------------------------------------------------------------------------- /database/migrations/create_points_table.php.stub: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->string('name'); 19 | $table->string('key')->unique(); 20 | $table->string('description')->nullable(); 21 | $table->bigInteger('points'); 22 | $table->nestedSet(); 23 | $table->timestamps(); 24 | $table->softDeletes(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('points'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/database/factories/UserTestFactory.php: -------------------------------------------------------------------------------- 1 | define(User::class, function (Faker $faker) { 20 | return [ 21 | 'name' => $faker->name, 22 | 'email' => $faker->unique()->safeEmail, 23 | 'email_verified_at' => now(), 24 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 25 | 'remember_token' => Str::random(10), 26 | ]; 27 | }); 28 | -------------------------------------------------------------------------------- /tests/database/migrations/2020_02_19_125056_create_points_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->string('name'); 19 | $table->string('key')->unique(); 20 | $table->string('description')->nullable(); 21 | $table->bigInteger('points'); 22 | $table->nestedSet(); 23 | $table->timestamps(); 24 | $table->softDeletes(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('points'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /resources/js/components/RoyaltyBadge.vue.stub: -------------------------------------------------------------------------------- 1 | 6 | 7 | 38 | 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Cuthbert Mirambo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Models/Point.php: -------------------------------------------------------------------------------- 1 | parent ? Str::slug($point->parent->name . ' ' . $point->name, '-') : $point->key; 38 | 39 | $point->key = $key; 40 | }); 41 | } 42 | 43 | /** 44 | * Get users with this point. 45 | * 46 | * @return BelongsToMany 47 | */ 48 | public function users() 49 | { 50 | return $this->belongsToMany(config('royalty.user.model')) 51 | ->withTimestamps(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /config/laravel-royalty.php: -------------------------------------------------------------------------------- 1 | [ 11 | 12 | /* 13 | * 14 | * User model. 15 | * 16 | */ 17 | 'model' => 'App\User', 18 | 19 | /* 20 | * 21 | * Users table. 22 | * 23 | * The table to reference users from. 24 | * 25 | */ 26 | 'table' => null, 27 | ], 28 | 29 | /* 30 | * 31 | * Point settings 32 | * 33 | */ 34 | 'point' => [ 35 | 36 | /* 37 | * 38 | * Points model. 39 | * 40 | * The model to use for points. 41 | * 42 | */ 43 | 'model' => \Miracuthbert\Royalty\Models\Point::class, 44 | 45 | /* 46 | * 47 | * Actions Path. 48 | * 49 | * The namespace under 'app' to place created points. 50 | * 51 | */ 52 | 'actions_path' => 'Points\Actions', 53 | ], 54 | 55 | /* 56 | * 57 | * Broadcast settings 58 | * 59 | */ 60 | 'broadcast' => [ 61 | 62 | /* 63 | * 64 | * Event broadcast name. 65 | * 66 | * The users channel to broadcast to. 67 | * 68 | */ 69 | 'name' => 'points-given', 70 | 71 | /* 72 | * 73 | * Broadcast channel. 74 | * 75 | * The prefix for the users channel to broadcast to. 76 | * 77 | * For example: 'users.' will be 'users.{id}' 78 | * 79 | */ 80 | 'channel' => 'users.', 81 | ], 82 | ]; 83 | -------------------------------------------------------------------------------- /src/Traits/CollectsPoints.php: -------------------------------------------------------------------------------- 1 | pointsRelation->sum('points') 32 | ); 33 | } 34 | 35 | /** 36 | * Add given point to user. 37 | * 38 | * @param \Miracuthbert\Royalty\Actions\ActionAbstract $action 39 | * @return void 40 | * @throws PointModelMissingException 41 | */ 42 | public function givePoints(ActionAbstract $action) 43 | { 44 | if (!$model = $action->getModel()) { 45 | throw new PointModelMissingException( 46 | __('Points model for key [:key] not found.', ['key' => $action->key()]) 47 | ); 48 | } 49 | 50 | $this->pointsRelation()->attach($model); 51 | 52 | event(new PointsGiven($this, $model)); 53 | } 54 | 55 | /** 56 | * Get the user's points. 57 | * 58 | * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany 59 | */ 60 | public function pointsRelation() 61 | { 62 | return $this->belongsToMany(config('royalty.point.model')) 63 | ->withTimestamps(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /database/migrations/create_point_user_table.php.stub: -------------------------------------------------------------------------------- 1 | users_table = $model->getTable(); 35 | 36 | $this->users_foreign_key = $model->getForeignKey(); 37 | } 38 | 39 | /** 40 | * Run the migrations. 41 | * 42 | * @return void 43 | */ 44 | public function up() 45 | { 46 | Schema::create('point_user', function (Blueprint $table) { 47 | $table->bigIncrements('id'); 48 | $table->unsignedBigInteger('point_id')->index(); 49 | $table->unsignedBigInteger('user_id')->index(); 50 | $table->timestamps(); 51 | 52 | $table->foreign('point_id')->references('id')->on('points')->onDelete('cascade'); 53 | $table->foreign($this->users_foreign_key) 54 | ->references('id') 55 | ->on(config('royalty.user.table') ?? $this->users_table) 56 | ->onDelete('cascade'); 57 | }); 58 | } 59 | 60 | /** 61 | * Reverse the migrations. 62 | * 63 | * @return void 64 | */ 65 | public function down() 66 | { 67 | Schema::dropIfExists('point_user'); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/database/migrations/2020_02_19_130849_create_point_user_table.php: -------------------------------------------------------------------------------- 1 | users_table = $model->getTable(); 35 | 36 | $this->users_foreign_key = $model->getForeignKey(); 37 | } 38 | 39 | /** 40 | * Run the migrations. 41 | * 42 | * @return void 43 | */ 44 | public function up() 45 | { 46 | Schema::create('point_user', function (Blueprint $table) { 47 | $table->bigIncrements('id'); 48 | $table->unsignedBigInteger('point_id')->index(); 49 | $table->unsignedBigInteger('user_id')->index(); 50 | $table->timestamps(); 51 | 52 | $table->foreign('point_id')->references('id')->on('points')->onDelete('cascade'); 53 | $table->foreign($this->users_foreign_key) 54 | ->references('id') 55 | ->on(config('royalty.user.table', $this->users_table)) 56 | ->onDelete('cascade'); 57 | }); 58 | } 59 | 60 | /** 61 | * Reverse the migrations. 62 | * 63 | * @return void 64 | */ 65 | public function down() 66 | { 67 | Schema::dropIfExists('point_user'); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miracuthbert/laravel-royalty", 3 | "description": "A user points package for Laravel that can be used to give rewards, loyalty or experience points with real time support", 4 | "keywords": [ 5 | "miracuthbert", 6 | "laravel", 7 | "royalty", 8 | "loyalty-points", 9 | "user-points", 10 | "user-experience-points", 11 | "user-rewards-points" 12 | ], 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Cuthbert Mirambo", 17 | "email": "miracuthbert@gmail.com" 18 | } 19 | ], 20 | "require": { 21 | "php": "^7.3|^8.0", 22 | "illuminate/console": "^7.0|^8.0|^9.0|^10.0", 23 | "illuminate/database": "^7.0|^8.0|^9.0|^10.0", 24 | "illuminate/events": "^7.0|^8.0|^9.0|^10.0", 25 | "illuminate/http": "^7.0|^8.0|^9.0|^10.0", 26 | "illuminate/support": "^7.0|^8.0|^9.0|^10.0", 27 | "kalnoy/nestedset": ">=5.0" 28 | }, 29 | "require-dev": { 30 | "phpunit/phpunit": ">=8.5", 31 | "orchestra/testbench": ">=4.0", 32 | "laravel/legacy-factories": ">=1.0.4" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Miracuthbert\\Royalty\\": "src/" 37 | }, 38 | "files": [ 39 | "src/Helpers/helpers.php" 40 | ] 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "Miracuthbert\\Royalty\\Tests\\": "tests/" 45 | }, 46 | "classmap": [ 47 | "tests/database/seeds" 48 | ] 49 | }, 50 | "minimum-stability": "dev", 51 | "prefer-stable": true, 52 | "extra": { 53 | "laravel": { 54 | "providers": [ 55 | "Miracuthbert\\Royalty\\RoyaltyServiceProvider" 56 | ], 57 | "aliases": { 58 | "Royalty": "Miracuthbert\\Royalty\\Facades\\Royalty" 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Events/PointsGiven.php: -------------------------------------------------------------------------------- 1 | user = $user; 41 | $this->point = $point; 42 | } 43 | 44 | /** 45 | * The event's broadcast name. 46 | * 47 | * @return string 48 | */ 49 | public function broadcastAs() 50 | { 51 | return config('royalty.broadcast.name'); 52 | } 53 | 54 | /** 55 | * Get the data to broadcast. 56 | * 57 | * @return array 58 | */ 59 | public function broadcastWith() 60 | { 61 | return [ 62 | 'point' => $this->point, 63 | 'user_points' => [ 64 | 'number' => $this->user->points()->number(), 65 | 'shorthand' => $this->user->points()->shorthand(), 66 | ], 67 | ]; 68 | } 69 | 70 | /** 71 | * Get the channels the event should broadcast on. 72 | * 73 | * @return \Illuminate\Broadcasting\Channel|array 74 | */ 75 | public function broadcastOn() 76 | { 77 | return new PrivateChannel(config('royalty.broadcast.channel') . $this->user->id); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Formatters/PointsFormatter.php: -------------------------------------------------------------------------------- 1 | points = $points; 23 | } 24 | 25 | /** 26 | * Get the absolute points value. 27 | * 28 | * @return int 29 | */ 30 | public function value() 31 | { 32 | return $this->points; 33 | } 34 | 35 | /** 36 | * Get a formatted number value. 37 | * 38 | * @return string 39 | */ 40 | public function number() 41 | { 42 | return number_format($this->value()); 43 | } 44 | 45 | /** 46 | * Get the shorthand value. 47 | * 48 | * @return int|string 49 | */ 50 | public function shorthand() 51 | { 52 | $points = $this->value(); 53 | 54 | if ($points === 0) { 55 | return 0; 56 | } 57 | 58 | switch ($points) { 59 | case $points < 1000: 60 | return number_format($points); 61 | break; 62 | case $points < 1000000: 63 | return sprintf( 64 | '%sk', 65 | (float)number_format($points / 1000, 1) 66 | ); 67 | break; 68 | case $points < 1000000000: 69 | return sprintf( 70 | '%sm', 71 | (float)number_format($points / 1000000, 1) 72 | ); 73 | break; 74 | case $points < 1000000000000: 75 | return sprintf( 76 | '%sb', 77 | (float)number_format($points / 1000000000, 1) 78 | ); 79 | break; 80 | case $points < 1000000000000000: 81 | return sprintf( 82 | '%st', 83 | (float)number_format($points / 1000000000000, 1) 84 | ); 85 | break; 86 | default: 87 | return; 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/Unit/PointTest.php: -------------------------------------------------------------------------------- 1 | create(['name' => 'Red', 'key' => $key]); 21 | 22 | $this->assertEquals($key, $point->key); 23 | } 24 | 25 | /** 26 | * Test a point has correct points. 27 | * 28 | * @test 29 | */ 30 | public function a_point_has_correct_points() 31 | { 32 | $points = 100; 33 | 34 | $point = factory(Point::class)->create(['name' => 'Green', 'key' => 'green', 'points' => $points]); 35 | 36 | $this->assertEquals($points, $point->points); 37 | } 38 | 39 | /** 40 | * Test a point has a name. 41 | * 42 | * @test 43 | */ 44 | public function a_point_has_correct_name() 45 | { 46 | $name = 'Yellow'; 47 | 48 | $point = factory(Point::class)->create(['name' => $name, 'key' => 'yellow']); 49 | 50 | $this->assertEquals($name, $point->name); 51 | } 52 | 53 | /** 54 | * Test a point can be deleted. 55 | * 56 | * @test 57 | */ 58 | public function a_point_can_be_deleted() 59 | { 60 | $user = factory(User::class)->create(); 61 | 62 | $point = new DeleteablePoint(); 63 | 64 | $pointModel = $point->getModel()->toArray(); 65 | 66 | $this->assertDatabaseHas('points', $pointModel); 67 | 68 | $user->givePoints($point); 69 | 70 | $this->assertCount(1, $point->getModel()->users()->get()); 71 | 72 | $point->getModel()->delete(); 73 | 74 | $this->assertNotContains('points', $pointModel); 75 | $this->assertCount(0, $user->pointsRelation()->where('key', $point->key())->get()); 76 | $this->assertCount(0, Point::where('key', $point->key())->get()); 77 | } 78 | 79 | /** 80 | * Test an action point exists. 81 | * 82 | * @test 83 | */ 84 | public function action_has_point_match_in_db() 85 | { 86 | $point = new Subscriber(); 87 | 88 | $this->assertDatabaseHas('points', $point->getModel()->toArray()); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | loadLaravelMigrations(['--database' => 'testbench']); 27 | 28 | // call migrations specific to our tests, e.g. to seed the db 29 | // the path option should be an absolute path. 30 | $this->loadMigrationsFrom([ 31 | '--database' => 'testbench', 32 | '--path' => realpath(__DIR__ . '/database/migrations'), 33 | ]); 34 | 35 | $this->seed(PointTableSeeder::class); 36 | 37 | // factories 38 | $this->withFactories(__DIR__ . '/database/factories'); 39 | } 40 | 41 | /** 42 | * Clean up the testing environment before the next test. 43 | * 44 | * @return void 45 | */ 46 | protected function tearDown(): void 47 | { 48 | // $this->artisan('migrate:rollback'); 49 | } 50 | 51 | 52 | /** 53 | * Get package providers. 54 | * 55 | * @param \Illuminate\Foundation\Application $app 56 | * 57 | * @return array 58 | */ 59 | protected function getPackageProviders($app) 60 | { 61 | return [ 62 | NestedSetServiceProvider::class, 63 | RoyaltyServiceProvider::class 64 | ]; 65 | } 66 | 67 | /** 68 | * Define environment setup. 69 | * 70 | * @param \Illuminate\Foundation\Application $app 71 | * @return void 72 | */ 73 | protected function getEnvironmentSetUp($app) 74 | { 75 | // Setup laravel roles user model 76 | $app['config']->set('royalty.user.model', User::class); 77 | 78 | $app['config']->set('database.default', 'testbench'); 79 | 80 | $app['config']->set('royalty.point.model', Point::class); 81 | 82 | // Setup default database to use sqlite :memory: 83 | $app['config']->set('database.connections.testbench', [ 84 | 'driver' => 'sqlite', 85 | 'database' => ':memory:', 86 | 'prefix' => '', 87 | ]); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/RoyaltyServiceProvider.php: -------------------------------------------------------------------------------- 1 | configure(); 20 | 21 | $this->app->register(EventServiceProvider::class); 22 | } 23 | 24 | /** 25 | * Bootstrap services. 26 | * 27 | * @return void 28 | */ 29 | public function boot() 30 | { 31 | $this->registerPublishing(); 32 | 33 | $this->registerCommands(); 34 | } 35 | 36 | /** 37 | * Setup configuration for the package. 38 | */ 39 | protected function configure() 40 | { 41 | $this->mergeConfigFrom( 42 | __DIR__ . '/../config/laravel-royalty.php', 'royalty' 43 | ); 44 | } 45 | 46 | /** 47 | * Register the package's publishable resources. 48 | * 49 | * @return void 50 | */ 51 | protected function registerPublishing() 52 | { 53 | if ($this->app->runningInConsole()) { 54 | 55 | // publish config 56 | $this->publishes([ 57 | __DIR__ . '/../config/laravel-royalty.php' => config_path('royalty.php'), 58 | ], Royalty::ROYALTY_CONFIG); 59 | 60 | // publish migrations 61 | $this->publishes([ 62 | __DIR__ . '/../database/migrations/create_points_table.php.stub' => database_path('migrations/' . date('Y_m_d_His', time()) . '_create_points_table.php'), 63 | __DIR__ . '/../database/migrations/create_point_user_table.php.stub' => database_path('migrations/' . date('Y_m_d_His', strtotime('+2 seconds')) . '_create_point_user_table.php'), 64 | ], Royalty::ROYALTY_MIGRATIONS); 65 | 66 | // publish components 67 | $this->publishes([ 68 | __DIR__ . '/../resources/js/components/RoyaltyBadge.vue.stub' => resource_path('js/components/royalty/RoyaltyBadge.vue'), 69 | ], Royalty::ROYALTY_COMPONENTS); 70 | } 71 | } 72 | 73 | /** 74 | * Register the package's commands. 75 | * 76 | * @return void 77 | */ 78 | protected function registerCommands() 79 | { 80 | if ($this->app->runningInConsole()) { 81 | $this->commands([ 82 | RoyaltySetup::class, 83 | RoyaltyAction::class, 84 | RoyaltyActions::class, 85 | ]); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Console/RoyaltyActions.php: -------------------------------------------------------------------------------- 1 | files = $files; 45 | } 46 | 47 | /** 48 | * Execute the console command. 49 | * 50 | * @return mixed 51 | */ 52 | public function handle() 53 | { 54 | $startTime = microtime(true); 55 | 56 | $path = $this->files->allFiles( 57 | app_path(str_replace('\\', DIRECTORY_SEPARATOR, config('royalty.point.actions_path'))) 58 | ); 59 | 60 | $points = config('royalty.point.model')::get(['id', 'name', 'points', 'key'])->map(function ($action) use ($path) { 61 | return $this->getActionFile($action, $path); 62 | }); 63 | 64 | $this->table(['id', 'key', 'points', 'name', 'file'], $points); 65 | 66 | $runTime = round(microtime(true) - $startTime, 2); 67 | 68 | $this->line("Run in: {$runTime} seconds"); 69 | } 70 | 71 | /** 72 | * Get an action's file. 73 | * 74 | * @param $action 75 | * @param $path 76 | * @return array 77 | */ 78 | protected function getActionFile($action, $path) 79 | { 80 | $file = Collection::make($path)->first(function ($actPath) use ($action) { 81 | $contents = $this->files->get($actPath); 82 | 83 | if (mb_stristr($contents, '\'' . $action->key . '\'')) { 84 | return true; 85 | } 86 | }); 87 | 88 | $name = '?'; 89 | 90 | if ($file) { 91 | $actPath = Arr::last(explode(DIRECTORY_SEPARATOR, $file)); 92 | $name = str_replace('.php', '', basename($actPath)); 93 | } 94 | 95 | return [$action->id, $action->key, $action->points, $action->name, '' . $name . '']; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/database/seeds/PointTableSeeder.php: -------------------------------------------------------------------------------- 1 | 'Subscriber', 22 | 'key' => 'subscriber', 23 | 'points' => 100, 24 | ], 25 | [ 26 | 'name' => 'Exclusive Seller', 27 | 'key' => 'exclusive-seller', 28 | 'points' => 50, 29 | ], 30 | [ 31 | 'name' => 'Deleteable Point', 32 | 'key' => 'deleteable-point', 33 | 'points' => 5, 34 | ], 35 | [ 36 | 'name' => 'Grades', 37 | 'key' => 'grades', 38 | 'points' => 0, 39 | 'children' => [ 40 | [ 41 | 'name' => 'Outstanding', 42 | 'points' => 100, 43 | ], 44 | [ 45 | 'name' => 'Excellent', 46 | 'points' => 90, 47 | ], 48 | [ 49 | 'name' => 'Very Good', 50 | 'points' => 80, 51 | ], 52 | [ 53 | 'name' => 'Good', 54 | 'points' => 70, 55 | ], 56 | ], 57 | ], 58 | ]; 59 | 60 | foreach ($points as $point) { 61 | $exists = Point::where('key', $point['key'])->first(); 62 | 63 | if (!$exists) { 64 | Point::create($point); 65 | } else { 66 | $this->updateOrCreate($point); 67 | $children = $point['children'] ?? []; 68 | if (count($children) > 0) { 69 | foreach ($children as $child) { 70 | $this->updateOrCreate($child, $exists); 71 | } 72 | } 73 | } 74 | } 75 | } 76 | 77 | /** 78 | * Create or update a point. 79 | * 80 | * @param $point 81 | * @param null $exists 82 | */ 83 | protected function updateOrCreate($point, $exists = null) 84 | { 85 | $key = $point['key'] ?? Str::slug($exists->name . ' ' . $point['name'], '-'); 86 | 87 | Point::updateOrCreate(['key' => $key], 88 | array_merge(Arr::except($point, ['key', 'children']), [ 89 | 'parent_id' => optional($exists)->id 90 | ]) 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/Feature/CollectPointsTest.php: -------------------------------------------------------------------------------- 1 | create(); 23 | 24 | $subscriber = new Subscriber(); 25 | 26 | $this->assertCount(0, $user->pointsRelation()->where('key', $subscriber->key())->get()); 27 | 28 | $user->givePoints($subscriber); 29 | 30 | $this->assertCount(1, $user->pointsRelation()->where('key', $subscriber->key())->get()); 31 | } 32 | 33 | /** 34 | * Test a user has the correct amount of points. 35 | * 36 | * @test 37 | */ 38 | public function user_has_correct_amount_of_points() 39 | { 40 | $user = factory(User::class)->create(); 41 | 42 | $subscriber = new Subscriber(); 43 | 44 | $this->assertCount(0, $user->pointsRelation()->where('key', $subscriber->key())->get()); 45 | 46 | $user->givePoints($subscriber); 47 | 48 | $this->assertCount(1, $user->pointsRelation()->where('key', $subscriber->key())->get()); 49 | 50 | $this->assertEquals($subscriber->getModel()->points, $user->points()->number()); 51 | } 52 | 53 | /** 54 | * Test an exception is thrown if point does not exist. 55 | * 56 | * @test 57 | */ 58 | public function exception_thrown_if_point_not_found() 59 | { 60 | $user = factory(User::class)->create(); 61 | 62 | $completedTask = new CompletedTask(); 63 | 64 | $point = $completedTask->getModel(); 65 | 66 | $this->assertNull($point); 67 | 68 | $this->assertCount(0, $user->pointsRelation()->where('key', $completedTask->key())->get()); 69 | 70 | $this->expectException(PointModelMissingException::class); 71 | 72 | $user->givePoints($completedTask); 73 | } 74 | 75 | /** 76 | * Test an event is emitted when a user is given points. 77 | * 78 | * @test 79 | */ 80 | public function an_event_is_emitted_when_user_is_given_points() 81 | { 82 | Event::fake(); 83 | 84 | $user = factory(User::class)->create(); 85 | 86 | $subscriber = new Subscriber(); 87 | 88 | $this->assertCount(0, $user->pointsRelation()->where('key', $subscriber->key())->get()); 89 | 90 | $user->givePoints($subscriber); 91 | 92 | $this->assertCount(1, $user->pointsRelation()->where('key', $subscriber->key())->get()); 93 | 94 | Event::assertDispatched(PointsGiven::class, function ($event) use ($subscriber, $user) { 95 | return $event->user->id === $user->id && $event->point->id === $subscriber->getModel()->id; 96 | }); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Console/RoyaltySetup.php: -------------------------------------------------------------------------------- 1 | files = $files; 47 | } 48 | 49 | /** 50 | * Execute the console command. 51 | * 52 | * @return mixed 53 | */ 54 | public function handle() 55 | { 56 | if (!$this->confirmToProceed()) { 57 | return; 58 | } 59 | 60 | $this->info('Publishing config...'); 61 | 62 | $this->call('vendor:publish', [ 63 | '--provider' => 'Miracuthbert\Royalty\RoyaltyServiceProvider', 64 | '--tag' => 'royalty-config', 65 | '--force' => $this->hasOption('force') && $this->option('force') 66 | ]); 67 | 68 | if ($publishMigrations = $this->canPublishMigrations()) { 69 | $this->info('Publishing migrations...'); 70 | 71 | $this->call('vendor:publish', [ 72 | '--provider' => 'Miracuthbert\Royalty\RoyaltyServiceProvider', 73 | '--tag' => 'royalty-migrations', 74 | '--force' => $publishMigrations, 75 | ]); 76 | } 77 | 78 | if ($this->hasOption('components') && $this->option('components')) { 79 | $this->info('Publishing components...'); 80 | 81 | $this->call('vendor:publish', [ 82 | '--provider' => 'Miracuthbert\Royalty\RoyaltyServiceProvider', 83 | '--tag' => 'royalty-components', 84 | '--force' => $this->hasOption('force') && $this->option('force') 85 | ]); 86 | } 87 | 88 | $this->info('Update the keys in "config/royalty.php" before migrating your database.'); 89 | } 90 | 91 | /** 92 | * Determine if migrations can be published. 93 | * 94 | * @return bool 95 | */ 96 | protected function canPublishMigrations() 97 | { 98 | if ($this->hasOption('force') && $this->option('force')) { 99 | return true; 100 | } 101 | 102 | $path = Collection::make($this->files->files(database_path('migrations')))->map(function ($migration){ 103 | return $migration->getFilename(); 104 | })->toArray(); 105 | 106 | if (count(preg_grep('/points|point_user/', $path)) >= 2) { 107 | return false; 108 | } 109 | 110 | return true; 111 | } 112 | 113 | /** 114 | * Get the console command options. 115 | * 116 | * @return array 117 | */ 118 | protected function getOptions() 119 | { 120 | return array_merge(parent::getOptions(), [ 121 | ['components', null, InputOption::VALUE_NONE, 'Publish the included components'], 122 | ['force', null, InputOption::VALUE_NONE, 'Setup the files for Royalty even if they already exists'], 123 | ]); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Console/RoyaltyAction.php: -------------------------------------------------------------------------------- 1 | confirmToProceed()) { 45 | return; 46 | } 47 | 48 | if (($count = count($this->canCreatePointInDb())) === 1) { 49 | $this->warn('You need to pass both "--name" and "--points" options to create point in the database.'); 50 | return; 51 | } 52 | 53 | parent::handle(); 54 | 55 | $this->createPoint(); 56 | } 57 | 58 | /** 59 | * Build the class with the given name. 60 | * 61 | * @param string $name 62 | * @return string 63 | * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException 64 | */ 65 | protected function buildClass($name) 66 | { 67 | $key = $this->buildActionKey(); 68 | 69 | return str_replace( 70 | [ 71 | 'DummyKey', 72 | ], 73 | [ 74 | $key, 75 | ], 76 | parent::buildClass($name) 77 | ); 78 | } 79 | 80 | /** 81 | * Get the stub file for the generator. 82 | * 83 | * @return string 84 | */ 85 | protected function getStub() 86 | { 87 | return __DIR__ . '/stubs/action.stub'; 88 | } 89 | 90 | /** 91 | * Get the default namespace for the class. 92 | * 93 | * @param string $rootNamespace 94 | * @return string 95 | */ 96 | protected function getDefaultNamespace($rootNamespace) 97 | { 98 | return $rootNamespace . '\\' . config('royalty.point.actions_path'); 99 | } 100 | 101 | /** 102 | * Build the action's key. 103 | * 104 | * @return string 105 | */ 106 | protected function buildActionKey() 107 | { 108 | $name = $this->qualifyClass($this->getNameInput()); 109 | 110 | if ($key = $this->option('key')) { 111 | $slug = Str::slug($key, '-'); 112 | 113 | return $slug; 114 | } 115 | 116 | $slug = Str::slug(Str::snake(class_basename($name), '-')); 117 | 118 | return $slug; 119 | } 120 | 121 | /** 122 | * Determine if the action's point should be created in the database. 123 | * 124 | * @return array 125 | */ 126 | protected function canCreatePointInDb() 127 | { 128 | // create point 129 | $optionNotNull = function ($option) { 130 | return $option !== null; 131 | }; 132 | 133 | $dbOptions = Arr::only($this->options(), ['name', 'points']); 134 | 135 | $canCreatePointInDb = array_filter(array_map($optionNotNull, $dbOptions), function ($option) { 136 | return $option === true; 137 | }); 138 | 139 | return $canCreatePointInDb; 140 | } 141 | 142 | /** 143 | * Create action point in database. 144 | * 145 | * @return void 146 | */ 147 | protected function createPoint() 148 | { 149 | if (count($this->canCreatePointInDb()) === 2) { 150 | config('royalty.point.model')::firstOrCreate(['key' => $this->buildActionKey()], 151 | Arr::only($this->options(), ['name', 'points', 'description']) 152 | ); 153 | 154 | $this->info($this->type . ' created in database.'); 155 | } 156 | } 157 | 158 | /** 159 | * Get the console command options. 160 | * 161 | * @return array 162 | */ 163 | protected function getOptions() 164 | { 165 | return array_merge(parent::getOptions(), [ 166 | ['key', null, InputOption::VALUE_OPTIONAL, 'Overrides the generated key with the given one'], 167 | ['name', null, InputOption::VALUE_OPTIONAL, 'The action point name to be used in the database'], 168 | ['points', null, InputOption::VALUE_OPTIONAL, 'The amount of points to rewarded'], 169 | ['description', null, InputOption::VALUE_OPTIONAL, 'A short overview of what the point reward is'], 170 | ['force', null, InputOption::VALUE_NONE, 'Create the class even if the point already exists'], 171 | ]); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Royalty 2 | 3 | A user points package for Laravel that can be used to give rewards, loyalty or experience points with real time support. 4 | 5 | ## How does it work? 6 | 7 | Simply every point has an action file which when called will resolve the model for a given point. 8 | 9 | The action file is used to assign points to a user. 10 | 11 | The reason we use action (files) is: 12 | 13 | - It makes it easy to track points. 14 | - It's easier to switch out points eg. you want to bump up the points a user gets on completing a lesson from `50` to `100`, you just create a new action and you replace the old one. 15 | 16 | ## Installation 17 | 18 | Use composer to install the package: 19 | 20 | ``` 21 | composer require miracuthbert/laravel-royalty 22 | ``` 23 | 24 | ## Setup 25 | 26 | The package takes advantage of Laravel Auto-Discovery, so it doesn't require you to manually add the ServiceProvider. 27 | 28 | If you don't use auto-discovery, add the ServiceProvider to the providers array in `config/app.php` 29 | 30 | ```php 31 | Miracuthbert\Royalty\RoyaltyServiceProvider::class 32 | ``` 33 | 34 | You must then publish the `config` and `migrations` file. 35 | 36 | ### Using the Setup command 37 | 38 | You can do all the necessary setup using the `royalty:setup` in your command console 39 | 40 | ``` 41 | php artisan royalty:setup 42 | ``` 43 | 44 | > Update the keys in "config/royalty.php" before migrating your database 45 | > You can set the model to be used for points. 46 | 47 | If you want to reset either `config` or `migrations` use the commands below in your console 48 | 49 | ### Publish Config 50 | 51 | ``` 52 | php artisan vendor:publish --provider=Miracuthbert\Royalty\RoyaltyServiceProvider --tag=royalty-config 53 | ``` 54 | 55 | > Setup the user `model` key in config to indicate which User model to use 56 | 57 | ### Publish Migrations 58 | 59 | ``` 60 | php artisan vendor:publish --provider=Miracuthbert\Royalty\RoyaltyServiceProvider --tag=royalty-migrations 61 | ``` 62 | 63 | > Before migrating the database make sure you setup the user `model` key in config 64 | 65 | ### Publish Vue Components 66 | 67 | A simple Vue component is included to display a user's points in real-time anytime they are given points. See [Real-time](#real-time) section under usage for more. 68 | 69 | ``` 70 | php artisan vendor:publish --provider=Miracuthbert\Royalty\RoyaltyServiceProvider --tag=royalty-components 71 | ``` 72 | 73 | > The package does not tie you to use a specific front-end framework to listen to the fired event, so feel free to experiment 74 | 75 | ## Usage 76 | 77 | ### Setting the User model 78 | 79 | First, setup the user `model` key in the config. 80 | 81 | Then add `CollectsPoints` trait in the respective model. 82 | 83 | ```php 84 | use CollectsPoints; 85 | ``` 86 | 87 | ### Creating Points 88 | 89 | Let's take an example of a user completing a Lesson in a course, we can create a `CompletedLesson`. 90 | 91 | #### Using Console Command 92 | 93 | You can use the command: 94 | 95 | ``` 96 | php artisan royalty:action CompletedLesson 97 | 98 | // with specific namespace 99 | php artisan royalty:action Course\\CompletedLesson 100 | ``` 101 | 102 | This will create an action file under the `Royalty\Actions` folder in the `app` directory 103 | or a corresponding one as specified in the `config/royalty.php` file. 104 | 105 | It will include: 106 | 107 | - `key` method with the unique key which will be used to identify created from the slug of the `Action` name. 108 | 109 | You can also use these options along with the command: 110 | 111 | - `--key` to override the key generated from class name. 112 | - `--name` to create the point in the database (use along with the `points` option). 113 | - `--points` the points for the given action. 114 | - `--description` the description of the point. 115 | 116 | > If you just created the point file only you need to create a record with reference to the action in the database. 117 | 118 | See [Adding Points in the Database](#adding-points-in-the-database) section on adding a point in the database. 119 | 120 | #### Creating Manually 121 | 122 | To create a point manually you need to create an `action` file and also a `record` referencing the action in the database. 123 | 124 | ##### Creating the Action File 125 | 126 | To create a point action file, you need to create a class that extends `Miracuthbert\Royalty\Actions\ActionAbstract`. 127 | 128 | ```php 129 | namespace App\Royalty\Actions; 130 | 131 | use Miracuthbert\Royalty\Actions\ActionAbstract; 132 | 133 | class CompletedLesson extends ActionAbstract 134 | { 135 | /** 136 | * Set the action key. 137 | * 138 | * @return mixed 139 | */ 140 | public function key() 141 | { 142 | return 'completed-lesson'; 143 | } 144 | } 145 | ``` 146 | 147 | ##### Adding Points in the Database 148 | 149 | ```php 150 | use Miracuthbert\Royalty\Models\Point; 151 | 152 | $point = Point::create([ 153 | 'name' => 'Completed Lesson', 154 | 'key' => 'completed-lesson', 155 | 'description' => 'Reward for completing a lesson', 156 | 'points' => 100, 157 | ]); 158 | ``` 159 | 160 | You can also create bulk points using, for example a seeder: 161 | 162 | ```php 163 | $points = [ 164 | [ 165 | 'name' => 'Completed Lesson', 166 | 'key' => 'completed-lesson', 167 | 'points' => 100, 168 | ], 169 | [ 170 | 'name' => 'Completed Course', 171 | 'key' => 'completed-course', 172 | 'points' => 500, 173 | ], 174 | 175 | // grouped 176 | [ 177 | 'name' => 'Grades', 178 | 'key' => 'grades', 179 | 'points' => 100, 180 | 'children' => [ 181 | [ 182 | 'name' => 'Excellent', 183 | 'points' => 100, 184 | ], 185 | [ 186 | 'name' => 'Very Good', 187 | 'points' => 90, 188 | ], 189 | [ 190 | 'name' => 'Good', 191 | 'points' => 80, 192 | ], 193 | ], 194 | ], 195 | ]; 196 | 197 | foreach ($points as $point) { 198 | $exists = Point::where('key', $point['key'])->first(); 199 | 200 | if (!$exists) { 201 | Miracuthbert\Royalty\Models\Point::create($point); 202 | } 203 | } 204 | ``` 205 | 206 | ### Giving Points 207 | 208 | To give points, just call the `givePoints` method on an instance of a user. 209 | 210 | ```php 211 | // user instance 212 | $user = User::find(1); 213 | $user->givePoints(new CompletedLesson()); 214 | 215 | // using request user 216 | $request->user()->givePoints(new CompletedLesson()); 217 | 218 | // using auth user 219 | auth()->user()->givePoints(new CompletedLesson()); 220 | ``` 221 | 222 | ### Getting User's Points 223 | 224 | To get user points, just call the `points` method on an instance of a user chaining one of the following methods: 225 | 226 | - `number`: The raw points value 227 | - `number`: For a formatted number value, i.e `1,000`, `1,000,000` 228 | - `shorthand`: For a formatted string value, i.e `1k`, `10.5k`, `1m` 229 | 230 | ```php 231 | // user instance 232 | $user = User::find(1); 233 | $user->points()->number(); 234 | $user->points()->shorthand(); 235 | 236 | // using request user 237 | $request->user()->points()->number(); 238 | $request->user()->points()->shorthand(); 239 | 240 | // using auth user 241 | auth()->user()->points()->number(); 242 | auth()->user()->points()->shorthand(); 243 | ``` 244 | 245 | ### Real-time 246 | 247 | Whenever a user is given points a `PointsGiven` event is fired. 248 | 249 | #### Broadcast Channel 250 | 251 | It broadcasts to the `users` (private) channel as set in the `channel` key of `broadcast` in `config/royalty.php`. 252 | 253 | > The channel should exist in `channels` file under routes or your respective user channel 254 | 255 | An example of the channel route: 256 | 257 | ```php 258 | Broadcast::channel('users.{id}', function ($user, $id) { 259 | return (int) $user->id === (int) $id; 260 | }); 261 | ``` 262 | 263 | #### Listening to the Event 264 | 265 | You can then listen to it using the `points-given` or the set value of the `name` key under `broadcast` in `config/royalty.php`. 266 | 267 | Example using Laravel Echo with Vue.js: 268 | 269 | ```vue 270 | Echo.private(`users.${this.userId}`) 271 | .listen('.points-given', (e) => { 272 | this.point = e.point 273 | this.userPoints = e.user_points 274 | }) 275 | ``` 276 | 277 | ##### Vue Component 278 | 279 | There is a Vue component included with the package. Use command below to publish it: 280 | 281 | ``` 282 | php artisan vendor:publish --provider=Miracuthbert\Royalty\RoyaltyServiceProvider --tag=royalty-components 283 | ``` 284 | 285 | The published component will be placed in your `resources/js/components` directory. Once the components have been published, you should register them in your `resources/js/app.js` file: 286 | 287 | ``` 288 | Vue.component('royalty-badge', require('./components/royalty/RoyaltyBadge.vue').default); 289 | ``` 290 | 291 | After registering the component, make sure to run `npm run dev` to recompile your assets. Once you have recompiled your assets, you may drop the components into one of your application's templates to get started: 292 | 293 | ``` 294 | 298 | ``` 299 | 300 | ## Console Commands 301 | 302 | There are three commands within the package: 303 | 304 | - `royalty:setup`: Used to setup the package files 305 | - `royalty:action`: Used to create an action file and point 306 | - `royalty:actions`: Used to list points and their related actions 307 | 308 | ## Security Vulnerabilities 309 | 310 | If you discover a security vulnerability, please send an e-mail to Cuthbert Mirambo via [miracuthbert@gmail.com](mailto:miracuthbert@gmail.com). All security vulnerabilities will be promptly addressed. 311 | 312 | ## Credits 313 | 314 | - [Cuthbert Mirambo](https://github.com/miracuthbert) 315 | 316 | ## License 317 | 318 | The project is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). 319 | --------------------------------------------------------------------------------