From 30ed77f3042dc7a155fb53c3e0b02f9fa4d1cd50 Mon Sep 17 00:00:00 2001 From: Edmond Lang Date: Mon, 26 Jan 2026 00:13:10 +0800 Subject: [PATCH 1/2] Merge branch 'affiliate-program' of https://gitlab.com/CIEFWorldwideSdnBhd/exchange-2.0 into vapor/production # Conflicts: # resources/assets/vue/components/accounts/forms/RegistrationFormComponent.vue # routes/api.php --- .../Eloquent/Filters/AffiliateSearch.php | 37 +++ .../ControllersLogic/CreateCustomerLogic.php | 11 +- .../ControllersLogic/CreateAffiliateLogic.php | 80 ++++++ .../ControllersLogic/DeleteAffiliateLogic.php | 36 +++ .../GetAffiliateSettingsLogic.php | 33 +++ .../ControllersLogic/ListAffiliatesLogic.php | 45 ++++ .../ControllersLogic/UpdateAffiliateLogic.php | 55 ++++ .../UpdateAffiliateSettingsLogic.php | 41 +++ .../DataTransferObjects/AffiliateObject.php | 68 +++++ .../AffiliateSettingsObject.php | 29 ++ .../Affiliate/Services/CreatesAffiliate.php | 68 +++++ .../Affiliate/Services/DeletesAffiliate.php | 18 ++ .../Services/GetsAffiliateSettings.php | 29 ++ .../Affiliate/Services/ListsAffiliates.php | 31 +++ .../Services/TracksAffiliateClick.php | 88 ++++++ .../Services/TracksAffiliateOrder.php | 83 ++++++ .../Services/TracksAffiliateRegistration.php | 85 ++++++ .../Affiliate/Services/UpdatesAffiliate.php | 27 ++ .../Services/UpdatesAffiliateSettings.php | 40 +++ .../Standards/Rules/CanCreateAffiliate.php | 52 ++++ .../Validators/AffiliateCreateValidation.php | 69 +++++ .../ControllersLogic/CreateBookingLogic.php | 11 +- .../Affiliate/CreateAffiliateController.php | 23 ++ .../Affiliate/DeleteAffiliateController.php | 37 +++ .../GetAffiliateSettingsController.php | 23 ++ .../Affiliate/ListAffiliatesController.php | 23 ++ .../Affiliate/UpdateAffiliateController.php | 39 +++ .../UpdateAffiliateSettingsController.php | 24 ++ .../Middleware/RedirectIfAuthenticated.php | 42 ++- app/Http/Middleware/TrackAffiliateClick.php | 146 ++++++++++ app/Http/Requests/CreateAffiliateRequest.php | 58 ++++ app/Http/Requests/UpdateAffiliateRequest.php | 47 ++++ .../UpdateAffiliateSettingsRequest.php | 47 ++++ app/Http/Resources/AffiliateResource.php | 32 +++ app/Models/Affiliate.php | 63 +++++ app/Models/User.php | 10 + app/Models/UserAffiliate.php | 39 +++ app/Policies/AffiliatePolicy.php | 96 +++++++ app/Providers/AuthServiceProvider.php | 1 + ...5_11_16_190751_create_affiliates_table.php | 43 +++ ...16_191326_create_user_affiliates_table.php | 42 +++ ...d_foreign_key_to_affiliates_created_by.php | 37 +++ .../forms/RegistrationFormComponent.vue | 4 +- .../elements/AffiliateSectionComponent.vue | 137 ++++++++++ .../elements/AffiliateSettingsComponent.vue | 126 +++++++++ .../elements/AffiliateSingleItemComponent.vue | 85 ++++++ .../settings/forms/AffiliateFormComponent.vue | 253 ++++++++++++++++++ .../forms/DeleteAffiliateFormComponent.vue | 46 ++++ resources/assets/vue/general/mixins/guards.js | 21 +- resources/views/pages/settings.blade.php | 32 +++ routes/affiliate.php | 14 + routes/api.php | 2 + routes/web.php | 5 +- 53 files changed, 2626 insertions(+), 7 deletions(-) create mode 100644 app/Classes/General/Eloquent/Filters/AffiliateSearch.php create mode 100644 app/Classes/Modules/Affiliate/ControllersLogic/CreateAffiliateLogic.php create mode 100644 app/Classes/Modules/Affiliate/ControllersLogic/DeleteAffiliateLogic.php create mode 100644 app/Classes/Modules/Affiliate/ControllersLogic/GetAffiliateSettingsLogic.php create mode 100644 app/Classes/Modules/Affiliate/ControllersLogic/ListAffiliatesLogic.php create mode 100644 app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateLogic.php create mode 100644 app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateSettingsLogic.php create mode 100644 app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateObject.php create mode 100644 app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateSettingsObject.php create mode 100644 app/Classes/Modules/Affiliate/Services/CreatesAffiliate.php create mode 100644 app/Classes/Modules/Affiliate/Services/DeletesAffiliate.php create mode 100644 app/Classes/Modules/Affiliate/Services/GetsAffiliateSettings.php create mode 100644 app/Classes/Modules/Affiliate/Services/ListsAffiliates.php create mode 100644 app/Classes/Modules/Affiliate/Services/TracksAffiliateClick.php create mode 100644 app/Classes/Modules/Affiliate/Services/TracksAffiliateOrder.php create mode 100644 app/Classes/Modules/Affiliate/Services/TracksAffiliateRegistration.php create mode 100644 app/Classes/Modules/Affiliate/Services/UpdatesAffiliate.php create mode 100644 app/Classes/Modules/Affiliate/Services/UpdatesAffiliateSettings.php create mode 100644 app/Classes/Modules/Affiliate/Standards/Rules/CanCreateAffiliate.php create mode 100644 app/Classes/Modules/Affiliate/Standards/Validators/AffiliateCreateValidation.php create mode 100644 app/Http/Controllers/Affiliate/CreateAffiliateController.php create mode 100644 app/Http/Controllers/Affiliate/DeleteAffiliateController.php create mode 100644 app/Http/Controllers/Affiliate/GetAffiliateSettingsController.php create mode 100644 app/Http/Controllers/Affiliate/ListAffiliatesController.php create mode 100644 app/Http/Controllers/Affiliate/UpdateAffiliateController.php create mode 100644 app/Http/Controllers/Affiliate/UpdateAffiliateSettingsController.php create mode 100644 app/Http/Middleware/TrackAffiliateClick.php create mode 100644 app/Http/Requests/CreateAffiliateRequest.php create mode 100644 app/Http/Requests/UpdateAffiliateRequest.php create mode 100644 app/Http/Requests/UpdateAffiliateSettingsRequest.php create mode 100644 app/Http/Resources/AffiliateResource.php create mode 100644 app/Models/Affiliate.php create mode 100644 app/Models/UserAffiliate.php create mode 100644 app/Policies/AffiliatePolicy.php create mode 100644 database/migrations/2025_11_16_190751_create_affiliates_table.php create mode 100644 database/migrations/2025_11_16_191326_create_user_affiliates_table.php create mode 100644 database/migrations/2025_11_28_002359_add_foreign_key_to_affiliates_created_by.php create mode 100644 resources/assets/vue/components/settings/elements/AffiliateSectionComponent.vue create mode 100644 resources/assets/vue/components/settings/elements/AffiliateSettingsComponent.vue create mode 100644 resources/assets/vue/components/settings/elements/AffiliateSingleItemComponent.vue create mode 100644 resources/assets/vue/components/settings/forms/AffiliateFormComponent.vue create mode 100644 resources/assets/vue/components/settings/forms/DeleteAffiliateFormComponent.vue create mode 100644 routes/affiliate.php diff --git a/app/Classes/General/Eloquent/Filters/AffiliateSearch.php b/app/Classes/General/Eloquent/Filters/AffiliateSearch.php new file mode 100644 index 00000000..7e1f5b51 --- /dev/null +++ b/app/Classes/General/Eloquent/Filters/AffiliateSearch.php @@ -0,0 +1,37 @@ +where(function ($query) use ($sanitized) { + $query->where('campaign_name', 'LIKE', '%' . $sanitized . '%') + ->orWhere('code', 'LIKE', '%' . $sanitized . '%') + ->orWhere('campaign_description', 'LIKE', '%' . $sanitized . '%'); + }); + } + +} + diff --git a/app/Classes/Modules/Accounts/ControllersLogic/CreateCustomerLogic.php b/app/Classes/Modules/Accounts/ControllersLogic/CreateCustomerLogic.php index d4da4d34..d681d182 100644 --- a/app/Classes/Modules/Accounts/ControllersLogic/CreateCustomerLogic.php +++ b/app/Classes/Modules/Accounts/ControllersLogic/CreateCustomerLogic.php @@ -17,6 +17,7 @@ use App\Classes\Modules\Vouchers\Processors\CreateVoucherProcessor; use App\Classes\Modules\Companies\DataTransferObjects\EmploymentObject; use App\Classes\Modules\PerfexCRM\DataTransferObjects\CreateLeadPerfexCRMObject; use App\Classes\Modules\Rewards\Services\CreatesUserReward; +use App\Classes\Modules\Affiliate\Services\TracksAffiliateRegistration; use App\Classes\ValueObjects\Constants\ApprovalStatus; use App\Classes\ValueObjects\Constants\BusinessType; use App\Classes\ValueObjects\Constants\CompanyType; @@ -82,6 +83,9 @@ class CreateCustomerLogic extends AbstractControllerLogic /** @var CreatesUserReward */ private $createsUserReward; + /** @var TracksAffiliateRegistration */ + private $tracksAffiliateRegistration; + /** * CreateCustomerLogic constructor. * @param CreateUserProcessor $createUserProcessor @@ -96,9 +100,10 @@ class CreateCustomerLogic extends AbstractControllerLogic * @param NewCustomerToVoucherifyProcessor $newCustomerToVoucherifyProcessor * @param CreateVoucherProcessor $createVoucherProcessor * @param CreatesUserReward $createsUserReward + * @param TracksAffiliateRegistration $tracksAffiliateRegistration */ public function __construct(CreateUserProcessor $createUserProcessor, CreateCompanyProcessor $createCompanyProcessor, CreateContactProcessor $createContactProcessor, AssignEmployeeProcessor $assignEmployeeProcessor, AssignSegmentProcessor $assignSegmentProcessor, AuthenticationProcessor $authenticationProcessor, GenerateEmailVerificationAttemptProcessor $generateEmailVerificationAttemptProcessor, - CreatesSeasonalSegment $createsSeasonalSegment, CheckMilestonesForRewardProcessor $checkMilestonesForRewardProcessor, NewCustomerToVoucherifyProcessor $newCustomerToVoucherifyProcessor, CreateVoucherProcessor $createVoucherProcessor, CreatesUserReward $createsUserReward) + CreatesSeasonalSegment $createsSeasonalSegment, CheckMilestonesForRewardProcessor $checkMilestonesForRewardProcessor, NewCustomerToVoucherifyProcessor $newCustomerToVoucherifyProcessor, CreateVoucherProcessor $createVoucherProcessor, CreatesUserReward $createsUserReward, TracksAffiliateRegistration $tracksAffiliateRegistration) { $this->createUserProcessor = $createUserProcessor; $this->createCompanyProcessor = $createCompanyProcessor; @@ -112,6 +117,7 @@ class CreateCustomerLogic extends AbstractControllerLogic $this->newCustomerToVoucherifyProcessor = $newCustomerToVoucherifyProcessor; $this->createVoucherProcessor = $createVoucherProcessor; $this->createsUserReward = $createsUserReward; + $this->tracksAffiliateRegistration = $tracksAffiliateRegistration; } /** @@ -177,6 +183,9 @@ class CreateCustomerLogic extends AbstractControllerLogic } } + // Track affiliate registration if user came from affiliate link + $this->tracksAffiliateRegistration->execute($user, $request->get('tracking')); + return $this->response($this->authenticationProcessor->execute($request, false)); } diff --git a/app/Classes/Modules/Affiliate/ControllersLogic/CreateAffiliateLogic.php b/app/Classes/Modules/Affiliate/ControllersLogic/CreateAffiliateLogic.php new file mode 100644 index 00000000..b6470ffe --- /dev/null +++ b/app/Classes/Modules/Affiliate/ControllersLogic/CreateAffiliateLogic.php @@ -0,0 +1,80 @@ + 'Created Affiliate', + 'message' => 'You have successfully created a new Affiliate Code' + ]; + } + + /** @var CanCreateAffiliate */ + private $canCreateAffiliate; + + /** @var CreatesAffiliate */ + private $createsAffiliate; + + /** + * CreateAffiliateLogic constructor. + * @param CanCreateAffiliate $canCreateAffiliate + * @param CreatesAffiliate $createsAffiliate + */ + public function __construct( + CanCreateAffiliate $canCreateAffiliate, + CreatesAffiliate $createsAffiliate + ) + { + $this->canCreateAffiliate = $canCreateAffiliate; + $this->createsAffiliate = $createsAffiliate; + } + + /** + * @param Request $request + * @return JsonResponse + * @throws \App\Classes\Exceptions\AccessForbiddenException + * @throws \App\Classes\Exceptions\RequestValidationException + */ + public function logic(Request $request): JsonResponse + { + $code = $request->input('code'); + $code = $code !== null ? (string) $code : ''; + + $isActive = $request->input('is_active', true); + // Convert string 'true'/'false' to boolean if needed + if (is_string($isActive)) { + $isActive = filter_var($isActive, FILTER_VALIDATE_BOOLEAN); + } + $isActive = (bool) $isActive; + + $object = new AffiliateObject( + $code, + $request->input('campaign_name'), + $request->input('campaign_description'), + $isActive + ); + + // Validate the affiliate object + $this->canCreateAffiliate->passes($object); + + // Create the affiliate + $affiliate = $this->createsAffiliate->execute($object); + + return $this->resourceResponse(new AffiliateResource($affiliate)); + } +} + diff --git a/app/Classes/Modules/Affiliate/ControllersLogic/DeleteAffiliateLogic.php b/app/Classes/Modules/Affiliate/ControllersLogic/DeleteAffiliateLogic.php new file mode 100644 index 00000000..74581ace --- /dev/null +++ b/app/Classes/Modules/Affiliate/ControllersLogic/DeleteAffiliateLogic.php @@ -0,0 +1,36 @@ +deletesAffiliate = $deletesAffiliate; + } + + /** + * @param int $id + * @return JsonResponse + */ + public function execute(int $id): JsonResponse + { + $affiliate = Affiliate::findOrFail($id); + $this->deletesAffiliate->execute($affiliate); + + return response()->json([ + 'message' => 'Affiliate code deleted successfully' + ]); + } +} + diff --git a/app/Classes/Modules/Affiliate/ControllersLogic/GetAffiliateSettingsLogic.php b/app/Classes/Modules/Affiliate/ControllersLogic/GetAffiliateSettingsLogic.php new file mode 100644 index 00000000..8bc6f2bc --- /dev/null +++ b/app/Classes/Modules/Affiliate/ControllersLogic/GetAffiliateSettingsLogic.php @@ -0,0 +1,33 @@ +getsAffiliateSettings = $getsAffiliateSettings; + } + + /** + * @param Request $request + * @return JsonResponse + */ + public function execute(Request $request): JsonResponse + { + $settings = $this->getsAffiliateSettings->execute(); + + return response()->json($settings); + } +} + diff --git a/app/Classes/Modules/Affiliate/ControllersLogic/ListAffiliatesLogic.php b/app/Classes/Modules/Affiliate/ControllersLogic/ListAffiliatesLogic.php new file mode 100644 index 00000000..b03f99ca --- /dev/null +++ b/app/Classes/Modules/Affiliate/ControllersLogic/ListAffiliatesLogic.php @@ -0,0 +1,45 @@ + 'Retrieved Affiliates', + 'message' => 'You have successfully retrieved a list of Affiliates' + ]; + } + + /** @var ListsAffiliates */ + private $listsAffiliates; + + /** + * @param ListsAffiliates $listsAffiliates + */ + public function __construct(ListsAffiliates $listsAffiliates) + { + $this->listsAffiliates = $listsAffiliates; + } + + /** + * @param Request $request + * @return JsonResponse + */ + public function logic(Request $request): JsonResponse + { + $query = $this->listsAffiliates->execute($this->listsAffiliates->deserializeFilters($request->input('filters'))); + + return $this->collectionResponse(AffiliateResource::collection($query)); + } +} + diff --git a/app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateLogic.php b/app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateLogic.php new file mode 100644 index 00000000..faf9f2fb --- /dev/null +++ b/app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateLogic.php @@ -0,0 +1,55 @@ +updatesAffiliate = $updatesAffiliate; + } + + /** + * @param Request $request + * @param int $id + * @return JsonResponse + */ + public function execute(Request $request, int $id): JsonResponse + { + $affiliate = Affiliate::findOrFail($id); + + $isActive = $request->input('is_active', $affiliate->is_active); + // Convert string 'true'/'false' to boolean if needed + if (is_string($isActive)) { + $isActive = filter_var($isActive, FILTER_VALIDATE_BOOLEAN); + } + $isActive = (bool) $isActive; + + $object = new AffiliateObject( + $affiliate->code, // Keep existing code + $request->input('campaign_name'), + $request->input('campaign_description'), + $isActive + ); + + $affiliate = $this->updatesAffiliate->execute($affiliate, $object); + + return response()->json([ + 'message' => 'Affiliate code updated successfully', + 'data' => $affiliate + ]); + } +} + diff --git a/app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateSettingsLogic.php b/app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateSettingsLogic.php new file mode 100644 index 00000000..9fd49a4d --- /dev/null +++ b/app/Classes/Modules/Affiliate/ControllersLogic/UpdateAffiliateSettingsLogic.php @@ -0,0 +1,41 @@ +updatesAffiliateSettings = $updatesAffiliateSettings; + } + + /** + * @param Request $request + * @return JsonResponse + */ + public function execute(Request $request): JsonResponse + { + $object = new AffiliateSettingsObject( + $request->input('code_binding_days', 30) + ); + + $settings = $this->updatesAffiliateSettings->execute($object); + + return response()->json([ + 'message' => 'Affiliate settings updated successfully', + 'data' => $settings + ]); + } +} + diff --git a/app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateObject.php b/app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateObject.php new file mode 100644 index 00000000..160c9c41 --- /dev/null +++ b/app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateObject.php @@ -0,0 +1,68 @@ +code = $code; + $this->campaignName = $campaignName; + $this->campaignDescription = $campaignDescription; + $this->isActive = $isActive; + } + + /** + * @return string + */ + public function getCode(): string + { + return $this->code; + } + + /** + * @return string + */ + public function getCampaignName(): string + { + return $this->campaignName; + } + + /** + * @return string|null + */ + public function getCampaignDescription(): ?string + { + return $this->campaignDescription; + } + + /** + * @return bool + */ + public function getIsActive(): bool + { + return $this->isActive; + } +} + diff --git a/app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateSettingsObject.php b/app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateSettingsObject.php new file mode 100644 index 00000000..c8042d0b --- /dev/null +++ b/app/Classes/Modules/Affiliate/DataTransferObjects/AffiliateSettingsObject.php @@ -0,0 +1,29 @@ +codeBindingDays = $codeBindingDays; + } + + /** + * @return int + */ + public function getCodeBindingDays(): int + { + return $this->codeBindingDays; + } +} + diff --git a/app/Classes/Modules/Affiliate/Services/CreatesAffiliate.php b/app/Classes/Modules/Affiliate/Services/CreatesAffiliate.php new file mode 100644 index 00000000..6a565f4b --- /dev/null +++ b/app/Classes/Modules/Affiliate/Services/CreatesAffiliate.php @@ -0,0 +1,68 @@ +getCode(); + + // Auto-generate code if not provided + if (empty($code)) { + $code = $this->generateUniqueCode(); + } else { + // Uppercase the code for consistency + $code = strtoupper($code); + } + + $model = new Affiliate(); + $model->code = strtoupper($code); + $model->campaign_name = $object->getCampaignName(); + $model->campaign_description = $object->getCampaignDescription(); + $model->is_active = $object->getIsActive(); + $model->created_by = Auth::id(); + $model->clicks_count = 0; + $model->registrations_count = 0; + $model->orders_count = 0; + + return $this->handler($model); + } + + /** + * Generate a unique affiliate code + * + * @return string + */ + private function generateUniqueCode(): string + { + $maxAttempts = 10; + $attempts = 0; + + do { + // Increased from 12 to 16 characters for better uniqueness + $code = Str::random(16); + $attempts++; + + if ($attempts >= $maxAttempts) { + // Fallback to UUID if we can't generate unique code + $code = strtoupper(Str::substr(str_replace('-', '', Str::uuid()), 0, 16)); + break; + } + } while (Affiliate::where('code', $code)->exists()); + + return $code; + } +} + diff --git a/app/Classes/Modules/Affiliate/Services/DeletesAffiliate.php b/app/Classes/Modules/Affiliate/Services/DeletesAffiliate.php new file mode 100644 index 00000000..de882c3a --- /dev/null +++ b/app/Classes/Modules/Affiliate/Services/DeletesAffiliate.php @@ -0,0 +1,18 @@ +delete(); + } +} + diff --git a/app/Classes/Modules/Affiliate/Services/GetsAffiliateSettings.php b/app/Classes/Modules/Affiliate/Services/GetsAffiliateSettings.php new file mode 100644 index 00000000..9f616229 --- /dev/null +++ b/app/Classes/Modules/Affiliate/Services/GetsAffiliateSettings.php @@ -0,0 +1,29 @@ +whereNull('owner_type') + ->whereNull('owner_id') + ->first(); + + $codeBindingDays = $keyValuePair ? (int) $keyValuePair->value : self::DEFAULT_VALUE; + + return [ + 'code_binding_days' => $codeBindingDays + ]; + } +} + diff --git a/app/Classes/Modules/Affiliate/Services/ListsAffiliates.php b/app/Classes/Modules/Affiliate/Services/ListsAffiliates.php new file mode 100644 index 00000000..b5890bec --- /dev/null +++ b/app/Classes/Modules/Affiliate/Services/ListsAffiliates.php @@ -0,0 +1,31 @@ +repository = $repository; + } + + /** + * @return Builder + */ + function getRepository(): Builder + { + return $this->repository->newQuery()->with('creator')->orderBy('created_at', 'desc'); + } +} + diff --git a/app/Classes/Modules/Affiliate/Services/TracksAffiliateClick.php b/app/Classes/Modules/Affiliate/Services/TracksAffiliateClick.php new file mode 100644 index 00000000..c1cac4d3 --- /dev/null +++ b/app/Classes/Modules/Affiliate/Services/TracksAffiliateClick.php @@ -0,0 +1,88 @@ +isValidCode($code)) { + return null; + } + + $affiliate = Affiliate::where('code', $code) + ->where('is_active', true) + ->first(); + + if (!$affiliate) { + return null; + } + + // Increment clicks count + $affiliate->increment('clicks_count'); + + // Get binding days from settings + $bindingDays = $this->getBindingDays(); + + // Store in cookie for later use during registration + // Set secure cookie flags for security + Cookie::queue( + cookie( + self::COOKIE_NAME, + $code, + $bindingDays * 24 * 60, // Convert days to minutes + '/', // path + null, // domain (null = current domain) + config('session.secure', false), // secure (HTTPS only) + true, // httpOnly (prevent JavaScript access) + false, // raw + config('session.same_site', 'lax') // sameSite + ) + ); + + return $affiliate; + } + + /** + * Validate affiliate code format + * + * @param string $code + * @return bool + */ + private function isValidCode(string $code): bool + { + // Check length (max 255 chars based on database schema) + if (strlen($code) > 255 || strlen($code) < 1) { + return false; + } + + // Only allow alphanumeric characters, hyphens, and underscores + return (bool) preg_match('/^[a-zA-Z0-9_-]+$/', $code); + } + + /** + * Get code binding days from settings + * + * @return int + */ + private function getBindingDays(): int + { + $settingsService = new GetsAffiliateSettings(); + $settings = $settingsService->execute(); + return $settings['code_binding_days'] ?? self::COOKIE_EXPIRY_DAYS; + } +} + diff --git a/app/Classes/Modules/Affiliate/Services/TracksAffiliateOrder.php b/app/Classes/Modules/Affiliate/Services/TracksAffiliateOrder.php new file mode 100644 index 00000000..b6f81dc7 --- /dev/null +++ b/app/Classes/Modules/Affiliate/Services/TracksAffiliateOrder.php @@ -0,0 +1,83 @@ +company->employees()->first(); + + if (!$user) { + return null; + } + + // Get user's affiliate + $userAffiliate = \App\Models\UserAffiliate::where('user_id', $user->id) + ->whereNotNull('registered_at') + ->first(); + + if (!$userAffiliate) { + return null; + } + + // Lock the affiliate record to prevent race conditions + $affiliate = Affiliate::where('id', $userAffiliate->affiliate_id) + ->where('is_active', true) + ->lockForUpdate() + ->first(); + + if (!$affiliate) { + return null; + } + + // Check if this booking has already been counted + // We'll use a KeyValuePair to track which bookings have been counted + $kvpKey = 'affiliate_order_tracked_' . $affiliate->id; + + $existingKvp = $booking->attributesKVP() + ->where('key', $kvpKey) + ->exists(); + + if ($existingKvp) { + return $affiliate; // Already counted + } + + // Mark this booking as tracked + $keyValuePairObject = new \App\Classes\Modules\Accounts\DataTransferObjects\KeyValuePairObject( + $kvpKey, + '1' + ); + + $createsKeyValuePair = app()->make(\App\Classes\Modules\Accounts\Services\CreatesKeyValuePair::class); + $createsKeyValuePair->execute($booking, $keyValuePairObject); + + // Increment orders count atomically + $affiliate->increment('orders_count'); + + return $affiliate; + }); + } catch (\Exception $e) { + Log::error('Failed to track affiliate order', [ + 'booking_id' => $booking->id, + 'error' => $e->getMessage() + ]); + return null; + } + } +} + diff --git a/app/Classes/Modules/Affiliate/Services/TracksAffiliateRegistration.php b/app/Classes/Modules/Affiliate/Services/TracksAffiliateRegistration.php new file mode 100644 index 00000000..ef1b049b --- /dev/null +++ b/app/Classes/Modules/Affiliate/Services/TracksAffiliateRegistration.php @@ -0,0 +1,85 @@ + $user, 'code' => $code]); + + if (!$code || !$this->isValidCode($code)) { + Log::info('Code is not valid', ['code' => $code]); + return null; + } + + $affiliate = Affiliate::where('code', $code) + ->where('is_active', true) + ->first(); + + if (!$affiliate) { + return null; + } + + // Check if user is already linked to this affiliate + $userAffiliate = UserAffiliate::where('user_id', $user->id) + ->where('affiliate_id', $affiliate->id) + ->first(); + + if (!$userAffiliate) { + // Create new link + $userAffiliate = new UserAffiliate(); + $userAffiliate->user_id = $user->id; + $userAffiliate->affiliate_id = $affiliate->id; + $userAffiliate->clicked_at = now(); // Approximate, we don't track exact click time + $userAffiliate->registered_at = now(); + $userAffiliate->save(); + + // Increment registrations count + $affiliate->increment('registrations_count'); + } else { + // Update registration time if not set + if (!$userAffiliate->registered_at) { + $userAffiliate->registered_at = now(); + $userAffiliate->save(); + $affiliate->increment('registrations_count'); + } + } + + // Clear the cookie after registration + Cookie::queue(Cookie::forget(TracksAffiliateClick::COOKIE_NAME)); + + return $affiliate; + } + + /** + * Validate affiliate code format + * + * @param string $code + * @return bool + */ + private function isValidCode(string $code): bool + { + // Check length (max 255 chars based on database schema) + if (strlen($code) > 255 || strlen($code) < 1) { + return false; + } + + // Only allow alphanumeric characters, hyphens, and underscores + return (bool) preg_match('/^[a-zA-Z0-9_-]+$/', $code); + } +} + diff --git a/app/Classes/Modules/Affiliate/Services/UpdatesAffiliate.php b/app/Classes/Modules/Affiliate/Services/UpdatesAffiliate.php new file mode 100644 index 00000000..f828d2fd --- /dev/null +++ b/app/Classes/Modules/Affiliate/Services/UpdatesAffiliate.php @@ -0,0 +1,27 @@ +campaign_name = $object->getCampaignName(); + $model->campaign_description = $object->getCampaignDescription(); + $model->is_active = $object->getIsActive(); + + return $this->handler($model); + } +} + diff --git a/app/Classes/Modules/Affiliate/Services/UpdatesAffiliateSettings.php b/app/Classes/Modules/Affiliate/Services/UpdatesAffiliateSettings.php new file mode 100644 index 00000000..b7159f5e --- /dev/null +++ b/app/Classes/Modules/Affiliate/Services/UpdatesAffiliateSettings.php @@ -0,0 +1,40 @@ +whereNull('owner_type') + ->whereNull('owner_id') + ->first(); + + if ($keyValuePair) { + $keyValuePair->value = (string) $object->getCodeBindingDays(); + $keyValuePair->save(); + } else { + $keyValuePair = new KeyValuePair(); + $keyValuePair->key = self::SETTING_KEY; + $keyValuePair->value = (string) $object->getCodeBindingDays(); + $keyValuePair->owner_type = null; + $keyValuePair->owner_id = null; + $keyValuePair->save(); + } + + return [ + 'code_binding_days' => $object->getCodeBindingDays() + ]; + } +} + diff --git a/app/Classes/Modules/Affiliate/Standards/Rules/CanCreateAffiliate.php b/app/Classes/Modules/Affiliate/Standards/Rules/CanCreateAffiliate.php new file mode 100644 index 00000000..03e7cee1 --- /dev/null +++ b/app/Classes/Modules/Affiliate/Standards/Rules/CanCreateAffiliate.php @@ -0,0 +1,52 @@ +affiliateCreateValidation = $affiliateCreateValidation; + } + + /** + * @param AffiliateObject $object + * @return bool + */ + protected function authorized($object): bool + { + // Authorization is handled by policy in controller + return true; + } + + /** + * @param AffiliateObject $object + * @return bool + * @throws \App\Classes\Exceptions\RequestValidationException + */ + protected function validators($object): bool + { + return $this->affiliateCreateValidation->validate($object); + } + + /** + * @param AffiliateObject $object + * @return bool + */ + protected function criteria($object): bool + { + // No additional criteria needed for affiliate creation + return true; + } +} diff --git a/app/Classes/Modules/Affiliate/Standards/Validators/AffiliateCreateValidation.php b/app/Classes/Modules/Affiliate/Standards/Validators/AffiliateCreateValidation.php new file mode 100644 index 00000000..85fa5079 --- /dev/null +++ b/app/Classes/Modules/Affiliate/Standards/Validators/AffiliateCreateValidation.php @@ -0,0 +1,69 @@ + $object->getCode(), + 'campaign_name' => $object->getCampaignName(), + 'campaign_description' => $object->getCampaignDescription(), + 'is_active' => $object->getIsActive(), + ]; + } + + /** + * @return array + */ + protected function rules(): array + { + return [ + 'code' => [ + 'nullable', + 'string', + 'min:3', + 'max:255', + 'regex:/^[a-zA-Z0-9_-]+$/', + function ($attribute, $value, $fail) { + if (!empty($value)) { + $uppercaseCode = strtoupper($value); + $exists = Affiliate::whereRaw('UPPER(code) = ?', [$uppercaseCode]) + ->whereNull('deleted_at') + ->exists(); + + if ($exists) { + $fail('Affiliate code already exists. Please choose a different code.'); + } + } + }, + ], + 'campaign_name' => 'required|string|max:255', + 'campaign_description' => 'nullable|string|max:65535', + 'is_active' => 'required|boolean', + ]; + } + + /** + * @return array + */ + protected function messages(): array + { + return [ + 'code.regex' => 'The affiliate code may only contain letters, numbers, hyphens, and underscores.', + 'code.min' => 'The affiliate code must be at least 3 characters.', + 'campaign_name.required' => 'A campaign name is required.', + 'campaign_name.max' => 'The campaign name cannot exceed 255 characters.', + ]; + } +} diff --git a/app/Classes/Modules/Bookings/ControllersLogic/CreateBookingLogic.php b/app/Classes/Modules/Bookings/ControllersLogic/CreateBookingLogic.php index f9c79326..540d94f3 100644 --- a/app/Classes/Modules/Bookings/ControllersLogic/CreateBookingLogic.php +++ b/app/Classes/Modules/Bookings/ControllersLogic/CreateBookingLogic.php @@ -15,6 +15,7 @@ use App\Classes\Modules\Vouchers\DataTransferObjects\CreateVoucherifyOrderObject use App\Classes\Modules\Vouchers\Services\Voucherify\CreatesVoucherifyOrder; use App\Classes\Modules\Vouchers\Services\CreatesVoucherEntityMapping; use App\Classes\Modules\Vouchers\DataTransferObjects\VoucherEntityObject; +use App\Classes\Modules\Affiliate\Services\TracksAffiliateOrder; use App\Classes\ValueObjects\Constants\BookingAttributeNames; use App\Classes\ValueObjects\Constants\VoucherifyEntityType; use App\Http\Resources\BookingResource; @@ -60,6 +61,9 @@ class CreateBookingLogic extends AbstractControllerLogic /** @var CreatesVoucherEntityMapping */ private $createsVoucherEntityMapping; + /** @var TracksAffiliateOrder */ + private $tracksAffiliateOrder; + /** * CreateBookingLogic constructor. * @param CanCreateBooking $canCreateBooking @@ -70,8 +74,9 @@ class CreateBookingLogic extends AbstractControllerLogic * @param CheckMilestoneForRewardProcessor $checkMilestonesForRewardProcessor * @param CreatesVoucherifyOrder $createsVoucherifyOrder * @param CreatesVoucherEntityMapping $createsVoucherEntityMapping + * @param TracksAffiliateOrder $tracksAffiliateOrder */ - public function __construct(CanCreateBooking $canCreateBooking, CreatesBooking $createsBooking, GeneratesBookingMarking $generatesBookingMarking, FetchesCompany $fetchesCompany, BookingToPerfexCRMProcessor $bookingToPerfexCRMProcessor, CheckMilestonesForRewardProcessor $checkMilestonesForRewardProcessor, CreatesVoucherifyOrder $createsVoucherifyOrder, CreatesVoucherEntityMapping $createsVoucherEntityMapping) + public function __construct(CanCreateBooking $canCreateBooking, CreatesBooking $createsBooking, GeneratesBookingMarking $generatesBookingMarking, FetchesCompany $fetchesCompany, BookingToPerfexCRMProcessor $bookingToPerfexCRMProcessor, CheckMilestonesForRewardProcessor $checkMilestonesForRewardProcessor, CreatesVoucherifyOrder $createsVoucherifyOrder, CreatesVoucherEntityMapping $createsVoucherEntityMapping, TracksAffiliateOrder $tracksAffiliateOrder) { $this->canCreateBooking = $canCreateBooking; $this->createsBooking = $createsBooking; @@ -81,6 +86,7 @@ class CreateBookingLogic extends AbstractControllerLogic $this->checkMilestonesForRewardProcessor = $checkMilestonesForRewardProcessor; $this->createsVoucherifyOrder = $createsVoucherifyOrder; $this->createsVoucherEntityMapping = $createsVoucherEntityMapping; + $this->tracksAffiliateOrder = $tracksAffiliateOrder; } @@ -147,6 +153,9 @@ class CreateBookingLogic extends AbstractControllerLogic $this->recordVoucherifyOrderInfo($voucherify_order_id, $booking); $this->recordVoucherifyCustomerInfo($voucherify_customer_id, $user); + // Track affiliate order if user has an affiliate code + $this->tracksAffiliateOrder->execute($booking); + //cief todo: case study 5 voucherify // $user = $company->employees()->first(); // $this->checkMilestonesForRewardProcessor->execute($user, [Milestones::MILESTONE_5]); diff --git a/app/Http/Controllers/Affiliate/CreateAffiliateController.php b/app/Http/Controllers/Affiliate/CreateAffiliateController.php new file mode 100644 index 00000000..9a0679c7 --- /dev/null +++ b/app/Http/Controllers/Affiliate/CreateAffiliateController.php @@ -0,0 +1,23 @@ +execute($request); + } +} + diff --git a/app/Http/Controllers/Affiliate/DeleteAffiliateController.php b/app/Http/Controllers/Affiliate/DeleteAffiliateController.php new file mode 100644 index 00000000..5cb79882 --- /dev/null +++ b/app/Http/Controllers/Affiliate/DeleteAffiliateController.php @@ -0,0 +1,37 @@ +authorize('delete', $affiliate); + return $logic->execute($id); + } + + /** + * Authorize a given action for the current user. + * + * @param mixed $ability + * @param mixed|array $arguments + * @return \Illuminate\Auth\Access\Response + * + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function authorize($ability, $arguments = []) + { + return app(\Illuminate\Contracts\Auth\Access\Gate::class)->authorize($ability, $arguments); + } +} + diff --git a/app/Http/Controllers/Affiliate/GetAffiliateSettingsController.php b/app/Http/Controllers/Affiliate/GetAffiliateSettingsController.php new file mode 100644 index 00000000..c3426ceb --- /dev/null +++ b/app/Http/Controllers/Affiliate/GetAffiliateSettingsController.php @@ -0,0 +1,23 @@ +execute($request); + } +} + diff --git a/app/Http/Controllers/Affiliate/ListAffiliatesController.php b/app/Http/Controllers/Affiliate/ListAffiliatesController.php new file mode 100644 index 00000000..f4487782 --- /dev/null +++ b/app/Http/Controllers/Affiliate/ListAffiliatesController.php @@ -0,0 +1,23 @@ +execute($request); + } +} + diff --git a/app/Http/Controllers/Affiliate/UpdateAffiliateController.php b/app/Http/Controllers/Affiliate/UpdateAffiliateController.php new file mode 100644 index 00000000..9ec26c0c --- /dev/null +++ b/app/Http/Controllers/Affiliate/UpdateAffiliateController.php @@ -0,0 +1,39 @@ +authorize('update', $affiliate); + return $logic->execute($request, $id); + } + + /** + * Authorize a given action for the current user. + * + * @param mixed $ability + * @param mixed|array $arguments + * @return \Illuminate\Auth\Access\Response + * + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function authorize($ability, $arguments = []) + { + return app(\Illuminate\Contracts\Auth\Access\Gate::class)->authorize($ability, $arguments); + } +} + diff --git a/app/Http/Controllers/Affiliate/UpdateAffiliateSettingsController.php b/app/Http/Controllers/Affiliate/UpdateAffiliateSettingsController.php new file mode 100644 index 00000000..e8e8a00f --- /dev/null +++ b/app/Http/Controllers/Affiliate/UpdateAffiliateSettingsController.php @@ -0,0 +1,24 @@ +execute($request); + } +} + diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php index 2395ddcc..bd955e53 100644 --- a/app/Http/Middleware/RedirectIfAuthenticated.php +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -4,7 +4,9 @@ namespace App\Http\Middleware; use App\Providers\RouteServiceProvider; use Closure; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Tymon\JWTAuth\Facades\JWTAuth; class RedirectIfAuthenticated { @@ -18,10 +20,48 @@ class RedirectIfAuthenticated */ public function handle($request, Closure $next, $guard = null) { + // Check session-based authentication if (Auth::guard($guard)->check()) { - return redirect(RouteServiceProvider::HOME); + return redirect()->route('dashboard'); + } + + // Check JWT authentication (since tokens are in localStorage, check cookies/headers) + try { + $token = + $this->getBearerToken($request) ?? + $request->get('token') ?? + $request->cookie('access_token') ?? + $request->cookie('user-token'); + + if ($token) { + try { + JWTAuth::setToken($token); + $user = JWTAuth::authenticate(); + if ($user) { + return redirect()->route('dashboard'); + } + } catch (\Exception $e) { + // Token invalid, continue + } + } + } catch (\Exception $e) { + // JWT check failed, continue } return $next($request); } + + /** + * Extract Bearer token from Authorization header + */ + private function getBearerToken(Request $request): ?string + { + if (!$request->hasHeader('Authorization')) { + return null; + } + + return preg_match('/Bearer\s+(.*)$/i', $request->header('Authorization'), $matches) + ? $matches[1] + : null; + } } diff --git a/app/Http/Middleware/TrackAffiliateClick.php b/app/Http/Middleware/TrackAffiliateClick.php new file mode 100644 index 00000000..205177f1 --- /dev/null +++ b/app/Http/Middleware/TrackAffiliateClick.php @@ -0,0 +1,146 @@ +checkWebAuth() || $this->checkJwtAuth($request); + + if (!$isAuthenticated && $request->filled('tracking')) { + $trackingCode = $this->validateTrackingCode($request->get('tracking')); + if ($trackingCode) { + $this->trackClick($trackingCode); + } + } + + return $next($request); + } + + /** + * Validate and sanitize tracking code + * + * @param mixed $code + * @return string|null + */ + private function validateTrackingCode($code): ?string + { + if (!is_string($code)) { + return null; + } + + // Trim whitespace + $code = trim($code); + + // Check length (max 255 chars based on database schema) + if (strlen($code) > 255 || strlen($code) < 1) { + return null; + } + + // Only allow alphanumeric characters, hyphens, and underscores + // This matches typical affiliate code formats + if (!preg_match('/^[a-zA-Z0-9_-]+$/', $code)) { + return null; + } + + return $code; + } + + /** + * Check if user is authenticated via WEB session + */ + private function checkWebAuth(): bool + { + try { + if (Auth::guard('web')->check()) { + return true; + } + + // fallback session check + foreach (Session::all() as $key => $value) { + if (strpos($key, 'login_web_') === 0 && !empty($value)) { + return true; + } + } + } catch (\Exception $e) { + Log::warning('AffiliateClickMiddleware: Web auth error', ['error' => $e->getMessage()]); + } + + return false; + } + + /** + * Check if user is authenticated via JWT token + */ + private function checkJwtAuth(Request $request): bool + { + try { + $token = + $this->getBearerToken($request) ?? + $request->get('token') ?? + $request->cookie('access_token') ?? + $request->cookie('user-token'); + + if (!$token) { + return false; + } + + JWTAuth::setToken($token); + $user = JWTAuth::authenticate(); + + if ($user) { + return true; + } + } catch (\Exception $e) { + Log::debug('AffiliateClickMiddleware: JWT invalid', ['error' => $e->getMessage()]); + } + + return false; + } + + /** + * Extract Bearer token + */ + private function getBearerToken(Request $request): ?string + { + if (!$request->hasHeader('Authorization')) return null; + + return preg_match('/Bearer\s+(.*)$/i', $request->header('Authorization'), $m) + ? $m[1] + : null; + } + + /** + * Track affiliate click once per session + */ + private function trackClick(string $trackingCode): void + { + // Use a single session key with an array to prevent session bloat + $trackedClicks = Session::get('affiliate_clicks_tracked', []); + + // Check if this code has already been tracked in this session + if (in_array($trackingCode, $trackedClicks)) { + return; // Already tracked + } + + // Execute tracking + app(TracksAffiliateClick::class)->execute($trackingCode); + + // Add to tracked list + $trackedClicks[] = $trackingCode; + + // Limit array size to prevent DoS (keep only last 10 clicks) + if (count($trackedClicks) > 10) { + $trackedClicks = array_slice($trackedClicks, -10); + } + + Session::put('affiliate_clicks_tracked', $trackedClicks); + } +} diff --git a/app/Http/Requests/CreateAffiliateRequest.php b/app/Http/Requests/CreateAffiliateRequest.php new file mode 100644 index 00000000..babdadf6 --- /dev/null +++ b/app/Http/Requests/CreateAffiliateRequest.php @@ -0,0 +1,58 @@ + [ + 'nullable', + 'string', + 'min:3', + 'max:255', + 'regex:/^[a-zA-Z0-9_-]+$/', + ], + 'campaign_name' => 'required|string|max:255', + 'campaign_description' => 'nullable|string|max:65535', + 'is_active' => 'nullable|boolean', + ]; + } + + /** + * Get custom error messages for validation rules. + * + * @return array + */ + public function messages() + { + return [ + 'code.regex' => 'The affiliate code may only contain letters, numbers, hyphens, and underscores.', + 'code.unique' => 'This affiliate code is already in use.', + 'code.min' => 'The affiliate code must be at least 3 characters.', + 'campaign_name.required' => 'A campaign name is required.', + 'campaign_name.max' => 'The campaign name cannot exceed 255 characters.', + ]; + } +} + + diff --git a/app/Http/Requests/UpdateAffiliateRequest.php b/app/Http/Requests/UpdateAffiliateRequest.php new file mode 100644 index 00000000..d4dda93e --- /dev/null +++ b/app/Http/Requests/UpdateAffiliateRequest.php @@ -0,0 +1,47 @@ + 'required|string|max:255', + 'campaign_description' => 'nullable|string|max:65535', + 'is_active' => 'nullable|boolean', + ]; + } + + /** + * Get custom error messages for validation rules. + * + * @return array + */ + public function messages() + { + return [ + 'campaign_name.required' => 'A campaign name is required.', + 'campaign_name.max' => 'The campaign name cannot exceed 255 characters.', + ]; + } +} + + diff --git a/app/Http/Requests/UpdateAffiliateSettingsRequest.php b/app/Http/Requests/UpdateAffiliateSettingsRequest.php new file mode 100644 index 00000000..b05f7040 --- /dev/null +++ b/app/Http/Requests/UpdateAffiliateSettingsRequest.php @@ -0,0 +1,47 @@ + 'required|integer|min:1|max:365', + ]; + } + + /** + * Get custom error messages for validation rules. + * + * @return array + */ + public function messages() + { + return [ + 'code_binding_days.required' => 'The code binding days setting is required.', + 'code_binding_days.integer' => 'The code binding days must be a number.', + 'code_binding_days.min' => 'The code binding days must be at least 1 day.', + 'code_binding_days.max' => 'The code binding days cannot exceed 365 days.', + ]; + } +} + + diff --git a/app/Http/Resources/AffiliateResource.php b/app/Http/Resources/AffiliateResource.php new file mode 100644 index 00000000..404e0e5d --- /dev/null +++ b/app/Http/Resources/AffiliateResource.php @@ -0,0 +1,32 @@ + $this->id, + 'code' => $this->code, + 'campaign_name' => $this->campaign_name, + 'campaign_description' => $this->campaign_description, + 'is_active' => (bool) $this->is_active, + 'created_by' => $this->created_by, + 'clicks_count' => $this->clicks_count, + 'registrations_count' => $this->registrations_count, + 'orders_count' => $this->orders_count, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} + diff --git a/app/Models/Affiliate.php b/app/Models/Affiliate.php new file mode 100644 index 00000000..71d0115a --- /dev/null +++ b/app/Models/Affiliate.php @@ -0,0 +1,63 @@ + 'boolean', + 'clicks_count' => 'integer', + 'registrations_count' => 'integer', + 'orders_count' => 'integer' + ]; + + /** + * @return BelongsTo + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'user_affiliates', 'affiliate_id', 'user_id') + ->withPivot('clicked_at', 'registered_at') + ->withTimestamps(); + } + + /** + * Generate the affiliate tracking link + * + * @return string + */ + public function getTrackingLinkAttribute(): string + { + $baseUrl = config('app.url'); + return $baseUrl . '/signup?tracking=' . $this->code; + } +} + diff --git a/app/Models/User.php b/app/Models/User.php index 46e8635f..d396fc20 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -120,4 +120,14 @@ class User extends AbstractModel implements { return $this->morphMany(KeyValuePair::class, 'owner'); } + + /** + * @return BelongsToMany + */ + public function affiliates(): BelongsToMany + { + return $this->belongsToMany(Affiliate::class, 'user_affiliates', 'user_id', 'affiliate_id') + ->withPivot('clicked_at', 'registered_at') + ->withTimestamps(); + } } diff --git a/app/Models/UserAffiliate.php b/app/Models/UserAffiliate.php new file mode 100644 index 00000000..bf8792e5 --- /dev/null +++ b/app/Models/UserAffiliate.php @@ -0,0 +1,39 @@ +belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function affiliate(): BelongsTo + { + return $this->belongsTo(Affiliate::class); + } +} + diff --git a/app/Policies/AffiliatePolicy.php b/app/Policies/AffiliatePolicy.php new file mode 100644 index 00000000..8a5b36a0 --- /dev/null +++ b/app/Policies/AffiliatePolicy.php @@ -0,0 +1,96 @@ +type, RoleTypes::ADMIN_ROLES); + } + + /** + * Determine whether the user can view any affiliates. + * + * @param User $user + * @return bool + */ + public function viewAny(User $user) + { + // Only admins can view affiliates list + return $this->isAdmin($user); + } + + /** + * Determine whether the user can view the affiliate. + * + * @param User $user + * @param Affiliate $affiliate + * @return bool + */ + public function view(User $user, Affiliate $affiliate) + { + return $this->isAdmin($user); + } + + /** + * Determine whether the user can create affiliates. + * + * @param User $user + * @return bool + */ + public function create(User $user) + { + return $this->isAdmin($user); + } + + /** + * Determine whether the user can update the affiliate. + * + * @param User $user + * @param Affiliate $affiliate + * @return bool + */ + public function update(User $user, Affiliate $affiliate) + { + return $this->isAdmin($user); + } + + /** + * Determine whether the user can delete the affiliate. + * + * @param User $user + * @param Affiliate $affiliate + * @return bool + */ + public function delete(User $user, Affiliate $affiliate) + { + return $this->isAdmin($user); + } + + /** + * Determine whether the user can manage affiliate settings. + * + * @param User $user + * @return bool + */ + public function manageSettings(User $user) + { + return $this->isAdmin($user); + } +} + + diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index b14e5898..31a1ad6f 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -13,6 +13,7 @@ class AuthServiceProvider extends ServiceProvider */ protected $policies = [ // 'App\Model' => 'App\Policies\ModelPolicy', + \App\Models\Affiliate::class => \App\Policies\AffiliatePolicy::class, ]; /** diff --git a/database/migrations/2025_11_16_190751_create_affiliates_table.php b/database/migrations/2025_11_16_190751_create_affiliates_table.php new file mode 100644 index 00000000..48e2cf1e --- /dev/null +++ b/database/migrations/2025_11_16_190751_create_affiliates_table.php @@ -0,0 +1,43 @@ +id(); + $table->string('code')->unique(); + $table->string('campaign_name'); + $table->text('campaign_description')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedInteger('clicks_count')->default(0); + $table->unsignedInteger('registrations_count')->default(0); + $table->unsignedInteger('orders_count')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index('code'); + $table->index('is_active'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('affiliates'); + } +} diff --git a/database/migrations/2025_11_16_191326_create_user_affiliates_table.php b/database/migrations/2025_11_16_191326_create_user_affiliates_table.php new file mode 100644 index 00000000..60a7349d --- /dev/null +++ b/database/migrations/2025_11_16_191326_create_user_affiliates_table.php @@ -0,0 +1,42 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->unsignedBigInteger('affiliate_id'); + $table->timestamp('clicked_at')->nullable(); + $table->timestamp('registered_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'affiliate_id']); + $table->index('user_id'); + $table->index('affiliate_id'); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('affiliate_id')->references('id')->on('affiliates')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('user_affiliates'); + } +} diff --git a/database/migrations/2025_11_28_002359_add_foreign_key_to_affiliates_created_by.php b/database/migrations/2025_11_28_002359_add_foreign_key_to_affiliates_created_by.php new file mode 100644 index 00000000..0ad17e29 --- /dev/null +++ b/database/migrations/2025_11_28_002359_add_foreign_key_to_affiliates_created_by.php @@ -0,0 +1,37 @@ +foreign('created_by') + ->references('id') + ->on('users') + ->onDelete('set null'); // Set to null if user is deleted, preserving affiliate record + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('affiliates', function (Blueprint $table) { + // Drop the foreign key constraint + $table->dropForeign(['created_by']); + }); + } +} diff --git a/resources/assets/vue/components/accounts/forms/RegistrationFormComponent.vue b/resources/assets/vue/components/accounts/forms/RegistrationFormComponent.vue index a046a7eb..d7a84c0b 100644 --- a/resources/assets/vue/components/accounts/forms/RegistrationFormComponent.vue +++ b/resources/assets/vue/components/accounts/forms/RegistrationFormComponent.vue @@ -205,7 +205,9 @@ }, submitForm(){ - this.step === 2 ? this.submit(this.route('api.account.registration.register'), 'post', 'registrationSection', false, false) : this.changeStep('next'); + const urlParams = new URLSearchParams(window.location.search); + const tracking = urlParams.get('tracking'); + this.step === 2 ? this.submit(this.route('api.account.registration.register') + (tracking ? '?tracking=' + tracking : ''), 'post', 'registrationSection', false, false) : this.changeStep('next'); }, successHandler(response){ // fire to gtag manager for fb pixel tracking: CompleteRegistration diff --git a/resources/assets/vue/components/settings/elements/AffiliateSectionComponent.vue b/resources/assets/vue/components/settings/elements/AffiliateSectionComponent.vue new file mode 100644 index 00000000..8da2cdbf --- /dev/null +++ b/resources/assets/vue/components/settings/elements/AffiliateSectionComponent.vue @@ -0,0 +1,137 @@ + + + \ No newline at end of file diff --git a/resources/assets/vue/components/settings/elements/AffiliateSettingsComponent.vue b/resources/assets/vue/components/settings/elements/AffiliateSettingsComponent.vue new file mode 100644 index 00000000..f0ecafa7 --- /dev/null +++ b/resources/assets/vue/components/settings/elements/AffiliateSettingsComponent.vue @@ -0,0 +1,126 @@ + + + diff --git a/resources/assets/vue/components/settings/elements/AffiliateSingleItemComponent.vue b/resources/assets/vue/components/settings/elements/AffiliateSingleItemComponent.vue new file mode 100644 index 00000000..7fcf9bfc --- /dev/null +++ b/resources/assets/vue/components/settings/elements/AffiliateSingleItemComponent.vue @@ -0,0 +1,85 @@ + + + + diff --git a/resources/assets/vue/components/settings/forms/AffiliateFormComponent.vue b/resources/assets/vue/components/settings/forms/AffiliateFormComponent.vue new file mode 100644 index 00000000..691e0902 --- /dev/null +++ b/resources/assets/vue/components/settings/forms/AffiliateFormComponent.vue @@ -0,0 +1,253 @@ + + + diff --git a/resources/assets/vue/components/settings/forms/DeleteAffiliateFormComponent.vue b/resources/assets/vue/components/settings/forms/DeleteAffiliateFormComponent.vue new file mode 100644 index 00000000..4fd68917 --- /dev/null +++ b/resources/assets/vue/components/settings/forms/DeleteAffiliateFormComponent.vue @@ -0,0 +1,46 @@ + + + diff --git a/resources/assets/vue/general/mixins/guards.js b/resources/assets/vue/general/mixins/guards.js index 3e5aaea5..f706135d 100644 --- a/resources/assets/vue/general/mixins/guards.js +++ b/resources/assets/vue/general/mixins/guards.js @@ -5,8 +5,25 @@ export default { (this.$store.getters.isAuthenticated && !this.isProtectedRoute()&& !this.isWithTokenRoute()) ? window.location.href = this.route('dashboard') : ''; }, isProtectedRoute(){ - const unprotectedRoutes = [this.route('login'), this.route('signup'), this.route('account.email.verification')]; - return !unprotectedRoutes.includes(window.location.href); + // Compare pathname only (without query parameters) to handle affiliate tracking links + const currentPath = window.location.pathname; + + // Define unprotected routes as pathnames directly + // This ensures affiliate tracking links like /signup?tracking=xxx work correctly + const unprotectedPaths = [ + '/', // login route + '/signup', // signup route + '/account/email/verification' // email verification route (may have token param) + ]; + + // Check if current path matches any unprotected path + // For email verification, check if path starts with the base path + if (currentPath.startsWith('/account/email/verification')) { + return false; // It's an email verification route (unprotected) + } + + // Check exact matches for other routes + return !unprotectedPaths.includes(currentPath); }, isWithTokenRoute(){ return window.location.href.includes(this.route('account.password.reset')); diff --git a/resources/views/pages/settings.blade.php b/resources/views/pages/settings.blade.php index 9be84129..dbd1c9de 100644 --- a/resources/views/pages/settings.blade.php +++ b/resources/views/pages/settings.blade.php @@ -287,6 +287,31 @@ +
@@ -766,6 +791,13 @@
+
diff --git a/routes/affiliate.php b/routes/affiliate.php new file mode 100644 index 00000000..1badb6b7 --- /dev/null +++ b/routes/affiliate.php @@ -0,0 +1,14 @@ + 'affiliate', 'as' => 'affiliate.'], function () { + Route::get('/settings', 'Affiliate\GetAffiliateSettingsController@get')->name('settings.get'); + Route::post('/settings', 'Affiliate\UpdateAffiliateSettingsController@update')->name('settings.update'); + + Route::get('/list', 'Affiliate\ListAffiliatesController@list')->name('list'); + Route::post('/create', 'Affiliate\CreateAffiliateController@create')->name('create'); + Route::put('/update/{id}', 'Affiliate\UpdateAffiliateController@update')->name('update'); + Route::delete('/delete/{id}', 'Affiliate\DeleteAffiliateController@delete')->name('delete'); +}); + diff --git a/routes/api.php b/routes/api.php index 7bd4c1fc..e8c8a651 100644 --- a/routes/api.php +++ b/routes/api.php @@ -79,6 +79,8 @@ Route::group(['middleware' => 'api', 'prefix' => 'v1', 'as' => 'api.'], function require __DIR__ . '/key_value_pair.php'; require __DIR__ . '/setting.php'; + + require __DIR__ . '/affiliate.php'; // require __DIR__ . '/rate.php'; // require __DIR__ . '/receipt.php'; diff --git a/routes/web.php b/routes/web.php index 128eea4f..8e02e7f0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -59,7 +59,10 @@ Route::get('', function () { Route::get('/signup', function () { return view('pages.accounts.sign_up'); -})->name('signup'); +})->middleware([ + \App\Http\Middleware\TrackAffiliateClick::class, + 'guest:web' +])->name('signup'); Route::get('/account/email/verification/{token}', function ($token) { return view('pages.accounts.email_verified', ['token' => $token]); From 4396b1a59b4e2aeb6b684027998c39e06a11770a Mon Sep 17 00:00:00 2001 From: Edmond Lang Date: Mon, 26 Jan 2026 00:44:30 +0800 Subject: [PATCH 2/2] affilate updates --- .../ControllersLogic/CreateCustomerLogic.php | 6 +++-- ...-payment-landing-page-general-fb.blade.php | 6 ++--- ...ment-landing-page-general-google.blade.php | 6 ++--- ...nt-landing-page-wf-comparison-fb.blade.php | 6 ++--- ...anding-page-wf-comparison-google.blade.php | 6 ++--- ...1688-vip-landing-page-general-fb.blade.php | 6 ++--- ...-vip-landing-page-general-google.blade.php | 6 ++--- resources/views/pages/settings.blade.php | 23 +++++++++++++++++++ 8 files changed, 45 insertions(+), 20 deletions(-) diff --git a/app/Classes/Modules/Accounts/ControllersLogic/CreateCustomerLogic.php b/app/Classes/Modules/Accounts/ControllersLogic/CreateCustomerLogic.php index d681d182..212977be 100644 --- a/app/Classes/Modules/Accounts/ControllersLogic/CreateCustomerLogic.php +++ b/app/Classes/Modules/Accounts/ControllersLogic/CreateCustomerLogic.php @@ -183,8 +183,10 @@ class CreateCustomerLogic extends AbstractControllerLogic } } - // Track affiliate registration if user came from affiliate link - $this->tracksAffiliateRegistration->execute($user, $request->get('tracking')); + // Track affiliate registration if user came from affiliate link. + // Prefer ?tracking= from request; fallback to cookie set by TrackAffiliateClick on /signup visit. + $code = $request->get('tracking') ?? $request->cookie(\App\Classes\Modules\Affiliate\Services\TracksAffiliateClick::COOKIE_NAME); + $this->tracksAffiliateRegistration->execute($user, $code); return $this->response($this->authenticationProcessor->execute($request, false)); diff --git a/resources/views/pages/landing/1688-vip-payment-landing-page-general-fb.blade.php b/resources/views/pages/landing/1688-vip-payment-landing-page-general-fb.blade.php index 02e836dc..b42064e9 100644 --- a/resources/views/pages/landing/1688-vip-payment-landing-page-general-fb.blade.php +++ b/resources/views/pages/landing/1688-vip-payment-landing-page-general-fb.blade.php @@ -531,7 +531,7 @@
@@ -881,7 +881,7 @@
- 立即获取子账号 + 立即获取子账号
@@ -1233,7 +1233,7 @@

- 立即获取子账号 + 立即获取子账号

🕐 客服时间:周一至周五 9:00 AM - 6:00 PM(马来西亚时间)
diff --git a/resources/views/pages/landing/1688-vip-payment-landing-page-general-google.blade.php b/resources/views/pages/landing/1688-vip-payment-landing-page-general-google.blade.php index 6fd84a11..abeede71 100644 --- a/resources/views/pages/landing/1688-vip-payment-landing-page-general-google.blade.php +++ b/resources/views/pages/landing/1688-vip-payment-landing-page-general-google.blade.php @@ -531,7 +531,7 @@

- 立即获取子账号 + 立即获取子账号
@@ -881,7 +881,7 @@
- 立即获取子账号 + 立即获取子账号
@@ -1233,7 +1233,7 @@

- 立即获取子账号 + 立即获取子账号

🕐 客服时间:周一至周五 9:00 AM - 6:00 PM(马来西亚时间)
diff --git a/resources/views/pages/landing/1688-vip-payment-landing-page-wf-comparison-fb.blade.php b/resources/views/pages/landing/1688-vip-payment-landing-page-wf-comparison-fb.blade.php index 218d3e75..f79e7ca6 100644 --- a/resources/views/pages/landing/1688-vip-payment-landing-page-wf-comparison-fb.blade.php +++ b/resources/views/pages/landing/1688-vip-payment-landing-page-wf-comparison-fb.blade.php @@ -530,7 +530,7 @@

- 立即获取子账号 + 立即获取子账号
@@ -887,7 +887,7 @@
- 立即获取子账号 + 立即获取子账号
@@ -1264,7 +1264,7 @@

- 立即获取子账号 + 立即获取子账号

🕐 客服时间:周一至周五 9:00 AM - 6:00 PM(马来西亚时间)
diff --git a/resources/views/pages/landing/1688-vip-payment-landing-page-wf-comparison-google.blade.php b/resources/views/pages/landing/1688-vip-payment-landing-page-wf-comparison-google.blade.php index 8598d363..b1a97636 100644 --- a/resources/views/pages/landing/1688-vip-payment-landing-page-wf-comparison-google.blade.php +++ b/resources/views/pages/landing/1688-vip-payment-landing-page-wf-comparison-google.blade.php @@ -530,7 +530,7 @@

- 立即获取子账号 + 立即获取子账号
@@ -887,7 +887,7 @@
- 立即获取子账号 + 立即获取子账号
@@ -1264,7 +1264,7 @@

- 立即获取子账号 + 立即获取子账号

🕐 客服时间:周一至周五 9:00 AM - 6:00 PM(马来西亚时间)
diff --git a/resources/views/pages/landing/en-1688-vip-landing-page-general-fb.blade.php b/resources/views/pages/landing/en-1688-vip-landing-page-general-fb.blade.php index dcaf9f5e..5298dd3e 100644 --- a/resources/views/pages/landing/en-1688-vip-landing-page-general-fb.blade.php +++ b/resources/views/pages/landing/en-1688-vip-landing-page-general-fb.blade.php @@ -578,7 +578,7 @@

- Get Your Sub-Account Now + Get Your Sub-Account Now
@@ -928,7 +928,7 @@
- Get Your Sub-Account Now + Get Your Sub-Account Now
@@ -1280,7 +1280,7 @@

- Get Your Sub-Account Now + Get Your Sub-Account Now

🕐 Customer Service Hours: Monday to Friday 9:00 AM - 6:00 PM (Malaysia Time)
diff --git a/resources/views/pages/landing/en-1688-vip-landing-page-general-google.blade.php b/resources/views/pages/landing/en-1688-vip-landing-page-general-google.blade.php index 1ef2e46e..a24c12e5 100644 --- a/resources/views/pages/landing/en-1688-vip-landing-page-general-google.blade.php +++ b/resources/views/pages/landing/en-1688-vip-landing-page-general-google.blade.php @@ -578,7 +578,7 @@

- Get Your Sub-Account Now + Get Your Sub-Account Now
@@ -928,7 +928,7 @@
- Get Your Sub-Account Now + Get Your Sub-Account Now
@@ -1280,7 +1280,7 @@

- Get Your Sub-Account Now + Get Your Sub-Account Now

🕐 Customer Service Hours: Monday to Friday 9:00 AM - 6:00 PM (Malaysia Time)
diff --git a/resources/views/pages/settings.blade.php b/resources/views/pages/settings.blade.php index dbd1c9de..45a8bf2a 100644 --- a/resources/views/pages/settings.blade.php +++ b/resources/views/pages/settings.blade.php @@ -78,6 +78,29 @@ +

+
+
+
+
+
+
+
+ +
+
+
Affiliate
+
+
+
+
+
+
+
+