├── 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 |
2 |
3 | {{ userPoints.shorthand }}
4 |
5 |
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 |
--------------------------------------------------------------------------------